This commit is contained in:
whatevertogo
2026-03-14 21:33:54 +08:00
parent 1612f6a2f1
commit 6127d4ef3f
116 changed files with 510 additions and 18959 deletions
+8
View File
@@ -0,0 +1,8 @@
{
"permissions": {
"allow": [
"Bash(cd:*)",
"Bash(python:*)"
]
}
}
+49 -11
View File
@@ -203,7 +203,42 @@ src-new/
- `SessionRef` 是结构化发送目标 schema,不是 capability。
- `internal.*` 与 `handler.*` 命名空间保留给框架内部使用,不属于公开内建 capability 列表。
## 7. 兼容层现状
## 7. 兼容层现状(已弃用)
> **⚠️ 重要:兼容层已弃用,将在下个大版本移除**
>
> - 旧插件请使用 **AstrBot 主程序** 运行(主程序有完整的 `StarManager` 支持)
> - 新插件请迁移到 `astrbot_sdk` 顶层入口
> - 导入兼容层会触发 `DeprecationWarning`
### 7.0 迁移指南
**旧插件开发者有两个选择:**
1. **继续使用旧 API**:由 AstrBot 主程序运行,无需修改代码
2. **迁移到 v4 SDK**:
```python
# 旧版(由主程序运行)
from astrbot.api.event import filter, AstrMessageEvent
from astrbot.api.star import Context, Star
class MyPlugin(Star):
def __init__(self, context: Context):
self.context = context
@filter.command("hello")
async def hello(self, event: AstrMessageEvent):
yield event.plain_result("Hello!")
# 新版(v4 SDK)
from astrbot_sdk import Star, on_command, MessageEvent
class MyPlugin(Star):
@on_command("hello")
async def hello(self, event: MessageEvent):
return event.reply("Hello!")
```
### 7.1 等价或接近等价的兼容面
@@ -253,30 +288,34 @@ src-new/
## 8. 对插件作者的导入建议
### 推荐的新代码
### 推荐的新代码(v4)
```python
from astrbot_sdk import Star, Context, MessageEvent
from astrbot_sdk.decorators import on_command, on_message, provide_capability
```
### 仍受支持的旧代码
### ~~仍受支持的旧代码~~(已弃用)
> ⚠️ 以下导入路径已弃用,将在下个大版本移除。旧插件请使用 AstrBot 主程序运行。
```python
# 已弃用 - 请迁移到 v4 或使用主程序
from astrbot_sdk.api.event import AstrMessageEvent
from astrbot_sdk.api.star.context import Context
from astrbot_sdk.api.event.filter import filter
```
### 旧包名 facade
### ~~旧包名 facade~~(已弃用)
> ⚠️ 以下导入路径已弃用,将在下个大版本移除。
```python
# 已弃用 - 请迁移到 v4 或使用主程序
from astrbot.api.star import Star
from astrbot.core.utils.session_waiter import session_waiter
```
只有在需要兼容现有旧插件时才应继续使用这些路径;新插件应直接使用 v4 顶层入口。
## 9. 本地开发与测试
当前仓库已经提供一条受控的本地开发路径:
@@ -318,9 +357,8 @@ from astrbot.core.utils.session_waiter import session_waiter
- compat 支持级别变化时,同时更新本文档、`CLAUDE.md` / `AGENTS.md` 备注以及相关契约测试。
- `refactor.md` 不再承载现状;出现冲突时,一律以本文档和代码/测试为准。
## 11. 当前建议的后续演进方向
## 11. 后续演进方向
1. 继续把 runtime 对 compat 的认知收口到 `_legacy_runtime.py` 与 `_legacy_loader.py`。
2. 继续拆薄 `_legacy_api.py`,让 `LegacyContext` 更偏向 facade 和 orchestration。
3. 保持 `src-new/astrbot` 为受控 facade,不要把旧应用整棵树重新复制进来。
4. 用契约测试保护 capability 注册表、compat hook 执行和 facade 导入矩阵,避免文档再次漂移。
1. **当前版本**:兼容层已标记为 deprecated,触发 `DeprecationWarning`
2. **下个大版本**:完全移除兼容层,SDK 只支持 v4 新插件
3. 旧插件将由 AstrBot 主程序独立支持,不依赖 SDK 兼容层
+19
View File
@@ -1,5 +1,24 @@
# CLAUDE Notes
## ⚠️ 兼容层弃用通知 (2026-03-14)
**兼容层已标记为 deprecated,将在下个大版本移除。**
- 旧插件请使用 **AstrBot 主程序** 运行(主程序有完整的 `StarManager` 支持)
- 新插件请使用 `astrbot_sdk` 顶层入口
- 导入兼容层会触发 `DeprecationWarning`
**待移除的文件/目录**:
- `src-new/astrbot_sdk/_legacy_*.py` - 所有 legacy 私有模块
- `src-new/astrbot_sdk/api/` - 旧版 API 兼容层
- `src-new/astrbot_sdk/compat.py` - 顶层兼容入口
- `src-new/astrbot_sdk/protocol/legacy_adapter.py` - JSON-RPC 适配器
- `src-new/astrbot/` - 旧包名别名
- `test_plugin/old/` - 旧插件示例
- `tests_v4/test_legacy*.py` - legacy 相关测试
---
- 2026-03-12: Legacy `handshake` payloads only contain `event_type` / `handler_full_name` metadata and do not preserve v4 command/message trigger details such as command names, aliases, keywords, or regex. Any legacy-to-v4 handshake translation must approximate handlers as coarse event subscriptions and keep the raw handshake payload in metadata for lossless fallback.
- 2026-03-12: Legacy `src/astrbot_sdk/api/event/filter.py` exported a much larger decorator surface than `src-new/astrbot_sdk/api/event/filter.py`. Current compat coverage is enough for `command` / `regex` / `permission` and the exercised migration tests, but it is not a full drop-in replacement for every historical filter helper.
- 2026-03-13: Transport-pair startup tests for `SupervisorRuntime` must start a real peer on the opposite transport and provide an `initialize` response. Wiring only the supervisor side drops or captures the outgoing initialize message without replying, and `Peer.initialize()` then waits forever.
-7
View File
@@ -1,7 +0,0 @@
"""旧版 ``astrbot`` 包名兼容入口。"""
from loguru import logger
from . import api, core
__all__ = ["api", "core", "logger"]
-38
View File
@@ -1,38 +0,0 @@
"""旧版 ``astrbot.api`` 导入路径兼容入口。"""
from astrbot import logger
from astrbot.core import AstrBotConfig, html_renderer, sp
from astrbot_sdk.api import (
components,
event,
message,
message_components,
star,
)
from astrbot_sdk.api.event.filter import llm_tool
from . import platform, provider, util
def agent(*args, **kwargs):
raise NotImplementedError(
"astrbot.api.agent() 尚未在 v4 兼容层实现,请改用新版 capability/handler 结构。"
)
__all__ = [
"AstrBotConfig",
"agent",
"components",
"event",
"html_renderer",
"llm_tool",
"logger",
"message",
"message_components",
"platform",
"provider",
"sp",
"star",
"util",
]
-72
View File
@@ -1,72 +0,0 @@
"""旧版 ``astrbot.api.all`` 兼容入口。"""
from loguru import logger
from astrbot.api import AstrBotConfig, html_renderer, llm_tool, sp
from astrbot.api.event import (
AstrBotMessage,
AstrMessageEvent,
EventResultType,
Group,
MessageChain,
MessageEventResult,
MessageMember,
MessageType,
)
from astrbot.api.event.filter import (
EventMessageType,
EventMessageTypeFilter,
PlatformAdapterType,
PlatformAdapterTypeFilter,
command,
command_group,
event_message_type,
platform_adapter_type,
regex,
)
from astrbot.api.platform import PlatformMetadata
from astrbot.api.provider import (
LLMResponse,
Provider,
ProviderMetaData,
ProviderRequest,
ProviderType,
STTProvider,
)
from astrbot.api.star import Context, Star, register
from astrbot.api.message_components import * # noqa: F403
__all__ = [
"AstrBotConfig",
"AstrBotMessage",
"AstrMessageEvent",
"Context",
"EventMessageType",
"EventMessageTypeFilter",
"EventResultType",
"Group",
"LLMResponse",
"MessageChain",
"MessageEventResult",
"MessageMember",
"MessageType",
"PlatformAdapterType",
"PlatformAdapterTypeFilter",
"PlatformMetadata",
"Provider",
"ProviderMetaData",
"ProviderRequest",
"ProviderType",
"STTProvider",
"Star",
"command",
"command_group",
"event_message_type",
"html_renderer",
"llm_tool",
"logger",
"platform_adapter_type",
"regex",
"register",
"sp",
]
@@ -1,5 +0,0 @@
"""旧版 ``astrbot.api.components`` 导入路径兼容入口。"""
from astrbot_sdk.api.components.command import CommandComponent
__all__ = ["CommandComponent"]
@@ -1,5 +0,0 @@
"""旧版 ``astrbot.api.components.command`` 导入路径兼容入口。"""
from astrbot_sdk.api.components.command import CommandComponent
__all__ = ["CommandComponent"]
-37
View File
@@ -1,37 +0,0 @@
"""旧版 ``astrbot.api.event`` 导入路径兼容入口。"""
from astrbot_sdk.api.event import (
ADMIN,
AstrBotMessage,
AstrMessageEvent,
AstrMessageEventModel,
EventResultType,
EventType,
Group,
MessageChain,
MessageEventResult,
MessageMember,
MessageSesion,
MessageSession,
MessageType,
ResultContentType,
filter,
)
__all__ = [
"ADMIN",
"AstrBotMessage",
"AstrMessageEvent",
"AstrMessageEventModel",
"EventResultType",
"EventType",
"Group",
"MessageChain",
"MessageEventResult",
"MessageMember",
"MessageSesion",
"MessageSession",
"MessageType",
"ResultContentType",
"filter",
]
@@ -1,3 +0,0 @@
"""旧版 ``astrbot.api.event.filter`` 导入路径兼容入口。"""
from astrbot_sdk.api.event.filter import * # noqa: F403
@@ -1,3 +0,0 @@
"""旧版 ``astrbot.api.message_components`` 导入路径兼容入口。"""
from astrbot_sdk.api.message_components import * # noqa: F403
-33
View File
@@ -1,33 +0,0 @@
"""旧版 ``astrbot.api.platform`` 导入路径兼容入口。"""
from astrbot.core.message.components import * # noqa: F403
from astrbot_sdk.api.event import (
AstrBotMessage,
AstrMessageEvent,
Group,
MessageMember,
MessageType,
)
from astrbot_sdk.api.platform import PlatformMetadata
class Platform:
"""旧版平台适配器基类占位。"""
def register_platform_adapter(*args, **kwargs):
raise NotImplementedError(
"astrbot.api.platform.register_platform_adapter() 尚未在 v4 兼容层实现。"
)
__all__ = [
"AstrBotMessage",
"AstrMessageEvent",
"Group",
"MessageMember",
"MessageType",
"Platform",
"PlatformMetadata",
"register_platform_adapter",
]
-70
View File
@@ -1,70 +0,0 @@
"""旧版 ``astrbot.api.provider`` 导入路径兼容入口。"""
from __future__ import annotations
from dataclasses import dataclass, field
from enum import Enum
from typing import Any, TypedDict
from astrbot_sdk.api.provider import LLMResponse
class ProviderType(str, Enum):
CHAT_COMPLETION = "chat_completion"
SPEECH_TO_TEXT = "speech_to_text"
TEXT_TO_SPEECH = "text_to_speech"
EMBEDDING = "embedding"
RERANK = "rerank"
@dataclass(slots=True)
class ProviderMetaData:
id: str
model: str | None = None
type: str = ""
provider_type: ProviderType = ProviderType.CHAT_COMPLETION
desc: str = ""
cls_type: Any = None
default_config_tmpl: dict[str, Any] | None = None
provider_display_name: str | None = None
@dataclass(slots=True)
class ProviderRequest:
prompt: str | None = None
session_id: str | None = ""
image_urls: list[str] = field(default_factory=list)
contexts: list[dict[str, Any]] = field(default_factory=list)
system_prompt: str = ""
conversation: Any | None = None
tool_calls_result: Any | None = None
model: str | None = None
class Personality(TypedDict, total=False):
prompt: str
name: str
begin_dialogs: list[str]
mood_imitation_dialogs: list[str]
tools: list[str] | None
skills: list[str] | None
custom_error_message: str | None
class Provider:
"""旧版 Provider 基类占位。"""
class STTProvider:
"""旧版 STTProvider 基类占位。"""
__all__ = [
"LLMResponse",
"Personality",
"Provider",
"ProviderMetaData",
"ProviderRequest",
"ProviderType",
"STTProvider",
]
-5
View File
@@ -1,5 +0,0 @@
"""旧版 ``astrbot.api.star`` 导入路径兼容入口。"""
from astrbot_sdk.api.star import Context, Star, StarMetadata, StarTools, register
__all__ = ["Context", "Star", "StarMetadata", "StarTools", "register"]
-5
View File
@@ -1,5 +0,0 @@
"""旧版 ``astrbot.api.star.context`` 导入路径兼容入口。"""
from astrbot_sdk.api.star.context import Context
__all__ = ["Context"]
-9
View File
@@ -1,9 +0,0 @@
"""旧版 ``astrbot.api.util`` 导入路径兼容入口。"""
from astrbot.core.utils.session_waiter import (
SessionController,
SessionWaiter,
session_waiter,
)
__all__ = ["SessionController", "SessionWaiter", "session_waiter"]
-44
View File
@@ -1,44 +0,0 @@
"""旧版 ``astrbot.core`` 导入路径兼容入口。"""
from loguru import logger
from astrbot_sdk._shared_preferences import sp
from astrbot_sdk.api.basic import AstrBotConfig
from . import agent, config, db, message, platform, provider, utils
class _HtmlRendererCompat:
"""旧版 ``html_renderer`` 的导入占位。
v4 兼容层目前没有复刻旧 core 的整套 HTML 渲染系统。
保留符号用于导入兼容,真实调用时显式报错,避免静默伪兼容。
TODO: 后续如果需要,可以在这里实现一个基于当前平台能力的 HTML 渲染适配器。
"""
def __call__(self, *args, **kwargs):
raise NotImplementedError(
"astrbot.core.html_renderer 在 v4 兼容层中尚未提供,请改用当前平台发送/渲染能力。"
)
def __getattr__(self, _name: str):
raise NotImplementedError(
"astrbot.core.html_renderer 在 v4 兼容层中尚未提供,请改用当前平台发送/渲染能力。"
)
html_renderer = _HtmlRendererCompat()
__all__ = [
"AstrBotConfig",
"agent",
"config",
"db",
"html_renderer",
"logger",
"message",
"platform",
"provider",
"sp",
"utils",
]
-5
View File
@@ -1,5 +0,0 @@
"""旧版 ``astrbot.core.agent`` 兼容入口。"""
from .message import TextPart
__all__ = ["TextPart"]
-13
View File
@@ -1,13 +0,0 @@
"""旧版 ``astrbot.core.agent.message`` 兼容入口。"""
from __future__ import annotations
from dataclasses import dataclass
@dataclass(slots=True)
class TextPart:
text: str
__all__ = ["TextPart"]
-5
View File
@@ -1,5 +0,0 @@
"""旧版 ``astrbot.core.config`` 导入路径兼容入口。"""
from astrbot_sdk.api.basic import AstrBotConfig
__all__ = ["AstrBotConfig"]
@@ -1,5 +0,0 @@
"""旧版 ``astrbot.core.config.astrbot_config`` 导入路径兼容入口。"""
from astrbot_sdk.api.basic import AstrBotConfig
__all__ = ["AstrBotConfig"]
-5
View File
@@ -1,5 +0,0 @@
"""旧版 ``astrbot.core.db`` 兼容入口。"""
from .po import Personality
__all__ = ["Personality"]
-5
View File
@@ -1,5 +0,0 @@
"""旧版 ``astrbot.core.db.po`` 兼容入口。"""
from astrbot.api.provider import Personality
__all__ = ["Personality"]
-4
View File
@@ -1,4 +0,0 @@
"""旧版 ``astrbot.core.message`` 导入路径兼容入口。"""
from .components import * # noqa: F403
from .message_event_result import * # noqa: F403
@@ -1,3 +0,0 @@
"""旧版 ``astrbot.core.message.components`` 导入路径兼容入口。"""
from astrbot_sdk.api.message.components import * # noqa: F403
@@ -1,17 +0,0 @@
"""旧版 ``astrbot.core.message.message_event_result`` 导入路径兼容入口。"""
from astrbot_sdk.api.event import (
EventResultType,
MessageChain,
MessageEventResult,
ResultContentType,
)
from astrbot_sdk.api.event.event_result import CommandResult
__all__ = [
"CommandResult",
"EventResultType",
"MessageChain",
"MessageEventResult",
"ResultContentType",
]
-26
View File
@@ -1,26 +0,0 @@
"""旧版 ``astrbot.core.platform`` 导入路径兼容入口。"""
from astrbot.core.message.components import * # noqa: F403
from astrbot_sdk.api.event import (
AstrBotMessage,
AstrMessageEvent,
Group,
MessageMember,
MessageType,
)
from astrbot_sdk.api.platform import PlatformMetadata
class Platform:
"""旧版平台适配器基类占位。"""
__all__ = [
"AstrBotMessage",
"AstrMessageEvent",
"Group",
"MessageMember",
"MessageType",
"Platform",
"PlatformMetadata",
]
@@ -1,18 +0,0 @@
"""旧版 ``astrbot.core.platform.astr_message_event`` 导入路径兼容入口。"""
from astrbot_sdk.api.event import (
AstrMessageEvent,
AstrMessageEventModel,
MessageSesion,
MessageSession,
)
if not hasattr(AstrMessageEvent, "bot"):
AstrMessageEvent.bot = None
__all__ = [
"AstrMessageEvent",
"AstrMessageEventModel",
"MessageSesion",
"MessageSession",
]
@@ -1,5 +0,0 @@
"""旧版 ``astrbot.core.platform.astrbot_message`` 导入路径兼容入口。"""
from astrbot_sdk.api.event import AstrBotMessage, Group, MessageMember, MessageType
__all__ = ["AstrBotMessage", "Group", "MessageMember", "MessageType"]
@@ -1,5 +0,0 @@
"""旧版 ``astrbot.core.platform.message_type`` 导入路径兼容入口。"""
from astrbot_sdk.api.event import MessageType
__all__ = ["MessageType"]
@@ -1,8 +0,0 @@
"""旧版 ``astrbot.core.platform.platform`` 导入路径兼容入口。"""
class Platform:
"""旧版平台适配器基类占位。"""
__all__ = ["Platform"]
@@ -1,5 +0,0 @@
"""旧版 ``astrbot.core.platform.platform_metadata`` 导入路径兼容入口。"""
from astrbot_sdk.api.platform import PlatformMetadata
__all__ = ["PlatformMetadata"]
-13
View File
@@ -1,13 +0,0 @@
"""
旧版 ``astrbot.core.platform.register`` 导入路径兼容入口。
TODO: 目前仅保留符号以兼容导入,后续如果需要,可以在这里实现一个基于当前平台能力的注册系统适配器。
"""
def register_platform_adapter(*args, **kwargs):
raise NotImplementedError(
"astrbot.core.platform.register_platform_adapter() 尚未在 v4 兼容层实现。"
)
__all__ = ["register_platform_adapter"]
@@ -1 +0,0 @@
"""旧版 ``astrbot.core.platform.sources`` 导入路径兼容入口。"""
@@ -1,5 +0,0 @@
"""旧版 ``astrbot.core.platform.sources.aiocqhttp`` 导入路径兼容入口。"""
from .aiocqhttp_message_event import AiocqhttpMessageEvent
__all__ = ["AiocqhttpMessageEvent"]
@@ -1,7 +0,0 @@
"""旧版 aiocqhttp 事件类型的最小导入兼容入口。"""
from astrbot.core.platform.astr_message_event import AstrMessageEvent
AiocqhttpMessageEvent = AstrMessageEvent
__all__ = ["AiocqhttpMessageEvent"]
-24
View File
@@ -1,24 +0,0 @@
"""旧版 ``astrbot.core.provider`` 兼容入口。"""
from .entities import (
LLMResponse,
Personality,
ProviderMetaData,
ProviderRequest,
ProviderType,
RerankResult,
)
from .provider import EmbeddingProvider, Provider, RerankProvider, STTProvider
__all__ = [
"EmbeddingProvider",
"LLMResponse",
"Personality",
"Provider",
"ProviderMetaData",
"ProviderRequest",
"ProviderType",
"RerankProvider",
"RerankResult",
"STTProvider",
]
-29
View File
@@ -1,29 +0,0 @@
"""旧版 ``astrbot.core.provider.entities`` 兼容入口。"""
from __future__ import annotations
from dataclasses import dataclass
from astrbot.api.provider import (
LLMResponse,
Personality,
ProviderMetaData,
ProviderRequest,
ProviderType,
)
@dataclass(slots=True)
class RerankResult:
index: int
relevance_score: float
__all__ = [
"LLMResponse",
"Personality",
"ProviderMetaData",
"ProviderRequest",
"ProviderType",
"RerankResult",
]
-61
View File
@@ -1,61 +0,0 @@
"""旧版 ``astrbot.core.provider.provider`` 兼容入口。"""
from __future__ import annotations
from typing import Any
from .entities import ProviderMetaData
class Provider:
"""旧版 Provider 基类占位。"""
async def text_chat(self, *args, **kwargs): # pragma: no cover - compat stub
raise NotImplementedError("compat facade does not implement core providers")
def meta(self) -> ProviderMetaData: # pragma: no cover - compat stub
raise NotImplementedError("compat facade does not implement core providers")
def get_model(self) -> str: # pragma: no cover - compat stub
raise NotImplementedError("compat facade does not implement core providers")
class STTProvider(Provider):
pass
class EmbeddingProvider(Provider):
async def get_embeddings(self, texts: list[str]) -> list[list[float]]:
raise NotImplementedError("compat facade does not implement embeddings")
async def get_embeddings_batch(
self,
texts: list[str],
*,
batch_size: int = 16,
tasks_limit: int = 3,
max_retries: int = 3,
progress_callback: Any | None = None,
) -> list[list[float]]:
raise NotImplementedError("compat facade does not implement embeddings")
def get_dim(self) -> int:
raise NotImplementedError("compat facade does not implement embeddings")
class RerankProvider(Provider):
async def rerank(
self,
query: str,
documents: list[str],
top_n: int | None = None,
):
raise NotImplementedError("compat facade does not implement rerank")
__all__ = [
"EmbeddingProvider",
"Provider",
"RerankProvider",
"STTProvider",
]
-37
View File
@@ -1,37 +0,0 @@
"""旧版 ``astrbot.core.utils`` 兼容入口。"""
from .astrbot_path import (
get_astrbot_backups_path,
get_astrbot_config_path,
get_astrbot_data_path,
get_astrbot_knowledge_base_path,
get_astrbot_path,
get_astrbot_plugin_data_path,
get_astrbot_plugin_path,
get_astrbot_root,
get_astrbot_site_packages_path,
get_astrbot_skills_path,
get_astrbot_t2i_templates_path,
get_astrbot_temp_path,
get_astrbot_webchat_path,
)
from .session_waiter import SessionController, SessionWaiter, session_waiter
__all__ = [
"SessionController",
"SessionWaiter",
"get_astrbot_backups_path",
"get_astrbot_config_path",
"get_astrbot_data_path",
"get_astrbot_knowledge_base_path",
"get_astrbot_path",
"get_astrbot_plugin_data_path",
"get_astrbot_plugin_path",
"get_astrbot_root",
"get_astrbot_site_packages_path",
"get_astrbot_skills_path",
"get_astrbot_t2i_templates_path",
"get_astrbot_temp_path",
"get_astrbot_webchat_path",
"session_waiter",
]
@@ -1,73 +0,0 @@
"""旧版 ``astrbot.core.utils.astrbot_path`` 兼容入口。"""
from __future__ import annotations
import os
from pathlib import Path
def get_astrbot_path() -> str:
"""返回当前兼容 SDK 的项目根路径。"""
return str(Path(__file__).resolve().parents[4])
def get_astrbot_root() -> str:
"""返回 AstrBot 运行根目录。
旧版优先读取 ``ASTRBOT_ROOT``,否则默认当前工作目录。compat 层保持
这个约定,方便旧插件继续把数据写到 ``<root>/data`` 下。
"""
root = os.environ.get("ASTRBOT_ROOT")
if root:
return str(Path(root).resolve())
return str(Path.cwd().resolve())
def _data_child(*parts: str) -> str:
return str(Path(get_astrbot_data_path(), *parts).resolve())
def get_astrbot_data_path() -> str:
return str(Path(get_astrbot_root(), "data").resolve())
def get_astrbot_config_path() -> str:
return _data_child("config")
def get_astrbot_plugin_path() -> str:
return _data_child("plugins")
def get_astrbot_plugin_data_path() -> str:
return _data_child("plugin_data")
def get_astrbot_t2i_templates_path() -> str:
return _data_child("t2i_templates")
def get_astrbot_webchat_path() -> str:
return _data_child("webchat")
def get_astrbot_temp_path() -> str:
return _data_child("temp")
def get_astrbot_skills_path() -> str:
return _data_child("skills")
def get_astrbot_site_packages_path() -> str:
return _data_child("site-packages")
def get_astrbot_knowledge_base_path() -> str:
return _data_child("knowledge_base")
def get_astrbot_backups_path() -> str:
return _data_child("backups")
@@ -1,5 +0,0 @@
"""旧版 ``astrbot.core.utils.session_waiter`` 导入路径兼容入口。"""
from astrbot_sdk._session_waiter import SessionController, SessionWaiter, session_waiter
__all__ = ["SessionController", "SessionWaiter", "session_waiter"]
+4 -4
View File
@@ -2,11 +2,11 @@
这里仅重新导出 v4 推荐直接导入的稳定入口。
- ``astrbot_sdk``: v4 原生稳定 API
- ``astrbot_sdk.compat``: 旧版顶层导入路径兼容入口
- ``astrbot_sdk.api``: 历史 ``api.*`` 导入路径兼容面
新插件应直接使用此模块的导出:
from astrbot_sdk import Star, Context, MessageEvent
from astrbot_sdk.decorators import on_command, on_message
这样可以把原生 API 与迁移入口明确分开,避免旧路径继续反向污染顶层包。
旧插件请使用 AstrBot 主程序运行,不再由 SDK 提供 compat 层。
"""
from .context import Context
-49
View File
@@ -1,49 +0,0 @@
"""旧版 API 兼容层聚合入口。
这个模块重导出来自 ``_legacy_context`` 和 ``_legacy_star`` 的所有公开符号,
供 ``compat.py``、``api/star/``、``api/components/`` 等外部导入路径使用。
不要在这里添加新的运行时逻辑;业务实现分别在 ``_legacy_context.py`` 和
``_legacy_star.py`` 中维护。
注意:``logger`` 在此显式导入,以保持向后兼容性——部分测试通过
``patch("astrbot_sdk._legacy_api.logger.warning")`` 路径拦截日志调用。
由于 loguru 的 ``logger`` 是全局单例,这里的引用与 ``_legacy_context``
内部使用的是同一个对象。
"""
from __future__ import annotations
from loguru import logger as logger # noqa: PLC0414 — re-exported for patch compat
from ._legacy_context import (
COMPAT_CONVERSATIONS_KEY,
MIGRATION_DOC_URL,
LegacyContext,
LegacyConversationManager,
_CompatHookEntry,
_iter_registered_component_methods,
_warn_once,
_warned_methods,
)
from ._legacy_star import CommandComponent, LegacyStar, StarTools, register
# Historical alias: ``Context`` was the original public name for ``LegacyContext``.
Context = LegacyContext
__all__ = [
"COMPAT_CONVERSATIONS_KEY",
"CommandComponent",
"Context",
"LegacyContext",
"LegacyConversationManager",
"LegacyStar",
"MIGRATION_DOC_URL",
"StarTools",
"_CompatHookEntry",
"_iter_registered_component_methods",
"_warn_once",
"_warned_methods",
"logger",
"register",
]
File diff suppressed because it is too large Load Diff
-202
View File
@@ -1,202 +0,0 @@
"""legacy LLM 与 tool 兼容辅助。
这个模块只承接 ``_legacy_api.py`` 中相对独立的旧 LLM/tool 兼容逻辑:
- 旧版 tool manager 与 tool schema 组装
- 旧 provider 请求对象
- 新响应到旧 ``LLMResponse`` 的转换
它不暴露新的公开 API,只用于减轻 ``LegacyContext`` 所在模块的职责。
"""
from __future__ import annotations
import ast
import json
from collections.abc import Callable
from dataclasses import dataclass
from typing import TYPE_CHECKING, Any
if TYPE_CHECKING:
from .api.provider.entities import LLMResponse
@dataclass(slots=True)
class _CompatToolSpec:
name: str
description: str
parameters: dict[str, Any]
handler: Callable[..., Any]
active: bool = True
@dataclass(slots=True)
class _CompatProviderRequest:
prompt: str | None = None
session_id: str | None = ""
image_urls: list[str] | None = None
contexts: list[dict[str, Any]] | None = None
system_prompt: str = ""
conversation: Any | None = None
tool_calls_result: Any | None = None
model: str | None = None
def _tool_parameters_from_legacy_args(
func_args: list[dict[str, Any]],
) -> dict[str, Any]:
parameters: dict[str, Any] = {"type": "object", "properties": {}, "required": []}
for item in func_args:
if not isinstance(item, dict):
continue
name = str(item.get("name", ""))
if not name:
continue
schema = {key: value for key, value in item.items() if key != "name"}
parameters["properties"][name] = schema
parameters["required"].append(name)
return parameters
class CompatLLMToolManager:
"""旧版 llm tool manager 的最小兼容实现。"""
def __init__(self) -> None:
self.func_list: list[_CompatToolSpec] = []
def add_tool(
self,
*,
name: str,
description: str,
parameters: dict[str, Any],
handler: Callable[..., Any],
) -> None:
self.remove_func(name)
self.func_list.append(
_CompatToolSpec(
name=name,
description=description,
parameters=parameters,
handler=handler,
)
)
def add_func(
self,
name: str,
func_args: list[dict[str, Any]],
desc: str,
handler: Callable[..., Any],
) -> None:
self.add_tool(
name=name,
description=desc,
parameters=_tool_parameters_from_legacy_args(func_args),
handler=handler,
)
def remove_func(self, name: str) -> None:
self.func_list = [tool for tool in self.func_list if tool.name != name]
def get_func(self, name: str) -> _CompatToolSpec | None:
for tool in self.func_list:
if tool.name == name:
return tool
return None
def activate_llm_tool(self, name: str) -> bool:
tool = self.get_func(name)
if tool is None:
return False
tool.active = True
return True
def deactivate_llm_tool(self, name: str) -> bool:
tool = self.get_func(name)
if tool is None:
return False
tool.active = False
return True
def get_func_desc_openai_style(self) -> list[dict[str, Any]]:
return [
{
"type": "function",
"function": {
"name": tool.name,
"description": tool.description,
"parameters": tool.parameters,
},
}
for tool in self.func_list
if tool.active
]
def _legacy_tool_calls(
response_payload: dict[str, Any] | None,
) -> tuple[list[dict[str, Any]], list[str], list[str]]:
tool_calls = list((response_payload or {}).get("tool_calls") or [])
tool_args: list[dict[str, Any]] = []
tool_names: list[str] = []
tool_ids: list[str] = []
for tool_call in tool_calls:
if not isinstance(tool_call, dict):
continue
function_payload = tool_call.get("function")
if isinstance(function_payload, dict):
name = str(function_payload.get("name") or "")
raw_arguments = function_payload.get("arguments")
else:
name = str(tool_call.get("name") or "")
raw_arguments = tool_call.get("arguments")
if isinstance(raw_arguments, str):
try:
arguments = json.loads(raw_arguments)
except json.JSONDecodeError:
try:
arguments = ast.literal_eval(raw_arguments)
except (SyntaxError, ValueError):
arguments = {}
elif isinstance(raw_arguments, dict):
arguments = raw_arguments
else:
arguments = {}
if not isinstance(arguments, dict):
arguments = {}
tool_names.append(name)
tool_args.append(arguments)
tool_ids.append(str(tool_call.get("id") or f"tool-{len(tool_ids) + 1}"))
return tool_args, tool_names, tool_ids
def _legacy_llm_response(response: Any) -> LLMResponse:
from .api.provider.entities import LLMResponse
if isinstance(response, LLMResponse):
return response
model_dump = getattr(response, "model_dump", None)
if callable(model_dump):
payload = model_dump()
elif isinstance(response, dict):
payload = dict(response)
else:
payload = {
"text": getattr(response, "text", ""),
"usage": getattr(response, "usage", None),
"finish_reason": getattr(response, "finish_reason", None),
"tool_calls": getattr(response, "tool_calls", []),
}
tool_args, tool_names, tool_ids = _legacy_tool_calls(payload)
return LLMResponse(
role=str(payload.get("role") or "assistant"),
completion_text=str(payload.get("text") or ""),
tools_call_args=tool_args,
tools_call_name=tool_names,
tools_call_ids=tool_ids,
raw_completion=response,
_new_record=payload,
)
-139
View File
@@ -1,139 +0,0 @@
"""legacy 插件发现与 main.py 包装导入辅助。"""
from __future__ import annotations
import importlib
import importlib.util
import inspect
import re
import sys
import types
from pathlib import Path
from typing import Any
from .star import Star
PLUGIN_MANIFEST_FILE = "plugin.yaml"
LEGACY_METADATA_FILE = "metadata.yaml"
LEGACY_MAIN_FILE = "main.py"
def looks_like_legacy_plugin(plugin_dir: Path) -> bool:
return (
not (plugin_dir / PLUGIN_MANIFEST_FILE).exists()
and (plugin_dir / LEGACY_MAIN_FILE).exists()
)
def build_legacy_manifest(
plugin_dir: Path,
*,
read_yaml,
default_python_version: str,
manifest_flag_key: str,
) -> tuple[Path, dict[str, Any]]:
metadata_path = plugin_dir / LEGACY_METADATA_FILE
metadata = read_yaml(metadata_path) if metadata_path.exists() else {}
plugin_name = str(metadata.get("name") or plugin_dir.name)
manifest_data: dict[str, Any] = {
"name": plugin_name,
"author": metadata.get("author"),
"desc": metadata.get("desc") or metadata.get("description"),
"version": metadata.get("version"),
"repo": metadata.get("repo"),
"display_name": metadata.get("display_name"),
"runtime": {"python": default_python_version},
"components": [],
manifest_flag_key: True,
}
return (
metadata_path if metadata_path.exists() else plugin_dir / LEGACY_MAIN_FILE,
manifest_data,
)
def load_plugin_manifest_payload(
plugin_dir: Path,
*,
read_yaml,
default_python_version: str,
manifest_flag_key: str,
) -> tuple[Path, dict[str, Any]]:
manifest_path = plugin_dir / PLUGIN_MANIFEST_FILE
if manifest_path.exists():
return manifest_path, read_yaml(manifest_path)
return build_legacy_manifest(
plugin_dir,
read_yaml=read_yaml,
default_python_version=default_python_version,
manifest_flag_key=manifest_flag_key,
)
def legacy_package_name(plugin_name: str) -> str:
sanitized = re.sub(r"[^a-zA-Z0-9_]", "_", plugin_name)
return f"_astrbot_legacy_pkg_{sanitized}"
def _prepare_legacy_package(package_name: str, plugin_dir: Path) -> None:
package = types.ModuleType(package_name)
package.__path__ = [str(plugin_dir)]
package.__package__ = package_name
sys.modules[package_name] = package
sys.modules.pop(f"{package_name}.main", None)
importlib.invalidate_caches()
def _iter_main_module_component_classes(module: types.ModuleType) -> list[type[Any]]:
component_classes: list[type[Any]] = []
for candidate in module.__dict__.values():
if not inspect.isclass(candidate):
continue
if candidate.__module__ != module.__name__:
continue
if not issubclass(candidate, Star) or candidate is Star:
continue
component_classes.append(candidate)
return component_classes
def load_legacy_main_component_classes(
*,
plugin_name: str,
plugin_dir: Path,
) -> list[type[Any]]:
package_name = legacy_package_name(plugin_name)
module_name = f"{package_name}.main"
module_path = plugin_dir / LEGACY_MAIN_FILE
_prepare_legacy_package(package_name, plugin_dir)
spec = importlib.util.spec_from_file_location(module_name, module_path)
if spec is None or spec.loader is None:
return []
module = importlib.util.module_from_spec(spec)
sys.modules[module_name] = module
spec.loader.exec_module(module)
return _iter_main_module_component_classes(module)
def resolve_plugin_component_classes(
*,
plugin_name: str,
plugin_dir: Path,
manifest_data: dict[str, Any],
manifest_flag_key: str,
import_string,
) -> list[type[Any]]:
component_classes: list[type[Any]] = []
for component in manifest_data.get("components", []):
class_path = component.get("class")
if not isinstance(class_path, str) or ":" not in class_path:
continue
component_classes.append(import_string(class_path, plugin_dir=plugin_dir))
if component_classes:
return component_classes
if manifest_data.get(manifest_flag_key):
return load_legacy_main_component_classes(
plugin_name=plugin_name,
plugin_dir=plugin_dir,
)
return []
-500
View File
@@ -1,500 +0,0 @@
"""legacy 运行时执行适配。
这个模块把 compat 执行细节从 runtime 主干中收口出来:
- 旧自定义过滤器执行
- 旧结果装饰与发送后 hook
- 旧插件错误 hook
- worker 生命周期中的 compat hook 调用
v4 主干只与这个适配层交互,不直接展开 legacy 事件包装和 hook 名称。
"""
from __future__ import annotations
import inspect
from collections.abc import Callable, Iterable
from dataclasses import dataclass, field
from typing import Any
from .api.event.astr_message_event import AstrMessageEvent
from .api.event.event_result import MessageEventResult
from .api.message.chain import MessageChain
from .context import Context
from .events import MessageEvent
from .star import Star
@dataclass(slots=True)
class LegacyPreparedResult:
item: Any
compat_event: AstrMessageEvent | None = None
stopped: bool = False
@dataclass(slots=True)
class LegacyHandlerPreparation:
adapter: LegacyRuntimeAdapter | None
should_run: bool = True
@dataclass(slots=True)
class LegacyComponentConstruction:
legacy_context: Any
shared_legacy_context: Any
component_config: Any | None
constructor_args: tuple[Any, ...]
@dataclass(slots=True)
class LegacyRuntimeAdapter:
legacy_context: Any
filters: list[Any] = field(default_factory=list)
@classmethod
def from_handler(cls, legacy_context: Any, handler: Any) -> "LegacyRuntimeAdapter":
from .api.event.filter import get_compat_custom_filters
return cls(
legacy_context=legacy_context,
filters=list(get_compat_custom_filters(handler)),
)
@classmethod
def from_capability(cls, legacy_context: Any) -> "LegacyRuntimeAdapter":
return cls(legacy_context=legacy_context)
def bind_runtime_context(self, runtime_context: Context) -> None:
binder = getattr(self.legacy_context, "bind_runtime_context", None)
if callable(binder):
binder(runtime_context)
def register_component(self, component: Any) -> None:
register = getattr(self.legacy_context, "_register_compat_component", None)
if callable(register):
register(component)
async def run_hook(self, hook_name: str, **kwargs: Any) -> list[Any]:
runner = getattr(self.legacy_context, "_run_compat_hook", None)
if not callable(runner):
return []
return await runner(
hook_name,
legacy_context=self.legacy_context,
**kwargs,
)
def runtime_config(self) -> Any:
config_getter = getattr(self.legacy_context, "_runtime_config", None)
if callable(config_getter):
return config_getter()
return None
async def passes_filters(self, event: MessageEvent) -> bool:
if not self.filters:
return True
compat_event = AstrMessageEvent.from_message_event(event)
cfg = self.runtime_config()
for filter_obj in self.filters:
if not filter_obj.filter(compat_event, cfg):
return False
return True
async def prepare_result(
self,
item: Any,
event: MessageEvent,
ctx: Context | None,
) -> LegacyPreparedResult:
compat_event = AstrMessageEvent.from_message_event(event)
if isinstance(item, (MessageEventResult, MessageChain, str)):
compat_event.set_result(item)
await self.run_hook(
"on_decorating_result",
event=compat_event,
context=ctx,
result=compat_event.get_result(),
)
if compat_event.is_stopped():
return LegacyPreparedResult(
item=item,
compat_event=compat_event,
stopped=True,
)
item = compat_event.get_result() or item
return LegacyPreparedResult(item=item, compat_event=compat_event)
async def after_send(
self,
compat_event: AstrMessageEvent | None,
ctx: Context | None,
) -> None:
if compat_event is None:
return
await self.run_hook(
"after_message_sent",
event=compat_event,
context=ctx,
)
async def dispatch_result(
self,
item: Any,
event: MessageEvent,
ctx: Context | None,
*,
sender: Callable[[Any], Any],
) -> bool:
prepared = await self.prepare_result(item, event, ctx)
if prepared.stopped:
return False
handled = sender(prepared.item)
if inspect.isawaitable(handled):
handled = await handled
sent = bool(handled)
if sent:
await self.after_send(prepared.compat_event, ctx)
return sent
async def handle_error(
self,
*,
plugin_id: str,
handler_name: str,
exc: Exception,
event: MessageEvent,
ctx: Context,
traceback_text: str,
) -> None:
await self.run_hook(
"on_plugin_error",
event=AstrMessageEvent.from_message_event(event),
context=ctx,
plugin_name=plugin_id,
handler_name=handler_name,
error=exc,
traceback_text=traceback_text,
)
async def run_worker_startup_hooks(
self,
*,
context: Context,
metadata: dict[str, Any],
) -> None:
await self.run_hook("on_astrbot_loaded", context=context)
await self.run_hook("on_platform_loaded", context=context)
await self.run_hook("on_plugin_loaded", context=context, metadata=metadata)
async def run_worker_shutdown_hooks(
self,
*,
context: Context,
metadata: dict[str, Any],
) -> None:
await self.run_hook("on_plugin_unloaded", context=context, metadata=metadata)
def build_handler_legacy_runtime(
legacy_context: Any,
handler: Any,
*,
compat_filters: list[Any] | None = None,
) -> LegacyRuntimeAdapter:
if compat_filters is None:
return LegacyRuntimeAdapter.from_handler(legacy_context, handler)
return LegacyRuntimeAdapter(
legacy_context=legacy_context,
filters=list(compat_filters),
)
def build_capability_legacy_runtime(legacy_context: Any) -> LegacyRuntimeAdapter:
return LegacyRuntimeAdapter.from_capability(legacy_context)
def register_legacy_component(legacy_context: Any, component: Any) -> None:
LegacyRuntimeAdapter.from_capability(legacy_context).register_component(component)
def get_legacy_runtime_adapter(loaded: Any) -> LegacyRuntimeAdapter | None:
adapter = getattr(loaded, "legacy_runtime", None)
if adapter is not None:
return adapter
legacy_context = getattr(loaded, "legacy_context", None)
if legacy_context is None:
return None
filters = list(getattr(loaded, "compat_filters", ()))
if hasattr(loaded, "compat_filters"):
adapter = build_handler_legacy_runtime(
legacy_context,
getattr(loaded, "callable", None),
compat_filters=filters,
)
else:
adapter = build_capability_legacy_runtime(legacy_context)
try:
setattr(loaded, "legacy_runtime", adapter)
except AttributeError:
pass
return adapter
def iter_unique_legacy_runtime_adapters(
loaded_items: Iterable[Any],
) -> list[LegacyRuntimeAdapter]:
seen_contexts: set[int] = set()
adapters: list[LegacyRuntimeAdapter] = []
for loaded in loaded_items:
adapter = get_legacy_runtime_adapter(loaded)
if adapter is None:
continue
marker = id(adapter.legacy_context)
if marker in seen_contexts:
continue
seen_contexts.add(marker)
adapters.append(adapter)
return adapters
def bind_legacy_runtime_contexts(
loaded_items: Iterable[Any],
runtime_context: Context,
) -> None:
for adapter in iter_unique_legacy_runtime_adapters(loaded_items):
adapter.bind_runtime_context(runtime_context)
async def run_legacy_worker_startup_hooks(
loaded_items: Iterable[Any],
*,
context: Context,
metadata: dict[str, Any],
) -> None:
for adapter in iter_unique_legacy_runtime_adapters(loaded_items):
await adapter.run_worker_startup_hooks(
context=context,
metadata=metadata,
)
async def run_legacy_worker_shutdown_hooks(
loaded_items: Iterable[Any],
*,
context: Context,
metadata: dict[str, Any],
) -> None:
for adapter in iter_unique_legacy_runtime_adapters(loaded_items):
await adapter.run_worker_shutdown_hooks(
context=context,
metadata=metadata,
)
def bind_loaded_legacy_runtime(
loaded: Any,
runtime_context: Context,
) -> LegacyRuntimeAdapter | None:
adapter = get_legacy_runtime_adapter(loaded)
if adapter is None:
return None
adapter.bind_runtime_context(runtime_context)
return adapter
async def prepare_legacy_handler_runtime(
loaded: Any,
*,
runtime_context: Context,
event: MessageEvent,
) -> LegacyHandlerPreparation:
adapter = bind_loaded_legacy_runtime(loaded, runtime_context)
if adapter is None:
return LegacyHandlerPreparation(adapter=None, should_run=True)
should_run = await adapter.passes_filters(event)
return LegacyHandlerPreparation(adapter=adapter, should_run=should_run)
class LegacyWorkerRuntimeBridge:
def __init__(self, loaded_items: Callable[[], list[Any]] | Iterable[Any]) -> None:
self._loaded_items = loaded_items
def _items(self) -> list[Any]:
if callable(self._loaded_items):
return list(self._loaded_items())
return list(self._loaded_items)
def bind_runtime_contexts(self, runtime_context: Context) -> None:
bind_legacy_runtime_contexts(self._items(), runtime_context)
async def run_startup_hooks(
self,
*,
context: Context,
metadata: dict[str, Any],
) -> None:
await run_legacy_worker_startup_hooks(
self._items(),
context=context,
metadata=metadata,
)
async def run_shutdown_hooks(
self,
*,
context: Context,
metadata: dict[str, Any],
) -> None:
await run_legacy_worker_shutdown_hooks(
self._items(),
context=context,
metadata=metadata,
)
def build_legacy_worker_runtime_bridge(
loaded_items: Callable[[], list[Any]] | Iterable[Any],
) -> LegacyWorkerRuntimeBridge:
return LegacyWorkerRuntimeBridge(loaded_items)
def create_legacy_component_context(component_cls: Any, plugin_name: str) -> Any:
factory = getattr(component_cls, "_astrbot_create_legacy_context", None)
if callable(factory):
return factory(plugin_name)
from ._legacy_api import Context as LegacyContext
return LegacyContext(plugin_name)
def is_new_star_component(component_cls: Any) -> bool:
if not isinstance(component_cls, type):
return False
if not issubclass(component_cls, Star):
return False
marker = getattr(component_cls, "__astrbot_is_new_star__", None)
if callable(marker):
return bool(marker())
return True
def legacy_constructor_accepts_config(component_cls: Any) -> bool:
try:
signature = inspect.signature(component_cls.__init__)
except (TypeError, ValueError):
return False
positional = [
parameter
for parameter in signature.parameters.values()
if parameter.kind
in (
inspect.Parameter.POSITIONAL_ONLY,
inspect.Parameter.POSITIONAL_OR_KEYWORD,
)
]
if positional and positional[0].name == "self":
positional = positional[1:]
return len(positional) >= 2
def select_legacy_constructor_args(
component_cls: Any,
legacy_context: Any,
component_config: Any | None,
) -> tuple[Any, ...]:
if legacy_constructor_accepts_config(component_cls):
return (legacy_context, component_config)
return (legacy_context,)
def plan_legacy_component_construction(
component_cls: Any,
*,
plugin_name: str,
shared_legacy_context: Any | None,
plugin_config: Any | None,
default_config_factory: Callable[[], Any],
) -> LegacyComponentConstruction:
legacy_context = shared_legacy_context or create_legacy_component_context(
component_cls,
plugin_name,
)
component_config = plugin_config
if component_config is None and legacy_constructor_accepts_config(component_cls):
component_config = default_config_factory()
return LegacyComponentConstruction(
legacy_context=legacy_context,
shared_legacy_context=shared_legacy_context or legacy_context,
component_config=component_config,
constructor_args=select_legacy_constructor_args(
component_cls,
legacy_context,
component_config,
),
)
def finalize_legacy_component_instance(
instance: Any,
*,
legacy_context: Any,
component_config: Any | None,
) -> None:
setattr(instance, "context", legacy_context)
if component_config is not None:
setattr(instance, "config", component_config)
register_legacy_component(legacy_context, instance)
def resolve_plugin_lifecycle_hook(
instance: Any, method_name: str
) -> Callable[..., Any] | None:
direct = getattr(type(instance), method_name, None)
inherited = getattr(Star, method_name, None)
if direct is not None and direct is not inherited:
return getattr(instance, method_name)
if not is_new_star_component(type(instance)):
alias = {
"on_start": "initialize",
"on_stop": "terminate",
}.get(method_name)
if alias:
hook = getattr(instance, alias, None)
if callable(hook):
return hook
return None
async def run_plugin_lifecycle(
instances: list[Any],
method_name: str,
context: Any,
) -> None:
"""执行插件实例列表的生命周期钩子。
对每个实例查找对应的生命周期方法,按签名决定是否注入 context,然后调用。
"""
for instance in instances:
hook = resolve_plugin_lifecycle_hook(instance, method_name)
if hook is None:
continue
args: list[Any] = []
try:
signature = inspect.signature(hook)
except (TypeError, ValueError):
signature = None
if signature is not None:
positional_params = [
parameter
for parameter in signature.parameters.values()
if parameter.kind
in (
inspect.Parameter.POSITIONAL_ONLY,
inspect.Parameter.POSITIONAL_OR_KEYWORD,
)
]
if positional_params:
args.append(context)
result = hook(*args)
if inspect.isawaitable(result):
await result
-177
View File
@@ -1,177 +0,0 @@
"""旧版 API 兼容层 — 插件基类与注册装饰器。
这个模块承接旧 ``Star`` / ``CommandComponent`` / ``register`` 的实现,
供旧版插件在不修改代码的情况下继续运行。
依赖关系:
- ``_legacy_context`` 提供 ``LegacyContext``(单向依赖,本模块不被 ``_legacy_context`` 导入)
- ``_legacy_llm`` 提供 ``CompatLLMToolManager``
外部代码应通过 ``_legacy_api`` 聚合入口导入,而不是直接导入本模块。
"""
from __future__ import annotations
import inspect
from collections.abc import Callable
from pathlib import Path
from typing import Any
from ._legacy_context import LegacyContext
from ._legacy_llm import CompatLLMToolManager
from .star import Star
class StarTools:
"""旧版 ``StarTools`` 的最小兼容实现。"""
@staticmethod
def get_data_dir() -> Path:
frame = inspect.currentframe()
caller = frame.f_back if frame is not None else None
try:
while caller is not None:
caller_file = caller.f_globals.get("__file__")
if isinstance(caller_file, str) and caller_file:
data_dir = Path(caller_file).resolve().parent / "data"
data_dir.mkdir(parents=True, exist_ok=True)
return data_dir
caller = caller.f_back
finally:
del frame
data_dir = Path.cwd() / "data"
data_dir.mkdir(parents=True, exist_ok=True)
return data_dir
class LegacyStar(Star):
"""旧版 ``astrbot.api.star.Star`` 兼容基类。"""
def __init__(self, context: LegacyContext | None = None, config: Any | None = None):
self.context = context
if config is not None:
self.config = config
def _require_legacy_context(self) -> LegacyContext:
if self.context is None:
raise RuntimeError("LegacyStar 尚未绑定 compat Context")
return self.context
async def put_kv_data(self, key: str, value: Any) -> None:
await self._require_legacy_context().put_kv_data(key, value)
async def get_kv_data(self, key: str, default: Any = None) -> Any:
return await self._require_legacy_context().get_kv_data(key, default)
async def delete_kv_data(self, key: str) -> None:
await self._require_legacy_context().delete_kv_data(key)
async def send_message(self, session: str, message_chain: Any) -> None:
await self._require_legacy_context().send_message(session, message_chain)
async def llm_generate(
self,
chat_provider_id: str,
*args: Any,
**kwargs: Any,
) -> Any:
return await self._require_legacy_context().llm_generate(
chat_provider_id,
*args,
**kwargs,
)
async def tool_loop_agent(
self,
chat_provider_id: str,
*args: Any,
**kwargs: Any,
) -> Any:
return await self._require_legacy_context().tool_loop_agent(
chat_provider_id,
*args,
**kwargs,
)
async def add_llm_tools(self, *tools: Any) -> None:
await self._require_legacy_context().add_llm_tools(*tools)
def get_llm_tool_manager(self) -> CompatLLMToolManager:
return self._require_legacy_context().get_llm_tool_manager()
def activate_llm_tool(self, name: str) -> bool:
return self._require_legacy_context().activate_llm_tool(name)
def deactivate_llm_tool(self, name: str) -> bool:
return self._require_legacy_context().deactivate_llm_tool(name)
def register_llm_tool(
self,
name: str,
func_args: list[dict[str, Any]],
desc: str,
func_obj: Callable[..., Any],
) -> None:
self._require_legacy_context().register_llm_tool(
name,
func_args,
desc,
func_obj,
)
def unregister_llm_tool(self, name: str) -> None:
self._require_legacy_context().unregister_llm_tool(name)
def get_config(self) -> dict[str, Any]:
return self._require_legacy_context().get_config()
@classmethod
def __astrbot_is_new_star__(cls) -> bool:
return False
@classmethod
def _astrbot_create_legacy_context(cls, plugin_id: str) -> LegacyContext:
return LegacyContext(plugin_id)
class CommandComponent(LegacyStar):
@classmethod
def __astrbot_is_new_star__(cls) -> bool:
return False
@classmethod
def _astrbot_create_legacy_context(cls, plugin_id: str) -> LegacyContext:
# Loader 通过这个工厂拿到旧 Context,避免核心运行时直接依赖 compat 实现。
return LegacyContext(plugin_id)
def register(
name: str | None = None,
author: str | None = None,
desc: str | None = None,
version: str | None = None,
repo: str | None = None,
):
"""旧版插件元数据装饰器兼容入口。"""
metadata = {
"name": name,
"author": author,
"desc": desc,
"version": version,
"repo": repo,
}
def decorator(cls):
existing = getattr(cls, "__astrbot_plugin_metadata__", {})
setattr(
cls,
"__astrbot_plugin_metadata__",
{
**existing,
**{key: value for key, value in metadata.items() if value is not None},
},
)
return cls
return decorator
-153
View File
@@ -1,153 +0,0 @@
"""旧版 ``session_waiter`` 的最小兼容实现。"""
from __future__ import annotations
import asyncio
import inspect
from dataclasses import dataclass
from typing import Any
@dataclass
class _SessionWaitState:
queue: asyncio.Queue[Any]
timeout: float | None
stopped: bool = False
class SessionController:
"""兼容旧版交互式会话等待控制器。"""
def __init__(self, state: _SessionWaitState) -> None:
self._state = state
def keep(
self,
*,
timeout: float | None = None,
reset_timeout: bool = False,
) -> None:
if timeout is not None and (reset_timeout or self._state.timeout is None):
self._state.timeout = timeout
self._state.stopped = False
def stop(self) -> None:
self._state.stopped = True
class SessionWaiterManager:
"""按会话路由后续消息到等待中的 compat 回调。"""
def __init__(self) -> None:
self._waiters: dict[str, _SessionWaitState] = {}
@staticmethod
def session_key(event: Any) -> str:
event = SessionWaiterManager._coerce_event(event)
unified = getattr(event, "unified_msg_origin", None)
if unified:
return str(unified)
session = getattr(event, "session_id", "")
return str(session)
def register(self, event: Any, state: _SessionWaitState) -> str:
key = self.session_key(event)
if key in self._waiters:
raise RuntimeError(f"session_waiter 已存在活跃会话: {key}")
self._waiters[key] = state
return key
def has_waiter(self, event: Any) -> bool:
return self.session_key(event) in self._waiters
def unregister(self, key: str, state: _SessionWaitState) -> None:
if self._waiters.get(key) is state:
self._waiters.pop(key, None)
async def dispatch(self, event: Any) -> bool:
key = self.session_key(event)
return await self.dispatch_to_key(key, event)
async def dispatch_to_key(self, key: str, event: Any) -> bool:
state = self._waiters.get(key)
if state is None:
return False
await state.queue.put(self._coerce_event(event))
return True
@staticmethod
def _coerce_event(event: Any) -> Any:
from .api.event.astr_message_event import AstrMessageEvent
if isinstance(event, AstrMessageEvent):
return event
return AstrMessageEvent.from_message_event(event)
class SessionWaiter:
"""旧版 ``SessionWaiter`` 的轻量兼容入口。"""
@staticmethod
async def trigger(session_id: str, event: Any) -> None:
context = getattr(event, "_context", None)
manager = getattr(context, "_session_waiter_manager", None)
if manager is None:
return
await manager.dispatch_to_key(str(session_id), event)
def session_waiter(
*,
timeout: float | None = None,
record_history_chains: bool | None = None,
):
"""兼容旧版 ``@session_waiter`` 装饰器。
当前实现只保留运行时等待下一条同会话消息的核心语义;
``record_history_chains`` 仅为兼容旧签名而保留。
"""
del record_history_chains
def decorator(func):
async def runner(event: Any, *args: Any, **kwargs: Any) -> None:
context = getattr(event, "_context", None)
manager = getattr(context, "_session_waiter_manager", None)
if manager is None:
raise RuntimeError("session_waiter 只能在插件运行时消息上下文中使用")
state = _SessionWaitState(queue=asyncio.Queue(), timeout=timeout)
key = manager.register(event, state)
try:
while True:
try:
next_event = await asyncio.wait_for(
state.queue.get(),
timeout=state.timeout,
)
except asyncio.TimeoutError as exc:
raise TimeoutError from exc
controller = SessionController(state)
result = func(controller, next_event, *args, **kwargs)
if inspect.isawaitable(result):
await result
if state.stopped:
return
finally:
manager.unregister(key, state)
runner.__name__ = getattr(func, "__name__", "session_waiter")
runner.__doc__ = getattr(func, "__doc__", None)
runner.__wrapped__ = func
return runner
return decorator
__all__ = [
"SessionController",
"SessionWaiter",
"SessionWaiterManager",
"session_waiter",
]
-171
View File
@@ -1,171 +0,0 @@
"""旧版 ``sp`` 共享偏好存储的轻量兼容实现。
当前实现是进程内存级别的 KV 存储,主要目标是让旧插件的导入和常见读写流程
继续工作,而不是完整复刻旧 core 的数据库持久化语义。
"""
from __future__ import annotations
import asyncio
from collections import defaultdict
from dataclasses import dataclass
from typing import Any, TypeVar
_VT = TypeVar("_VT")
@dataclass(slots=True)
class PreferenceRecord:
scope: str
scope_id: str
key: str
value: dict[str, Any]
class SharedPreferences:
def __init__(self) -> None:
self._store: dict[str, dict[str, dict[str, Any]]] = defaultdict(
lambda: defaultdict(dict)
)
self.temporary_cache: dict[str, dict[str, Any]] = defaultdict(dict)
async def get_async(
self,
scope: str,
scope_id: str,
key: str,
default: _VT = None,
) -> _VT:
return self._store.get(scope, {}).get(scope_id, {}).get(key, default)
async def range_get_async(
self,
scope: str,
scope_id: str | None = None,
key: str | None = None,
) -> list[PreferenceRecord]:
records: list[PreferenceRecord] = []
scope_store = self._store.get(scope, {})
for current_scope_id, values in scope_store.items():
if scope_id is not None and current_scope_id != scope_id:
continue
for current_key, current_value in values.items():
if key is not None and current_key != key:
continue
records.append(
PreferenceRecord(
scope=scope,
scope_id=current_scope_id,
key=current_key,
value={"val": current_value},
)
)
return records
async def session_get(
self,
umo: str | None,
key: str | None = None,
default: _VT = None,
) -> _VT | list[PreferenceRecord]:
if umo is None or key is None:
return await self.range_get_async("umo", umo, key)
return await self.get_async("umo", umo, key, default)
async def global_get(
self,
key: str | None,
default: _VT = None,
) -> _VT | list[PreferenceRecord]:
if key is None:
return await self.range_get_async("global", "global", key)
return await self.get_async("global", "global", key, default)
async def put_async(self, scope: str, scope_id: str, key: str, value: Any) -> None:
self._store[scope][scope_id][key] = value
async def session_put(self, umo: str, key: str, value: Any) -> None:
await self.put_async("umo", umo, key, value)
async def global_put(self, key: str, value: Any) -> None:
await self.put_async("global", "global", key, value)
async def remove_async(self, scope: str, scope_id: str, key: str) -> None:
scope_store = self._store.get(scope)
if not scope_store:
return
values = scope_store.get(scope_id)
if not values:
return
values.pop(key, None)
if not values:
scope_store.pop(scope_id, None)
async def session_remove(self, umo: str, key: str) -> None:
await self.remove_async("umo", umo, key)
async def global_remove(self, key: str) -> None:
await self.remove_async("global", "global", key)
async def clear_async(self, scope: str, scope_id: str) -> None:
scope_store = self._store.get(scope)
if scope_store is None:
return
scope_store.pop(scope_id, None)
def _run_sync(self, coro):
try:
asyncio.get_running_loop()
except RuntimeError:
return asyncio.run(coro)
raise RuntimeError(
"sp 的同步接口不能在运行中的事件循环内调用,请改用 async 版本"
)
def get(
self,
key: str,
default: _VT = None,
scope: str | None = None,
scope_id: str | None = "",
) -> _VT:
return self._run_sync(
self.get_async(scope or "unknown", scope_id or "unknown", key, default)
)
def range_get(
self,
scope: str,
scope_id: str | None = None,
key: str | None = None,
) -> list[PreferenceRecord]:
return self._run_sync(self.range_get_async(scope, scope_id, key))
def put(
self,
key: str,
value: Any,
scope: str | None = None,
scope_id: str | None = None,
) -> None:
self._run_sync(
self.put_async(scope or "unknown", scope_id or "unknown", key, value)
)
def remove(
self,
key: str,
scope: str | None = None,
scope_id: str | None = None,
) -> None:
self._run_sync(
self.remove_async(scope or "unknown", scope_id or "unknown", key)
)
def clear(self, scope: str | None = None, scope_id: str | None = None) -> None:
self._run_sync(self.clear_async(scope or "unknown", scope_id or "unknown"))
sp = SharedPreferences()
__all__ = ["PreferenceRecord", "SharedPreferences", "sp"]
-54
View File
@@ -1,54 +0,0 @@
"""过渡期兼容实现层 ``astrbot_sdk.api``。
这个包承载旧插件所需的兼容模型与逻辑实现,供 legacy 执行路径直接使用。
它不是简单的重导出层,而是维护旧语义所必需的实现模块:
- ``astrbot_sdk.api.event``:``AstrMessageEvent``、``filter``、``MessageEventResult``
- ``astrbot_sdk.api.message``:``MessageChain``、消息组件模型
- ``astrbot_sdk.api.provider``:``LLMResponse`` 兼容实体
- ``astrbot_sdk.api.basic``、``platform``、``components``:配置、平台、组件兼容面
与 ``src-new/astrbot/`` 对比:
- 此包(``astrbot_sdk.api``)是真正的兼容实现,包含运行时逻辑
- ``src-new/astrbot/`` 是薄 facade,只做导入路径转发
迁移目标(仅在有明确迁移计划时移除):
- ``astrbot_sdk.context.Context``
- ``astrbot_sdk.events.MessageEvent``
- ``astrbot_sdk.decorators``
- ``astrbot_sdk.star.Star``
维护约束:
- 新增运行时逻辑优先放在 v4 主路径,由此层按需包装
- compat 真实执行边界收口在顶层私有模块(``_legacy_runtime.py`` 等)
- 新插件应直接使用 ``astrbot_sdk`` 顶层 v4 API
"""
from loguru import logger
from . import (
basic,
components,
event,
message,
message_components,
platform,
provider,
star,
)
from .basic import AstrBotConfig
__all__ = [
"AstrBotConfig",
"basic",
"components",
"event",
"logger",
"message",
"message_components",
"platform",
"provider",
"star",
]
@@ -1,7 +0,0 @@
"""过渡期 ``astrbot_sdk.api.basic`` compat facade。"""
from .astrbot_config import AstrBotConfig
from .conversation_mgr import BaseConversationManager
from .entities import Conversation
__all__ = ["AstrBotConfig", "BaseConversationManager", "Conversation"]
@@ -1,43 +0,0 @@
"""旧版配置对象兼容类型。"""
from __future__ import annotations
import json
from pathlib import Path
from typing import Any
class AstrBotConfig(dict):
"""兼容旧版 ``AstrBotConfig``。
旧版实现本身就是 ``dict`` 的薄封装。compat 层额外补上
``save_config()``,以支持文档里的插件配置用法。
"""
def __init__(
self,
*args: Any,
save_path: str | Path | None = None,
**kwargs: Any,
) -> None:
super().__init__(*args, **kwargs)
self._save_path = Path(save_path) if save_path is not None else None
@property
def save_path(self) -> Path | None:
return self._save_path
def bind_save_path(self, save_path: str | Path | None) -> "AstrBotConfig":
self._save_path = Path(save_path) if save_path is not None else None
return self
def save_config(self, save_path: str | Path | None = None) -> None:
path = Path(save_path) if save_path is not None else self._save_path
if path is None:
raise RuntimeError("AstrBotConfig 未绑定保存路径")
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(
json.dumps(self, ensure_ascii=False, indent=2, sort_keys=True),
encoding="utf-8",
)
self._save_path = path
@@ -1,5 +0,0 @@
"""旧版会话管理器兼容导出。"""
from ..._legacy_api import LegacyConversationManager as BaseConversationManager
__all__ = ["BaseConversationManager"]
-20
View File
@@ -1,20 +0,0 @@
"""旧版基础实体兼容类型。"""
from __future__ import annotations
from dataclasses import dataclass, field
from typing import Any
@dataclass(slots=True)
class Conversation:
"""兼容旧版对话实体。"""
platform_id: str
user_id: str
cid: str
history: list[dict[str, Any]] = field(default_factory=list)
title: str | None = ""
persona_id: str | None = ""
created_at: int = 0
updated_at: int = 0
@@ -1,5 +0,0 @@
"""过渡期 ``astrbot_sdk.api.components`` compat facade。"""
from .command import CommandComponent
__all__ = ["CommandComponent"]
@@ -1,5 +0,0 @@
"""旧版 ``CommandComponent`` 的兼容导出。"""
from ..._legacy_api import CommandComponent
__all__ = ["CommandComponent"]
-28
View File
@@ -1,28 +0,0 @@
"""过渡期 ``astrbot_sdk.api.event`` compat facade。"""
from ..message.chain import MessageChain
from .astr_message_event import AstrMessageEvent, AstrMessageEventModel
from .astrbot_message import AstrBotMessage, Group, MessageMember
from .event_result import EventResultType, MessageEventResult, ResultContentType
from .event_type import EventType
from .filter import ADMIN, filter
from .message_session import MessageSesion, MessageSession
from .message_type import MessageType
__all__ = [
"ADMIN",
"AstrBotMessage",
"AstrMessageEvent",
"AstrMessageEventModel",
"EventResultType",
"EventType",
"Group",
"MessageChain",
"MessageEventResult",
"MessageMember",
"MessageSesion",
"MessageSession",
"MessageType",
"ResultContentType",
"filter",
]
@@ -1,379 +0,0 @@
"""旧版 ``AstrMessageEvent`` 的兼容包装。"""
from __future__ import annotations
from typing import Any, Literal
from pydantic import BaseModel, Field
from ...events import MessageEvent
from ..message.chain import MessageChain
from ..message.components import BaseMessageComponent
from ..platform.platform_metadata import PlatformMetadata
from .astrbot_message import AstrBotMessage, Group, MessageMember
from .event_result import MessageEventResult
from .message_session import MessageSession
from .message_type import MessageType
def _coerce_message_type(
message_type: MessageType | str | None,
*,
has_group: bool = False,
has_user: bool = False,
) -> MessageType:
if isinstance(message_type, MessageType):
return message_type
if isinstance(message_type, str):
try:
return MessageType(message_type)
except ValueError:
pass
if has_group:
return MessageType.GROUP_MESSAGE
if has_user:
return MessageType.FRIEND_MESSAGE
return MessageType.OTHER_MESSAGE
class AstrMessageEventModel(BaseModel):
message_str: str
message_obj: AstrBotMessage
platform_meta: PlatformMetadata | None = None
session_id: str
role: Literal["admin", "member"] = "member"
is_wake: bool = False
is_at_or_wake_command: bool = False
extras: dict[str, Any] = Field(default_factory=dict)
result: MessageEventResult | None = None
has_send_oper: bool = False
call_llm: bool = False
plugins_name: list[str] = Field(default_factory=list)
@classmethod
def from_event(cls, event: "AstrMessageEvent") -> "AstrMessageEventModel":
return cls(
message_str=event.get_message_str(),
message_obj=event.message_obj,
platform_meta=event.platform_meta,
session_id=event.session_id,
role=event.role,
is_wake=event.is_wake,
is_at_or_wake_command=event.is_at_or_wake_command,
extras=dict(event.get_extra()),
result=event.get_result(),
has_send_oper=event.has_send_oper,
call_llm=event.call_llm,
plugins_name=list(event._plugins_name),
)
def to_event(self) -> "AstrMessageEvent":
return AstrMessageEvent(
text=self.message_str,
user_id=self.message_obj.sender.user_id,
group_id=self.message_obj.group_id,
platform=self.platform_meta.id if self.platform_meta else None,
session_id=self.session_id,
raw=self.message_obj.raw_message,
message_obj=self.message_obj,
platform_meta=self.platform_meta,
role=self.role,
is_wake=self.is_wake,
is_at_or_wake_command=self.is_at_or_wake_command,
extras=self.extras,
result=self.result,
has_send_oper=self.has_send_oper,
call_llm=self.call_llm,
plugins_name=self.plugins_name,
)
class AstrMessageEvent(MessageEvent):
def __init__(
self,
*,
text: str = "",
user_id: str | None = None,
group_id: str | None = None,
platform: str | None = None,
session_id: str | None = None,
raw: dict[str, Any] | None = None,
context=None,
reply_handler=None,
message_obj: AstrBotMessage | None = None,
platform_meta: PlatformMetadata | None = None,
role: Literal["admin", "member"] = "member",
is_wake: bool = False,
is_at_or_wake_command: bool = False,
extras: dict[str, Any] | None = None,
result: MessageEventResult | None = None,
has_send_oper: bool = False,
call_llm: bool = False,
plugins_name: list[str] | None = None,
) -> None:
super().__init__(
text=text,
user_id=user_id,
group_id=group_id,
platform=platform,
session_id=session_id,
raw=raw,
context=context,
reply_handler=reply_handler,
)
self.message_obj = message_obj or self._build_message_obj()
self.platform_meta = platform_meta
self.role = role
self.is_wake = is_wake
self.is_at_or_wake_command = is_at_or_wake_command
self._extras = dict(extras or {})
self._result = result
self.has_send_oper = has_send_oper
self.call_llm = call_llm
self._plugins_name = list(plugins_name or [])
self.session = MessageSession(
platform_name=self.get_platform_id(),
message_type=self.get_message_type(),
session_id=self.session_id,
)
self.unified_msg_origin = str(self.session)
def to_payload(self) -> dict[str, Any]:
"""Override to guarantee ``platform`` in the wire payload is always a string id.
``MessageEvent.to_payload()`` serialises ``self.platform`` verbatim.
Since ``AstrMessageEvent`` may receive a ``PlatformMetadata`` object
via ``platform_meta``, we normalise it through ``get_platform_id()``
so the wire format stays a clean ``str | None``.
"""
payload = super().to_payload()
payload["platform"] = self.get_platform_id() or None
return payload
@classmethod
def from_payload(
cls,
payload: dict[str, Any],
*,
context=None,
reply_handler=None,
) -> "AstrMessageEvent":
return cls(
text=str(payload.get("text", payload.get("message_str", ""))),
user_id=payload.get("user_id"),
group_id=payload.get("group_id"),
platform=payload.get("platform"),
session_id=payload.get("session_id"),
raw=payload,
context=context,
reply_handler=reply_handler,
)
@classmethod
def from_message_event(cls, event: MessageEvent) -> "AstrMessageEvent":
if isinstance(event, cls):
return event
return cls(
text=event.text,
user_id=event.user_id,
group_id=event.group_id,
platform=event.platform,
session_id=event.session_id,
raw=event.raw,
context=getattr(event, "_context", None),
reply_handler=getattr(event, "_reply_handler", None),
)
def _build_message_obj(self) -> AstrBotMessage:
sender_payload = (
self.raw.get("sender") if isinstance(self.raw, dict) else {}
) or {}
sender = MessageMember(
user_id=str(sender_payload.get("user_id") or self.user_id or ""),
nickname=sender_payload.get("nickname"),
)
group = None
group_payload = (
self.raw.get("group") if isinstance(self.raw, dict) else None
) or None
if isinstance(group_payload, dict):
group = Group(
group_id=str(group_payload.get("group_id") or self.group_id or ""),
group_name=group_payload.get("group_name"),
group_avatar=group_payload.get("group_avatar"),
group_owner=group_payload.get("group_owner"),
group_admins=group_payload.get("group_admins"),
members=group_payload.get("members"),
)
elif self.group_id:
group = Group(group_id=self.group_id)
message_components = self.raw.get("message")
if not isinstance(message_components, list):
message_components = []
message_type = _coerce_message_type(
self.raw.get("message_type"),
has_group=bool(group),
has_user=bool(self.user_id),
)
return AstrBotMessage(
type=message_type,
self_id=str(self.raw.get("self_id", "")),
session_id=self.session_id,
message_id=str(self.raw.get("message_id", "")),
sender=sender,
message=message_components,
message_str=self.text,
raw_message=self.raw,
group=group,
)
def get_platform_name(self) -> str:
if self.platform_meta is not None:
return self.platform_meta.name
# When no explicit PlatformMetadata is provided, try to derive the
# platform name from the raw payload if it is a dict; otherwise, fall
# back to an empty string.
if isinstance(self.raw, dict):
return str(
self.raw.get("platform_name") or self.raw.get("platform") or ""
)
return ""
def get_platform_id(self) -> str:
# Priority:
# 1. Explicit PlatformMetadata.id
# 2. platform_id / platform from raw payload (if dict)
# 3. Fallback to the inherited MessageEvent.platform field
if self.platform_meta is not None:
return self.platform_meta.id
if isinstance(self.raw, dict):
platform_from_raw = (
self.raw.get("platform_id") or self.raw.get("platform")
)
if platform_from_raw:
return str(platform_from_raw)
if getattr(self, "platform", None) is not None:
return str(self.platform)
return ""
def get_message_str(self) -> str:
return self.text
@property
def message_str(self) -> str:
return self.text
def get_messages(self) -> list[BaseMessageComponent]:
return list(self.message_obj.message)
def get_message_type(self) -> MessageType:
return self.message_obj.type
def get_session_id(self) -> str:
return self.session_id
def get_group_id(self) -> str | None:
if self.message_obj.group is None:
return None
return self.message_obj.group.group_id
def get_self_id(self) -> str:
return self.message_obj.self_id
def get_sender_id(self) -> str:
return self.message_obj.sender.user_id
def get_sender_name(self) -> str | None:
return self.message_obj.sender.nickname
def set_extra(self, key: str, value: Any) -> None:
self._extras[key] = value
def get_extra(self, key: str | None = None, default: Any = None) -> Any:
if key is None:
return self._extras
return self._extras.get(key, default)
def clear_extra(self) -> None:
self._extras.clear()
def is_private_chat(self) -> bool:
return self.get_message_type() == MessageType.FRIEND_MESSAGE
def is_wake_up(self) -> bool:
return self.is_wake
def is_admin(self) -> bool:
return self.role == "admin"
def set_result(self, result: MessageEventResult | str) -> None:
if isinstance(result, str):
result = MessageEventResult().message(result)
self._result = result
def stop_event(self) -> None:
if self._result is None:
self._result = MessageEventResult().stop_event()
return
self._result.stop_event()
def continue_event(self) -> None:
if self._result is None:
self._result = MessageEventResult().continue_event()
return
self._result.continue_event()
def is_stopped(self) -> bool:
if self._result is None:
return False
return self._result.is_stopped()
def should_call_llm(self, call_llm: bool) -> None:
self.call_llm = call_llm
def get_result(self) -> MessageEventResult | None:
return self._result
def clear_result(self) -> None:
self._result = None
def make_result(self) -> MessageEventResult:
return MessageEventResult()
def plain_result(self, text: str) -> MessageEventResult:
return MessageEventResult().message(text)
def image_result(self, url_or_path: str) -> MessageEventResult:
result = MessageEventResult()
if url_or_path.startswith("http"):
return result.url_image(url_or_path)
return result.file_image(url_or_path)
def chain_result(self, chain: list[BaseMessageComponent]) -> MessageEventResult:
result = MessageEventResult()
result.chain = chain
return result
async def send(self, message: MessageChain) -> None:
self.has_send_oper = True
runtime_context = getattr(self, "_context", None)
if runtime_context is not None and not message.is_plain_text_only():
await runtime_context.platform.send_chain(
self.session_ref or self.session_id,
message.to_payload(),
)
return
await self.reply(message.get_plain_text())
async def react(self, emoji: str) -> None:
self.has_send_oper = True
await self.reply(emoji)
async def get_group(self, group_id: str | None = None, **kwargs) -> Group | None:
if self.message_obj.group is None:
return None
if group_id is None or self.message_obj.group.group_id == group_id:
return self.message_obj.group
return None
@@ -1,55 +0,0 @@
"""旧版消息对象兼容类型。"""
from __future__ import annotations
import time
from dataclasses import dataclass, field
from ..message.components import BaseMessageComponent
from .message_type import MessageType
@dataclass(slots=True)
class MessageMember:
user_id: str
nickname: str | None = None
@dataclass(slots=True)
class Group:
group_id: str
group_name: str | None = None
group_avatar: str | None = None
group_owner: str | None = None
group_admins: list[str] | None = None
members: list[MessageMember] | None = None
@dataclass(slots=True)
class AstrBotMessage:
type: MessageType
self_id: str
session_id: str
message_id: str
sender: MessageMember
message: list[BaseMessageComponent] = field(default_factory=list)
message_str: str = ""
raw_message: dict = field(default_factory=dict)
timestamp: int = field(default_factory=lambda: int(time.time()))
group: Group | None = None
@property
def group_id(self) -> str:
if self.group is None:
return ""
return self.group.group_id
@group_id.setter
def group_id(self, value: str) -> None:
if value:
if self.group is None:
self.group = Group(group_id=value)
else:
self.group.group_id = value
return
self.group = None
@@ -1,57 +0,0 @@
"""旧版事件结果兼容类型。"""
from __future__ import annotations
import enum
from dataclasses import dataclass, field
from typing import Any
from ..message.chain import MessageChain
class EventResultType(enum.Enum):
CONTINUE = enum.auto()
STOP = enum.auto()
class ResultContentType(enum.Enum):
LLM_RESULT = enum.auto()
GENERAL_RESULT = enum.auto()
STREAMING_RESULT = enum.auto()
STREAMING_FINISH = enum.auto()
@dataclass
class MessageEventResult(MessageChain):
result_type: EventResultType | None = field(
default_factory=lambda: EventResultType.CONTINUE
)
result_content_type: ResultContentType | None = field(
default_factory=lambda: ResultContentType.GENERAL_RESULT
)
async_stream: Any | None = None
def stop_event(self) -> "MessageEventResult":
self.result_type = EventResultType.STOP
return self
def continue_event(self) -> "MessageEventResult":
self.result_type = EventResultType.CONTINUE
return self
def is_stopped(self) -> bool:
return self.result_type == EventResultType.STOP
def set_async_stream(self, stream: Any) -> "MessageEventResult":
self.async_stream = stream
return self
def set_result_content_type(self, typ: ResultContentType) -> "MessageEventResult":
self.result_content_type = typ
return self
def is_llm_result(self) -> bool:
return self.result_content_type == ResultContentType.LLM_RESULT
CommandResult = MessageEventResult
@@ -1,16 +0,0 @@
"""旧版事件类型兼容枚举。"""
from __future__ import annotations
import enum
class EventType(enum.Enum):
OnAstrBotLoadedEvent = enum.auto()
OnPlatformLoadedEvent = enum.auto()
AdapterMessageEvent = enum.auto()
OnLLMRequestEvent = enum.auto()
OnLLMResponseEvent = enum.auto()
OnDecoratingResultEvent = enum.auto()
OnCallingFuncToolEvent = enum.auto()
OnAfterMessageSentEvent = enum.auto()
-706
View File
@@ -1,706 +0,0 @@
"""旧版事件过滤器兼容层。
当前兼容层保证以下能力可运行:
- ``command(name, alias=..., priority=...)`` -> ``CommandTrigger``
- ``regex(pattern, priority=...)`` -> ``MessageTrigger``
- ``custom_filter(...)`` -> 记录旧自定义过滤器,运行时在分发前执行
- ``event_message_type(...)`` -> 记录消息类型约束
- ``platform_adapter_type(...)`` -> 记录平台约束
- ``permission(ADMIN)`` / ``permission_type(PermissionType.ADMIN)``
-> ``require_admin``
- ``after_message_sent`` / ``on_llm_request`` / ``llm_tool`` 等旧 hook
-> 记录 compat 元数据,由 legacy 运行时在可映射链路中执行
其余没有等价执行链路的旧 helper 仍然显式报错,避免静默失效。
"""
from __future__ import annotations
import inspect
from dataclasses import dataclass
import enum
from abc import ABCMeta, abstractmethod
from typing import Any
from ...decorators import _get_or_create_meta, require_admin
from ...protocol.descriptors import CommandTrigger, MessageTrigger
from ..basic.astrbot_config import AstrBotConfig
from .astr_message_event import AstrMessageEvent
from .message_type import MessageType
ADMIN = "admin"
COMPAT_HOOKS_ATTR = "__astrbot_compat_hooks__"
COMPAT_LLM_TOOL_ATTR = "__astrbot_compat_llm_tool__"
COMPAT_CUSTOM_FILTERS_ATTR = "__astrbot_compat_custom_filters__"
@dataclass(slots=True)
class CompatHookMeta:
name: str
priority: int = 0
@dataclass(slots=True)
class CompatLLMToolMeta:
name: str
description: str
parameters: list[dict[str, Any]]
class PermissionType(enum.Flag):
ADMIN = enum.auto()
MEMBER = enum.auto()
class PermissionTypeFilter:
def __init__(self, permission_type: PermissionType, raise_error: bool = True):
self.permission_type = permission_type
self.raise_error = raise_error
def filter(self, event: AstrMessageEvent, cfg: AstrBotConfig) -> bool:
if self.permission_type == PermissionType.ADMIN:
return event.is_admin()
return True
class EventMessageType(enum.Flag):
GROUP_MESSAGE = enum.auto()
PRIVATE_MESSAGE = enum.auto()
OTHER_MESSAGE = enum.auto()
ALL = GROUP_MESSAGE | PRIVATE_MESSAGE | OTHER_MESSAGE
MESSAGE_TYPE_2_EVENT_MESSAGE_TYPE = {
MessageType.GROUP_MESSAGE: EventMessageType.GROUP_MESSAGE,
MessageType.FRIEND_MESSAGE: EventMessageType.PRIVATE_MESSAGE,
MessageType.OTHER_MESSAGE: EventMessageType.OTHER_MESSAGE,
}
class EventMessageTypeFilter:
def __init__(self, event_message_type: EventMessageType):
self.event_message_type = event_message_type
def filter(self, event: AstrMessageEvent, cfg: AstrBotConfig) -> bool:
event_message_type = MESSAGE_TYPE_2_EVENT_MESSAGE_TYPE.get(
event.get_message_type()
)
if event_message_type is None:
return False
return bool(event_message_type & self.event_message_type)
class PlatformAdapterType(enum.Flag):
AIOCQHTTP = enum.auto()
QQOFFICIAL = enum.auto()
GEWECHAT = enum.auto()
TELEGRAM = enum.auto()
WECOM = enum.auto()
LARK = enum.auto()
WECHATPADPRO = enum.auto()
DINGTALK = enum.auto()
DISCORD = enum.auto()
SLACK = enum.auto()
KOOK = enum.auto()
VOCECHAT = enum.auto()
WEIXIN_OFFICIAL_ACCOUNT = enum.auto()
SATORI = enum.auto()
MISSKEY = enum.auto()
ALL = (
AIOCQHTTP
| QQOFFICIAL
| GEWECHAT
| TELEGRAM
| WECOM
| LARK
| WECHATPADPRO
| DINGTALK
| DISCORD
| SLACK
| KOOK
| VOCECHAT
| WEIXIN_OFFICIAL_ACCOUNT
| SATORI
| MISSKEY
)
ADAPTER_NAME_2_TYPE = {
"aiocqhttp": PlatformAdapterType.AIOCQHTTP,
"qq_official": PlatformAdapterType.QQOFFICIAL,
"gewechat": PlatformAdapterType.GEWECHAT,
"telegram": PlatformAdapterType.TELEGRAM,
"wecom": PlatformAdapterType.WECOM,
"lark": PlatformAdapterType.LARK,
"dingtalk": PlatformAdapterType.DINGTALK,
"discord": PlatformAdapterType.DISCORD,
"slack": PlatformAdapterType.SLACK,
"kook": PlatformAdapterType.KOOK,
"wechatpadpro": PlatformAdapterType.WECHATPADPRO,
"vocechat": PlatformAdapterType.VOCECHAT,
"weixin_official_account": PlatformAdapterType.WEIXIN_OFFICIAL_ACCOUNT,
"satori": PlatformAdapterType.SATORI,
"misskey": PlatformAdapterType.MISSKEY,
}
class PlatformAdapterTypeFilter:
def __init__(self, platform_adapter_type_or_str: PlatformAdapterType | str):
if isinstance(platform_adapter_type_or_str, str):
self.platform_type = ADAPTER_NAME_2_TYPE.get(platform_adapter_type_or_str)
else:
self.platform_type = platform_adapter_type_or_str
def filter(self, event: AstrMessageEvent, cfg: AstrBotConfig) -> bool:
adapter_type = ADAPTER_NAME_2_TYPE.get(event.get_platform_name())
if adapter_type is None or self.platform_type is None:
return False
return bool(adapter_type & self.platform_type)
class CustomFilterMeta(ABCMeta):
def __and__(cls, other):
if not issubclass(other, CustomFilter):
raise TypeError("Operands must be subclasses of CustomFilter.")
return CustomFilterAnd(cls(), other())
def __or__(cls, other):
if not issubclass(other, CustomFilter):
raise TypeError("Operands must be subclasses of CustomFilter.")
return CustomFilterOr(cls(), other())
class CustomFilter(metaclass=CustomFilterMeta):
def __init__(self, raise_error: bool = True, **kwargs: Any):
self.raise_error = raise_error
@abstractmethod
def filter(self, event: AstrMessageEvent, cfg: AstrBotConfig) -> bool:
raise NotImplementedError
def __or__(self, other):
return CustomFilterOr(self, other)
def __and__(self, other):
return CustomFilterAnd(self, other)
class CustomFilterOr(CustomFilter):
def __init__(self, filter1: CustomFilter, filter2: CustomFilter):
super().__init__()
self.filter1 = filter1
self.filter2 = filter2
def filter(self, event: AstrMessageEvent, cfg: AstrBotConfig) -> bool:
return self.filter1.filter(event, cfg) or self.filter2.filter(event, cfg)
class CustomFilterAnd(CustomFilter):
def __init__(self, filter1: CustomFilter, filter2: CustomFilter):
super().__init__()
self.filter1 = filter1
self.filter2 = filter2
def filter(self, event: AstrMessageEvent, cfg: AstrBotConfig) -> bool:
return self.filter1.filter(event, cfg) and self.filter2.filter(event, cfg)
EVENT_MESSAGE_TYPE_NAMES = {
EventMessageType.GROUP_MESSAGE: "group",
EventMessageType.PRIVATE_MESSAGE: "private",
EventMessageType.OTHER_MESSAGE: "other",
}
_LLM_TOOL_PARAM_TYPES: dict[type[Any], str] = {
str: "string",
int: "number",
float: "number",
bool: "boolean",
dict: "object",
list: "array",
}
def _append_compat_hook(func, name: str, *, priority: int | None = None):
hooks = list(getattr(func, COMPAT_HOOKS_ATTR, ()))
hooks.append(CompatHookMeta(name=name, priority=priority or 0))
setattr(func, COMPAT_HOOKS_ATTR, hooks)
return func
def get_compat_hook_metas(func) -> list[CompatHookMeta]:
return list(getattr(func, COMPAT_HOOKS_ATTR, ()))
def _append_custom_filter(func, filter_obj: Any):
filters = list(getattr(func, COMPAT_CUSTOM_FILTERS_ATTR, ()))
filters.append(filter_obj)
setattr(func, COMPAT_CUSTOM_FILTERS_ATTR, filters)
return func
def get_compat_custom_filters(func) -> list[Any]:
return list(getattr(func, COMPAT_CUSTOM_FILTERS_ATTR, ()))
def _doc_description(func) -> str:
doc = inspect.getdoc(func) or ""
if not doc:
return ""
return doc.split("\n\n", 1)[0].strip()
def _parameter_description(func, parameter_name: str) -> str:
doc = inspect.getdoc(func) or ""
if not doc:
return ""
in_args = False
for raw_line in doc.splitlines():
line = raw_line.rstrip()
stripped = line.strip()
if stripped in {"Args:", "Arguments:"}:
in_args = True
continue
if in_args and stripped and not raw_line.startswith((" ", "\t")):
break
if not in_args:
continue
if stripped.startswith(f"{parameter_name}(") or stripped.startswith(
f"{parameter_name}:"
):
_, _, tail = stripped.partition(":")
return tail.strip()
return ""
def _resolve_json_schema(
func,
parameter: inspect.Parameter,
annotations: dict[str, Any] | None = None,
) -> dict[str, Any]:
annotation = (
annotations.get(parameter.name, parameter.annotation)
if annotations is not None
else parameter.annotation
)
origin = getattr(annotation, "__origin__", None)
args = getattr(annotation, "__args__", ())
item_type = None
if annotation in _LLM_TOOL_PARAM_TYPES:
type_name = _LLM_TOOL_PARAM_TYPES[annotation]
elif origin in {list, tuple}:
type_name = "array"
if args:
item_type = _LLM_TOOL_PARAM_TYPES.get(args[0], "string")
elif origin is dict:
type_name = "object"
else:
type_name = "string"
schema = {
"type": type_name,
"name": parameter.name,
"description": _parameter_description(func, parameter.name),
}
if item_type is not None:
schema["items"] = {"type": item_type}
return schema
def _build_llm_tool_meta(func, tool_name: str | None) -> CompatLLMToolMeta:
signature = inspect.signature(func)
annotations = inspect.get_annotations(func, eval_str=True)
parameters: list[dict[str, Any]] = []
for parameter in signature.parameters.values():
if parameter.kind not in (
inspect.Parameter.POSITIONAL_ONLY,
inspect.Parameter.POSITIONAL_OR_KEYWORD,
):
continue
if parameter.name in {
"self",
"event",
"ctx",
"context",
"cancel_token",
"token",
}:
continue
parameters.append(_resolve_json_schema(func, parameter, annotations))
return CompatLLMToolMeta(
name=tool_name or func.__name__,
description=_doc_description(func),
parameters=parameters,
)
def get_compat_llm_tool_meta(func) -> CompatLLMToolMeta | None:
return getattr(func, COMPAT_LLM_TOOL_ATTR, None)
def _merge_unique(existing: list[str], additions: list[str]) -> list[str]:
merged: list[str] = []
for item in [*existing, *additions]:
if item not in merged:
merged.append(item)
return merged
def _normalize_aliases(*alias_groups: Any) -> list[str]:
aliases: list[str] = []
for alias_group in alias_groups:
if alias_group is None:
continue
if isinstance(alias_group, str):
values = [alias_group]
elif isinstance(alias_group, set):
values = sorted(str(item) for item in alias_group)
else:
values = [str(item) for item in alias_group]
aliases = _merge_unique(aliases, values)
return aliases
def _existing_trigger_constraints(
trigger: CommandTrigger | MessageTrigger | None,
) -> tuple[list[str], list[str], list[str]]:
if isinstance(trigger, CommandTrigger):
return list(trigger.platforms), list(trigger.message_types), []
if isinstance(trigger, MessageTrigger):
return (
list(trigger.platforms),
list(trigger.message_types),
list(trigger.keywords),
)
return [], [], []
def _apply_priority(meta, priority: int | None) -> None:
if priority is not None:
meta.priority = priority
def _selected_message_types(event_type: EventMessageType) -> list[str]:
selected: list[str] = []
for flag, name in EVENT_MESSAGE_TYPE_NAMES.items():
if event_type & flag:
selected.append(name)
return selected
def _selected_platforms(
platform_type: PlatformAdapterType | str,
) -> list[str]:
if isinstance(platform_type, str):
return [platform_type]
selected: list[str] = []
for name, flag in ADAPTER_NAME_2_TYPE.items():
if platform_type & flag:
selected.append(name)
return selected
def command(
name: str,
alias: set[str] | list[str] | tuple[str, ...] | str | None = None,
*,
aliases: set[str] | list[str] | tuple[str, ...] | str | None = None,
priority: int | None = None,
desc: str | None = None,
):
def decorator(func):
meta = _get_or_create_meta(func)
platforms, message_types, _ = _existing_trigger_constraints(meta.trigger)
meta.trigger = CommandTrigger(
command=name,
aliases=_normalize_aliases(alias, aliases),
description=desc,
platforms=platforms,
message_types=message_types,
)
_apply_priority(meta, priority)
return func
return decorator
def regex(pattern: str, *, priority: int | None = None):
def decorator(func):
meta = _get_or_create_meta(func)
platforms, message_types, keywords = _existing_trigger_constraints(meta.trigger)
meta.trigger = MessageTrigger(
regex=pattern,
keywords=keywords,
platforms=platforms,
message_types=message_types,
)
_apply_priority(meta, priority)
return func
return decorator
def permission(level: str | PermissionType):
if level in {ADMIN, PermissionType.ADMIN}:
return require_admin
def decorator(func):
return func
return decorator
def permission_type(level: PermissionType, raise_error: bool = True):
return permission(level)
class LegacyCommandGroup:
"""旧版命令组兼容对象。
当前运行时还没有旧版树状帮助与多层命令组执行链,所以 compat 层先把
`group sub` 展平为普通命令名,确保真实旧插件至少能无感加载与分发。
"""
def __init__(
self,
*parts: str,
priority: int | None = None,
desc: str | None = None,
) -> None:
self._parts = tuple(str(part) for part in parts if str(part))
self._priority = priority
self._desc = desc
def __call__(self, func):
return self
def command(
self,
name: str,
alias: set[str] | list[str] | tuple[str, ...] | str | None = None,
*,
aliases: set[str] | list[str] | tuple[str, ...] | str | None = None,
priority: int | None = None,
desc: str | None = None,
):
return command(
" ".join((*self._parts, name)),
alias=alias,
aliases=aliases,
priority=self._priority if priority is None else priority,
desc=desc,
)
def group(
self,
name: str,
*,
priority: int | None = None,
desc: str | None = None,
) -> "LegacyCommandGroup":
return LegacyCommandGroup(
*self._parts,
name,
priority=self._priority if priority is None else priority,
desc=desc,
)
def _unsupported_factory(name: str, replacement: str | None = None):
suggestion = f"请改用 {replacement}" if replacement else "当前没有直接替代实现"
message = (
f"astrbot_sdk.api.event.filter.{name}() 尚未在 v4 兼容层中实现。"
f"{suggestion},或改写为新版插件结构。"
)
def factory(*args, **kwargs):
raise NotImplementedError(message)
return factory
def custom_filter(custom_type_filter, raise_error: bool = True, **kwargs):
"""旧版自定义过滤器兼容入口。
当前 compat 层支持最常见的函数级 `@custom_filter(MyFilter)` 用法。
指令组级自定义过滤链路仍然依赖旧 command_group 树,不在 v4 主链里复刻。
"""
def decorator(func):
if isinstance(custom_type_filter, (CustomFilterAnd, CustomFilterOr)):
filter_obj = custom_type_filter
elif isinstance(custom_type_filter, type) and issubclass(
custom_type_filter, CustomFilter
):
filter_obj = custom_type_filter(raise_error=raise_error, **kwargs)
elif isinstance(custom_type_filter, CustomFilter):
filter_obj = custom_type_filter
else:
raise TypeError("custom_filter 只支持 CustomFilter 子类或实例")
return _append_custom_filter(func, filter_obj)
return decorator
def _compat_hook(name: str):
def factory(*, priority: int | None = None, **_kwargs):
def decorator(func):
return _append_compat_hook(func, name, priority=priority)
return decorator
return factory
after_message_sent = _compat_hook("after_message_sent")
on_astrbot_loaded = _compat_hook("on_astrbot_loaded")
on_platform_loaded = _compat_hook("on_platform_loaded")
on_decorating_result = _compat_hook("on_decorating_result")
on_llm_request = _compat_hook("on_llm_request")
on_llm_response = _compat_hook("on_llm_response")
on_waiting_llm_request = _compat_hook("on_waiting_llm_request")
on_using_llm_tool = _compat_hook("on_using_llm_tool")
on_llm_tool_respond = _compat_hook("on_llm_tool_respond")
on_plugin_error = _compat_hook("on_plugin_error")
on_plugin_loaded = _compat_hook("on_plugin_loaded")
on_plugin_unloaded = _compat_hook("on_plugin_unloaded")
def llm_tool(name: str | None = None, **_kwargs):
def decorator(func):
setattr(func, COMPAT_LLM_TOOL_ATTR, _build_llm_tool_meta(func, name))
return func
return decorator
def command_group(
name: str,
alias: set[str] | list[str] | tuple[str, ...] | str | None = None,
*,
aliases: set[str] | list[str] | tuple[str, ...] | str | None = None,
priority: int | None = None,
desc: str | None = None,
) -> LegacyCommandGroup:
del alias, aliases
return LegacyCommandGroup(name, priority=priority, desc=desc)
def event_message_type(
level: EventMessageType,
*,
priority: int | None = None,
):
message_types = _selected_message_types(level)
def decorator(func):
meta = _get_or_create_meta(func)
if meta.trigger is None:
meta.trigger = MessageTrigger(message_types=message_types)
elif isinstance(meta.trigger, MessageTrigger):
meta.trigger.message_types = _merge_unique(
meta.trigger.message_types,
message_types,
)
elif isinstance(meta.trigger, CommandTrigger):
meta.trigger.message_types = _merge_unique(
meta.trigger.message_types,
message_types,
)
else:
raise NotImplementedError(
"event_message_type() 目前只支持消息/命令处理器。"
)
_apply_priority(meta, priority)
return func
return decorator
def platform_adapter_type(
level: PlatformAdapterType | str,
*,
priority: int | None = None,
):
platforms = _selected_platforms(level)
def decorator(func):
meta = _get_or_create_meta(func)
if meta.trigger is None:
meta.trigger = MessageTrigger(platforms=platforms)
elif isinstance(meta.trigger, MessageTrigger):
meta.trigger.platforms = _merge_unique(meta.trigger.platforms, platforms)
elif isinstance(meta.trigger, CommandTrigger):
meta.trigger.platforms = _merge_unique(meta.trigger.platforms, platforms)
else:
raise NotImplementedError(
"platform_adapter_type() 目前只支持消息/命令处理器。"
)
_apply_priority(meta, priority)
return func
return decorator
class _FilterNamespace:
ADMIN = ADMIN
PermissionType = PermissionType
EventMessageType = EventMessageType
PlatformAdapterType = PlatformAdapterType
command = staticmethod(command)
regex = staticmethod(regex)
permission = staticmethod(permission)
permission_type = staticmethod(permission_type)
custom_filter = staticmethod(custom_filter)
event_message_type = staticmethod(event_message_type)
platform_adapter_type = staticmethod(platform_adapter_type)
after_message_sent = staticmethod(after_message_sent)
on_astrbot_loaded = staticmethod(on_astrbot_loaded)
on_platform_loaded = staticmethod(on_platform_loaded)
on_decorating_result = staticmethod(on_decorating_result)
on_llm_request = staticmethod(on_llm_request)
on_llm_response = staticmethod(on_llm_response)
llm_tool = staticmethod(llm_tool)
on_waiting_llm_request = staticmethod(on_waiting_llm_request)
on_using_llm_tool = staticmethod(on_using_llm_tool)
on_llm_tool_respond = staticmethod(on_llm_tool_respond)
on_plugin_error = staticmethod(on_plugin_error)
on_plugin_loaded = staticmethod(on_plugin_loaded)
on_plugin_unloaded = staticmethod(on_plugin_unloaded)
command_group = staticmethod(command_group)
filter = _FilterNamespace()
__all__ = [
"ADMIN",
"CustomFilter",
"EventMessageType",
"EventMessageTypeFilter",
"PermissionType",
"PermissionTypeFilter",
"PlatformAdapterType",
"PlatformAdapterTypeFilter",
"after_message_sent",
"command",
"command_group",
"custom_filter",
"event_message_type",
"filter",
"llm_tool",
"on_astrbot_loaded",
"on_decorating_result",
"on_llm_tool_respond",
"on_llm_request",
"on_llm_response",
"on_platform_loaded",
"on_plugin_error",
"on_plugin_loaded",
"on_plugin_unloaded",
"on_using_llm_tool",
"on_waiting_llm_request",
"permission",
"permission_type",
"platform_adapter_type",
"regex",
]
@@ -1,29 +0,0 @@
"""旧版消息会话标识兼容类型。"""
from __future__ import annotations
from dataclasses import dataclass
from .message_type import MessageType
@dataclass(slots=True)
class MessageSession:
platform_name: str
message_type: MessageType
session_id: str
platform_id: str | None = None
def __post_init__(self) -> None:
self.platform_id = self.platform_name
def __str__(self) -> str:
return f"{self.platform_id}:{self.message_type.value}:{self.session_id}"
@staticmethod
def from_str(session_str: str) -> "MessageSession":
platform_id, message_type, session_id = session_str.split(":")
return MessageSession(platform_id, MessageType(message_type), session_id)
MessageSesion = MessageSession
@@ -1,11 +0,0 @@
"""旧版消息类型兼容枚举。"""
from __future__ import annotations
from enum import Enum
class MessageType(Enum):
GROUP_MESSAGE = "GroupMessage"
FRIEND_MESSAGE = "FriendMessage"
OTHER_MESSAGE = "OtherMessage"
@@ -1,65 +0,0 @@
"""过渡期 ``astrbot_sdk.api.message`` compat facade。"""
from . import components as Comp
from .chain import MessageChain
from .components import (
At,
AtAll,
BaseMessageComponent,
ComponentTypes,
ComponentType,
CompT,
Contact,
Dice,
Face,
File,
Forward,
Image,
Json,
Location,
Music,
Node,
Nodes,
Plain,
Poke,
Record,
Reply,
RPS,
Shake,
Share,
Unknown,
Video,
WechatEmoji,
)
__all__ = [
"At",
"AtAll",
"BaseMessageComponent",
"Comp",
"ComponentTypes",
"ComponentType",
"CompT",
"Contact",
"Dice",
"Face",
"File",
"Forward",
"Image",
"Json",
"Location",
"MessageChain",
"Music",
"Node",
"Nodes",
"Plain",
"Poke",
"Record",
"Reply",
"RPS",
"Shake",
"Share",
"Unknown",
"Video",
"WechatEmoji",
]
-109
View File
@@ -1,109 +0,0 @@
"""旧版消息链兼容实现。"""
from __future__ import annotations
from dataclasses import dataclass, field
from typing import Any
from . import components as Comp
@dataclass(slots=True)
class MessageChain:
chain: list[Comp.BaseMessageComponent] = field(default_factory=list)
use_t2i_: bool | None = None
type: str | None = None
def message(self, message: str) -> "MessageChain":
self.chain.append(Comp.Plain(text=message))
return self
def at(self, name: str, qq: str) -> "MessageChain":
self.chain.append(Comp.At(user_id=qq, user_name=name))
return self
def at_all(self) -> "MessageChain":
self.chain.append(Comp.AtAll())
return self
def error(self, message: str) -> "MessageChain":
self.chain.append(Comp.Plain(text=message))
return self
def url_image(self, url: str) -> "MessageChain":
self.chain.append(Comp.Image(file=url))
return self
def file_image(self, path: str) -> "MessageChain":
self.chain.append(Comp.Image(file=path))
return self
def base64_image(self, base64_str: str) -> "MessageChain":
self.chain.append(Comp.Image(file=base64_str))
return self
def use_t2i(self, use_t2i: bool) -> "MessageChain":
self.use_t2i_ = use_t2i
return self
def to_payload(self) -> list[dict[str, Any]]:
payload: list[dict[str, Any]] = []
for component in self.chain:
if isinstance(component, dict):
payload.append(dict(component))
continue
to_dict = getattr(component, "to_dict", None)
if callable(to_dict):
payload.append(to_dict())
continue
model_dump = getattr(component, "model_dump", None)
if callable(model_dump):
payload.append(model_dump())
continue
payload.append({"type": "Unknown", "text": str(component)})
return payload
def is_plain_text_only(self) -> bool:
if not self.chain:
return False
for component in self.chain:
if isinstance(component, Comp.Plain):
continue
if isinstance(component, dict) and str(component.get("type")) in {
"Plain",
"plain",
"text",
}:
continue
return False
return True
def get_plain_text(self) -> str:
return " ".join(
component.text
for component in self.chain
if isinstance(component, Comp.Plain)
)
def squash_plain(self) -> "MessageChain":
if not self.chain:
return self
new_chain: list[Comp.BaseMessageComponent] = []
first_plain: Comp.Plain | None = None
plain_texts: list[str] = []
for component in self.chain:
if isinstance(component, Comp.Plain):
if first_plain is None:
first_plain = component
new_chain.append(component)
plain_texts.append(component.text)
else:
new_chain.append(component)
if first_plain is not None:
first_plain.text = "".join(plain_texts)
self.chain = new_chain
return self
@@ -1,246 +0,0 @@
"""旧版消息组件兼容类型。"""
from __future__ import annotations
import base64
from enum import Enum
from typing import Literal
from pydantic import AliasChoices, BaseModel, Field
class ComponentType(str, Enum):
Plain = "Plain"
Image = "Image"
Record = "Record"
Video = "Video"
File = "File"
Face = "Face"
At = "At"
Node = "Node"
Nodes = "Nodes"
Poke = "Poke"
Reply = "Reply"
Forward = "Forward"
RPS = "RPS"
Dice = "Dice"
Shake = "Shake"
Share = "Share"
Contact = "Contact"
Location = "Location"
Music = "Music"
Json = "Json"
Unknown = "Unknown"
WechatEmoji = "WechatEmoji"
CompT = ComponentType
class BaseMessageComponent(BaseModel):
type: CompT
def to_dict(self) -> dict:
return self.model_dump(mode="json")
class Plain(BaseMessageComponent):
type: Literal[CompT.Plain] = CompT.Plain
text: str
class Image(BaseMessageComponent):
type: Literal[CompT.Image] = CompT.Image
file: str = Field(validation_alias=AliasChoices("file", "url", "path"))
@classmethod
def fromBytes(cls, data: bytes) -> "Image":
encoded = base64.b64encode(data).decode("ascii")
return cls(file=f"base64://{encoded}")
@classmethod
def fromURL(cls, url: str) -> "Image":
return cls(file=url)
@classmethod
def fromFileSystem(cls, path: str) -> "Image":
return cls(file=path)
class Record(BaseMessageComponent):
type: Literal[CompT.Record] = CompT.Record
file: str = Field(validation_alias=AliasChoices("file", "url", "path"))
@classmethod
def fromURL(cls, url: str) -> "Record":
return cls(file=url)
@classmethod
def fromFileSystem(cls, path: str) -> "Record":
return cls(file=path)
class Video(BaseMessageComponent):
type: Literal[CompT.Video] = CompT.Video
file: str = Field(validation_alias=AliasChoices("file", "url", "path"))
@classmethod
def fromURL(cls, url: str) -> "Video":
return cls(file=url)
@classmethod
def fromFileSystem(cls, path: str) -> "Video":
return cls(file=path)
class File(BaseMessageComponent):
type: Literal[CompT.File] = CompT.File
file_name: str = Field(validation_alias=AliasChoices("file_name", "name"))
mime_type: str | None = None
file: str = Field(validation_alias=AliasChoices("file", "url", "path"))
class At(BaseMessageComponent):
type: Literal[CompT.At] = CompT.At
user_id: str | None = Field(
default=None,
validation_alias=AliasChoices("user_id", "qq"),
)
user_name: str | None = Field(
default=None,
validation_alias=AliasChoices("user_name", "name"),
)
class AtAll(At):
user_id: str = "all"
class Reply(BaseMessageComponent):
type: Literal[CompT.Reply] = CompT.Reply
id: str | int
chain: list[BaseMessageComponent] = Field(default_factory=list)
sender_id: int | str | None = 0
sender_nickname: str | None = ""
time: int | None = 0
message_str: str | None = ""
class Node(BaseMessageComponent):
type: Literal[CompT.Node] = CompT.Node
sender_id: str = Field(validation_alias=AliasChoices("sender_id", "uin"))
nickname: str | None = Field(
default=None,
validation_alias=AliasChoices("nickname", "name"),
)
content: list[BaseMessageComponent] = Field(default_factory=list)
class Nodes(BaseMessageComponent):
type: Literal[CompT.Nodes] = CompT.Nodes
nodes: list[Node] = Field(default_factory=list)
class Face(BaseMessageComponent):
type: Literal[CompT.Face] = CompT.Face
id: int
class RPS(BaseMessageComponent):
type: Literal[CompT.RPS] = CompT.RPS
class Dice(BaseMessageComponent):
type: Literal[CompT.Dice] = CompT.Dice
class Shake(BaseMessageComponent):
type: Literal[CompT.Shake] = CompT.Shake
class Share(BaseMessageComponent):
type: Literal[CompT.Share] = CompT.Share
url: str
title: str
content: str | None = ""
image: str | None = ""
class Contact(BaseMessageComponent):
type: Literal[CompT.Contact] = CompT.Contact
_type: str
id: int | None = 0
class Location(BaseMessageComponent):
type: Literal[CompT.Location] = CompT.Location
lat: float
lon: float
title: str | None = ""
content: str | None = ""
class Music(BaseMessageComponent):
type: Literal[CompT.Music] = CompT.Music
_type: str
id: int | None = 0
url: str | None = ""
audio: str | None = ""
title: str | None = ""
content: str | None = ""
image: str | None = ""
class Poke(BaseMessageComponent):
type: Literal[CompT.Poke] = CompT.Poke
id: int | None = 0
qq: int | None = 0
class Forward(BaseMessageComponent):
type: Literal[CompT.Forward] = CompT.Forward
id: str
class Json(BaseMessageComponent):
type: Literal[CompT.Json] = CompT.Json
data: dict
class Unknown(BaseMessageComponent):
type: Literal[CompT.Unknown] = CompT.Unknown
text: str
class WechatEmoji(BaseMessageComponent):
type: Literal[CompT.WechatEmoji] = CompT.WechatEmoji
md5: str | None = ""
md5_len: int | None = 0
cdnurl: str | None = ""
ComponentTypes = {
"plain": Plain,
"text": Plain,
"image": Image,
"record": Record,
"video": Video,
"file": File,
"face": Face,
"at": At,
"rps": RPS,
"dice": Dice,
"shake": Shake,
"share": Share,
"contact": Contact,
"location": Location,
"music": Music,
"reply": Reply,
"poke": Poke,
"forward": Forward,
"node": Node,
"nodes": Nodes,
"json": Json,
"unknown": Unknown,
"WechatEmoji": WechatEmoji,
}
@@ -1,61 +0,0 @@
"""过渡期 ``astrbot_sdk.api.message_components`` compat facade。"""
from .message.components import (
At,
AtAll,
BaseMessageComponent,
ComponentTypes,
ComponentType,
CompT,
Contact,
Dice,
Face,
File,
Forward,
Image,
Json,
Location,
Music,
Node,
Nodes,
Plain,
Poke,
Record,
Reply,
RPS,
Shake,
Share,
Unknown,
Video,
WechatEmoji,
)
__all__ = [
"At",
"AtAll",
"BaseMessageComponent",
"ComponentTypes",
"ComponentType",
"CompT",
"Contact",
"Dice",
"Face",
"File",
"Forward",
"Image",
"Json",
"Location",
"Music",
"Node",
"Nodes",
"Plain",
"Poke",
"Record",
"Reply",
"RPS",
"Shake",
"Share",
"Unknown",
"Video",
"WechatEmoji",
]
@@ -1,5 +0,0 @@
"""过渡期 ``astrbot_sdk.api.platform`` compat facade。"""
from .platform_metadata import PlatformMetadata
__all__ = ["PlatformMetadata"]
@@ -1,15 +0,0 @@
"""旧版平台元数据兼容类型。"""
from __future__ import annotations
from dataclasses import dataclass
@dataclass(slots=True)
class PlatformMetadata:
name: str
description: str
id: str
default_config_tmpl: dict | None = None
adapter_display_name: str | None = None
logo_path: str | None = None
@@ -1,5 +0,0 @@
"""过渡期 ``astrbot_sdk.api.provider`` compat facade。"""
from .entities import LLMResponse
__all__ = ["LLMResponse"]
@@ -1,118 +0,0 @@
"""旧版 Provider 实体兼容类型。"""
from __future__ import annotations
import json
from dataclasses import dataclass
from typing import Any
from ..message import components as Comp
from ..message.chain import MessageChain
try:
from astr_agent_sdk.message import ToolCall as _ToolCall
except ImportError:
@dataclass(slots=True)
class _ToolCallFunctionBody:
name: str
arguments: str
@dataclass(slots=True)
class _ToolCall:
id: str
function: _ToolCallFunctionBody
FunctionBody = _ToolCallFunctionBody
@dataclass(init=False)
class LLMResponse:
"""兼容旧版 LLM 响应对象。"""
role: str
result_chain: MessageChain | None
tools_call_args: list[dict[str, Any]]
tools_call_name: list[str]
tools_call_ids: list[str]
raw_completion: Any | None
_new_record: dict[str, Any] | None
_completion_text: str
is_chunk: bool
def __init__(
self,
role: str,
completion_text: str = "",
result_chain: MessageChain | None = None,
tools_call_args: list[dict[str, Any]] | None = None,
tools_call_name: list[str] | None = None,
tools_call_ids: list[str] | None = None,
raw_completion: Any | None = None,
_new_record: dict[str, Any] | None = None,
is_chunk: bool = False,
) -> None:
self.role = role
self.result_chain = result_chain
self.tools_call_args = list(tools_call_args or [])
self.tools_call_name = list(tools_call_name or [])
self.tools_call_ids = list(tools_call_ids or [])
self.raw_completion = raw_completion
self._new_record = _new_record
self._completion_text = completion_text
self.is_chunk = is_chunk
@property
def completion_text(self) -> str:
if self.result_chain:
return self.result_chain.get_plain_text()
return self._completion_text
@completion_text.setter
def completion_text(self, value: str) -> None:
if self.result_chain:
self.result_chain.chain = [
component
for component in self.result_chain.chain
if not isinstance(component, Comp.Plain)
]
self.result_chain.chain.insert(0, Comp.Plain(text=value))
return
self._completion_text = value
@property
def text(self) -> str:
return self.completion_text
@text.setter
def text(self, value: str) -> None:
self.completion_text = value
def to_openai_tool_calls(self) -> list[dict[str, Any]]:
ret: list[dict[str, Any]] = []
for idx, tool_call_arg in enumerate(self.tools_call_args):
ret.append(
{
"id": self.tools_call_ids[idx],
"function": {
"name": self.tools_call_name[idx],
"arguments": json.dumps(tool_call_arg),
},
"type": "function",
}
)
return ret
def to_openai_to_calls_model(self) -> list[_ToolCall]:
ret: list[_ToolCall] = []
for idx, tool_call_arg in enumerate(self.tools_call_args):
ret.append(
_ToolCall(
id=self.tools_call_ids[idx],
function=_ToolCall.FunctionBody(
name=self.tools_call_name[idx],
arguments=json.dumps(tool_call_arg),
),
)
)
return ret
-7
View File
@@ -1,7 +0,0 @@
"""过渡期 ``astrbot_sdk.api.star`` compat facade。"""
from ..._legacy_api import LegacyStar as Star, StarTools, register
from .context import Context
from .star import StarMetadata
__all__ = ["Context", "Star", "StarMetadata", "StarTools", "register"]
-5
View File
@@ -1,5 +0,0 @@
"""旧版 ``Context`` 的兼容导出。"""
from ..._legacy_api import Context
__all__ = ["Context"]
-30
View File
@@ -1,30 +0,0 @@
"""旧版插件元数据兼容类型。"""
from __future__ import annotations
from dataclasses import dataclass, field
from ..basic.astrbot_config import AstrBotConfig
@dataclass(slots=True)
class StarMetadata:
name: str | None = None
author: str | None = None
desc: str | None = None
version: str | None = None
repo: str | None = None
module_path: str | None = None
root_dir_name: str | None = None
reserved: bool = False
activated: bool = True
config: AstrBotConfig | None = None
star_handler_full_names: list[str] = field(default_factory=list)
display_name: str | None = None
logo_path: str | None = None
def __str__(self) -> str:
return f"Plugin {self.name} ({self.version}) by {self.author}: {self.desc}"
def __repr__(self) -> str:
return str(self)
-21
View File
@@ -1,21 +0,0 @@
"""过渡期旧版顶层导入路径 compat facade。
这个模块只承接历史上的顶层 legacy 导入习惯,例如 ``Context`` /
``CommandComponent``。更细的旧路径兼容仍保留在 ``astrbot_sdk.api`` 下。
新代码不应从这里导入;这里的职责是给旧插件一个明确、可隔离的旁路入口。
"""
from ._legacy_api import (
CommandComponent,
Context,
LegacyContext,
LegacyConversationManager,
)
__all__ = [
"CommandComponent",
"Context",
"LegacyContext",
"LegacyConversationManager",
]
+1 -3
View File
@@ -1,8 +1,6 @@
"""AstrBot v4 协议公共入口。
这里优先暴露 v4 原生协议的消息模型、描述符和解析函数。
legacy JSON-RPC 兼容保留在 `astrbot_sdk.protocol.legacy_adapter` 子模块中,
供迁移和适配场景显式使用,而不是作为主协议根入口的一部分。
这里暴露 v4 原生协议的消息模型、描述符和解析函数。
握手阶段由 `InitializeMessage` 发起,返回值不是另一条 initialize 消息,而是
`ResultMessage(kind="initialize_result")`,其 `output` 负载可解析为
@@ -1,692 +0,0 @@
"""legacy JSON-RPC 与 v4 协议之间的适配器。
旧树没有独立的 `protocol` 包;这里做的是“旧 JSON-RPC 运行时语义”到
“v4 协议模型”的转换。它不是完美双向同构,尤其是 legacy handshake 无法
保留 v4 触发器的全部细节,因此适配器会保留原始握手载荷供兼容层回退使用。
"""
from __future__ import annotations
import json
from typing import Any, Literal
from pydantic import BaseModel, ConfigDict, Field
from .descriptors import EventTrigger, HandlerDescriptor, Permissions
from .messages import (
CancelMessage,
ErrorPayload,
EventMessage,
InitializeMessage,
InvokeMessage,
PeerInfo,
ResultMessage,
)
LEGACY_JSONRPC_VERSION = "2.0"
"""旧版 JSON-RPC 协议版本。"""
LEGACY_CONTEXT_CAPABILITY = "internal.legacy.call_context_function"
"""旧版上下文函数调用的能力名称。"""
LEGACY_HANDSHAKE_METADATA_KEY = "legacy_handshake_payload"
"""在 InitializeMessage.metadata 中存储原始握手数据的键。"""
LEGACY_PLUGIN_KEYS_METADATA_KEY = "legacy_plugin_keys"
"""在 InitializeMessage.metadata 中存储原始插件键列表的键。"""
LEGACY_ADAPTER_MESSAGE_EVENT = 3
"""默认的事件类型,用于无法识别的旧版处理器。"""
class _LegacyMessageBase(BaseModel):
model_config = ConfigDict(extra="forbid")
class LegacyErrorData(_LegacyMessageBase):
"""旧版 JSON-RPC 错误数据。
Attributes:
code: 错误码,整数类型(旧版规范)
message: 错误消息
data: 附加错误数据
"""
code: int = -32000
message: str
data: Any | None = None
class LegacyRequest(_LegacyMessageBase):
"""旧版 JSON-RPC 请求。
Attributes:
jsonrpc: 协议版本,固定为 "2.0"
id: 请求 ID,可选
method: 方法名称
params: 参数字典
"""
jsonrpc: Literal["2.0"] = LEGACY_JSONRPC_VERSION
id: str | None = None
method: str
params: dict[str, Any] = Field(default_factory=dict)
class _LegacyResponse(_LegacyMessageBase):
jsonrpc: Literal["2.0"] = LEGACY_JSONRPC_VERSION
id: str | None = None
class LegacySuccessResponse(_LegacyResponse):
"""旧版 JSON-RPC 成功响应。
Attributes:
result: 返回结果
"""
result: Any = Field(default_factory=dict)
class LegacyErrorResponse(_LegacyResponse):
"""旧版 JSON-RPC 错误响应。
Attributes:
error: 错误数据
"""
error: LegacyErrorData
LegacyMessage = LegacyRequest | LegacySuccessResponse | LegacyErrorResponse
"""旧版 JSON-RPC 消息联合类型。"""
LegacyToV4Message = (
InitializeMessage | InvokeMessage | ResultMessage | EventMessage | CancelMessage
)
"""旧版消息转换后的新版消息类型。"""
class LegacyAdapter:
"""旧版协议适配器,提供新旧协议之间的双向转换。
使用场景:
1. 旧版插件连接新版核心:将旧版 JSON-RPC 转换为新版协议
2. 新版插件连接旧版核心:将新版协议转换为旧版 JSON-RPC
3. 测试和迁移:验证协议转换的正确性
转换规则:
- handshake <-> InitializeMessage
- call_handler <-> InvokeMessage(capability="handler.invoke")
- call_context_function <-> InvokeMessage(capability="internal.legacy...")
- handler_stream_* <-> EventMessage
- cancel <-> CancelMessage
Attributes:
protocol_version: 新版协议版本号
legacy_peer_name: 默认的对等节点名称
legacy_peer_role: 默认的对等节点角色
legacy_peer_version: 默认的对等节点版本
"""
def __init__(
self,
*,
protocol_version: str = "1.0",
legacy_peer_name: str = "legacy-peer",
legacy_peer_role: Literal["plugin", "core"] = "plugin",
legacy_peer_version: str | None = None,
) -> None:
self.protocol_version = protocol_version
self.legacy_peer_name = legacy_peer_name
self.legacy_peer_role = legacy_peer_role
self.legacy_peer_version = legacy_peer_version
self._handler_names_by_request_id: dict[str, str] = {}
self._pending_handshake_ids: set[str] = set()
def track_handler(self, request_id: str, handler_full_name: str) -> None:
if request_id:
self._handler_names_by_request_id[request_id] = handler_full_name
def legacy_to_v4(
self,
payload: str | bytes | dict[str, Any] | LegacyMessage,
) -> LegacyToV4Message:
message = parse_legacy_message(payload)
if isinstance(message, LegacyRequest):
return self.legacy_request_to_message(message)
if isinstance(message, LegacySuccessResponse):
return self.legacy_response_to_message(message)
return self.legacy_error_to_result(message)
def legacy_request_to_message(
self,
payload: LegacyRequest | dict[str, Any],
) -> InitializeMessage | InvokeMessage | EventMessage | CancelMessage:
message = (
payload
if isinstance(payload, LegacyRequest)
else LegacyRequest.model_validate(payload)
)
params = message.params or {}
method = message.method
if method == "handshake":
request_id = self._request_id(message.id, "legacy-handshake")
self._pending_handshake_ids.add(request_id)
return InitializeMessage(
id=request_id,
protocol_version=self.protocol_version,
peer=PeerInfo(
name=self.legacy_peer_name,
role=self.legacy_peer_role,
version=self.legacy_peer_version,
),
handlers=[],
metadata={"legacy_handshake": True},
)
if method == "call_handler":
request_id = self._request_id(message.id, "legacy-call-handler")
handler_full_name = str(params.get("handler_full_name", ""))
self.track_handler(request_id, handler_full_name)
return InvokeMessage(
id=request_id,
capability="handler.invoke",
input={
"handler_id": handler_full_name,
"event": self._as_dict(params.get("event"), field_name="data"),
"args": self._as_dict(params.get("args"), field_name="value"),
},
stream=False,
)
if method == "call_context_function":
request_id = self._request_id(message.id, "legacy-context")
return InvokeMessage(
id=request_id,
capability=LEGACY_CONTEXT_CAPABILITY,
input={
"name": str(params.get("name", "")),
"args": self._as_dict(params.get("args"), field_name="value"),
},
stream=False,
)
if method == "handler_stream_start":
request_id = self._request_id(params.get("id"), "legacy-stream")
handler_full_name = str(params.get("handler_full_name", ""))
self.track_handler(request_id, handler_full_name)
return EventMessage(id=request_id, phase="started")
if method == "handler_stream_update":
request_id = self._request_id(params.get("id"), "legacy-stream")
handler_full_name = str(params.get("handler_full_name", ""))
self.track_handler(request_id, handler_full_name)
return EventMessage(
id=request_id,
phase="delta",
data=self._as_dict(params.get("data"), field_name="value"),
)
if method == "handler_stream_end":
request_id = self._request_id(params.get("id"), "legacy-stream")
handler_full_name = str(params.get("handler_full_name", ""))
self.track_handler(request_id, handler_full_name)
error = params.get("error")
if isinstance(error, dict):
return EventMessage(
id=request_id,
phase="failed",
error=ErrorPayload.model_validate(
self._coerce_error_payload(error)
),
)
# completed phase 需要 output 字段,提供空字典作为默认值
return EventMessage(id=request_id, phase="completed", output={"done": True})
if method == "cancel":
return CancelMessage(
id=self._request_id(message.id, "legacy-cancel"),
reason=str(params.get("reason", "user_cancelled")),
)
return InvokeMessage(
id=self._request_id(message.id, "legacy-invoke"),
capability=method,
input=self._as_dict(params, field_name="data"),
stream=False,
)
def legacy_response_to_message(
self,
payload: LegacySuccessResponse | dict[str, Any],
) -> InitializeMessage | ResultMessage:
message = (
payload
if isinstance(payload, LegacySuccessResponse)
else LegacySuccessResponse.model_validate(payload)
)
request_id = self._request_id(message.id, "legacy-result")
if (
request_id in self._pending_handshake_ids
or self._looks_like_handshake_payload(message.result)
):
self._pending_handshake_ids.discard(request_id)
payload_dict = self._as_dict(message.result, field_name="data")
peer_name, peer_version = self._legacy_peer_from_handshake_payload(
payload_dict
)
return InitializeMessage(
id=request_id,
protocol_version=self.protocol_version,
peer=PeerInfo(
name=peer_name,
role=self.legacy_peer_role,
version=peer_version,
),
handlers=self._legacy_handlers_to_descriptors(payload_dict),
metadata={
LEGACY_HANDSHAKE_METADATA_KEY: payload_dict,
LEGACY_PLUGIN_KEYS_METADATA_KEY: sorted(payload_dict.keys()),
},
)
return ResultMessage(
id=request_id,
success=True,
output=self._as_dict(message.result, field_name="data"),
)
def legacy_error_to_result(
self,
payload: LegacyErrorResponse | dict[str, Any],
) -> ResultMessage:
message = (
payload
if isinstance(payload, LegacyErrorResponse)
else LegacyErrorResponse.model_validate(payload)
)
request_id = self._request_id(message.id, "legacy-error")
kind = None
if request_id in self._pending_handshake_ids:
self._pending_handshake_ids.discard(request_id)
kind = "initialize_result"
return ResultMessage(
id=request_id,
kind=kind,
success=False,
error=ErrorPayload.model_validate(
self._legacy_error_to_payload(message.error)
),
)
def build_legacy_handshake_request(self, request_id: str) -> dict[str, Any]:
self._pending_handshake_ids.add(request_id)
return {
"jsonrpc": LEGACY_JSONRPC_VERSION,
"id": request_id,
"method": "handshake",
"params": {},
}
def initialize_to_legacy_handshake_response(
self,
message: InitializeMessage,
*,
request_id: str | None = None,
) -> dict[str, Any]:
response_id = request_id or message.id
payload = self._legacy_handshake_payload_from_initialize(message)
return {
"jsonrpc": LEGACY_JSONRPC_VERSION,
"id": response_id,
"result": payload,
}
def invoke_to_legacy_request(self, message: InvokeMessage) -> dict[str, Any]:
if message.capability == "handler.invoke":
handler_full_name = str(message.input.get("handler_id", ""))
self.track_handler(message.id, handler_full_name)
return {
"jsonrpc": LEGACY_JSONRPC_VERSION,
"id": message.id,
"method": "call_handler",
"params": {
"handler_full_name": handler_full_name,
"event": self._as_dict(
message.input.get("event"), field_name="data"
),
"args": self._as_dict(
message.input.get("args"), field_name="value"
),
},
}
if message.capability == LEGACY_CONTEXT_CAPABILITY:
return {
"jsonrpc": LEGACY_JSONRPC_VERSION,
"id": message.id,
"method": "call_context_function",
"params": {
"name": str(message.input.get("name", "")),
"args": self._as_dict(
message.input.get("args"), field_name="value"
),
},
}
return {
"jsonrpc": LEGACY_JSONRPC_VERSION,
"id": message.id,
"method": message.capability,
"params": self._as_dict(message.input, field_name="data"),
}
def result_to_legacy_response(self, message: ResultMessage) -> dict[str, Any]:
self._handler_names_by_request_id.pop(message.id, None)
if message.success:
return {
"jsonrpc": LEGACY_JSONRPC_VERSION,
"id": message.id,
"result": message.output,
}
return {
"jsonrpc": LEGACY_JSONRPC_VERSION,
"id": message.id,
"error": {
"code": -32000,
"message": message.error.message if message.error else "unknown error",
"data": message.error.model_dump() if message.error else None,
},
}
def event_to_legacy_notification(self, message: EventMessage) -> dict[str, Any]:
method = {
"started": "handler_stream_start",
"delta": "handler_stream_update",
"completed": "handler_stream_end",
"failed": "handler_stream_end",
}[message.phase]
params: dict[str, Any] = {
"id": message.id,
"handler_full_name": self._handler_names_by_request_id.get(message.id, ""),
}
if message.phase == "delta":
params["data"] = message.data
if message.phase == "failed" and message.error is not None:
params["error"] = message.error.model_dump()
return {
"jsonrpc": LEGACY_JSONRPC_VERSION,
"method": method,
"params": params,
}
def cancel_to_legacy_request(self, message: CancelMessage) -> dict[str, Any]:
return {
"jsonrpc": LEGACY_JSONRPC_VERSION,
"id": message.id,
"method": "cancel",
"params": {"reason": message.reason},
}
@staticmethod
def _request_id(value: Any, fallback: str) -> str:
text = "" if value is None else str(value)
return text or fallback
@staticmethod
def _as_dict(value: Any, *, field_name: str) -> dict[str, Any]:
if isinstance(value, dict):
return value
if value is None:
return {}
return {field_name: value}
@staticmethod
def _looks_like_handshake_payload(value: Any) -> bool:
if not isinstance(value, dict) or not value:
return False
return all(
isinstance(item, dict) and "handlers" in item for item in value.values()
)
@staticmethod
def _coerce_error_payload(value: dict[str, Any]) -> dict[str, Any]:
if {"code", "message"}.issubset(value):
return {
"code": str(value.get("code", "legacy_rpc_error")),
"message": str(value.get("message", "legacy error")),
"hint": str(value.get("hint", "")),
"retryable": bool(value.get("retryable", False)),
}
return {
"code": "legacy_rpc_error",
"message": str(value.get("message", "legacy error")),
"hint": "",
"retryable": False,
}
@staticmethod
def _legacy_error_to_payload(error: LegacyErrorData) -> dict[str, Any]:
if isinstance(error.data, dict) and {"code", "message"}.issubset(error.data):
return LegacyAdapter._coerce_error_payload(error.data)
return {
"code": "legacy_rpc_error",
"message": error.message,
"hint": "",
"retryable": False,
}
def _legacy_handlers_to_descriptors(
self,
payload: dict[str, Any],
) -> list[HandlerDescriptor]:
handlers: list[HandlerDescriptor] = []
for star_info in payload.values():
star_handlers = star_info.get("handlers") or []
if not isinstance(star_handlers, list):
continue
for handler_data in star_handlers:
if isinstance(handler_data, dict):
handlers.append(self._legacy_handler_to_descriptor(handler_data))
return handlers
@staticmethod
def _legacy_handler_to_descriptor(
handler_data: dict[str, Any],
) -> HandlerDescriptor:
extras_configs = handler_data.get("extras_configs")
extras = extras_configs if isinstance(extras_configs, dict) else {}
handler_id = str(
handler_data.get("handler_full_name")
or f"{handler_data.get('handler_module_path', 'legacy')}.{handler_data.get('handler_name', 'handler')}"
)
event_type = handler_data.get("event_type", LEGACY_ADAPTER_MESSAGE_EVENT)
permissions = Permissions(
require_admin=bool(extras.get("require_admin", False)),
level=int(extras.get("level", 0) or 0),
)
return HandlerDescriptor(
id=handler_id,
trigger=EventTrigger(event_type=str(event_type)),
priority=int(extras.get("priority", 0) or 0),
permissions=permissions,
)
def _legacy_handshake_payload_from_initialize(
self,
message: InitializeMessage,
) -> dict[str, Any]:
raw_payload = message.metadata.get(LEGACY_HANDSHAKE_METADATA_KEY)
if isinstance(raw_payload, dict) and raw_payload:
return raw_payload
plugin_name = str(message.metadata.get("plugin_id") or message.peer.name)
display_name = str(message.metadata.get("display_name") or plugin_name)
module_path = str(message.metadata.get("module_path") or f"{plugin_name}.main")
root_dir_name = str(message.metadata.get("root_dir_name") or plugin_name)
handlers = [
self._descriptor_to_legacy_handler(item) for item in message.handlers
]
return {
module_path: {
"name": plugin_name,
"author": message.metadata.get("author", "legacy-adapter"),
"desc": message.metadata.get("desc", ""),
"version": message.peer.version,
"repo": message.metadata.get("repo"),
"module_path": module_path,
"root_dir_name": root_dir_name,
"reserved": bool(message.metadata.get("reserved", False)),
"activated": bool(message.metadata.get("activated", True)),
"config": message.metadata.get("config"),
"star_handler_full_names": [
item["handler_full_name"] for item in handlers
],
"display_name": display_name,
"logo_path": message.metadata.get("logo_path"),
"handlers": handlers,
}
}
@staticmethod
def _descriptor_to_legacy_handler(descriptor: HandlerDescriptor) -> dict[str, Any]:
module_path, _, handler_name = descriptor.id.rpartition(".")
if not module_path:
module_path = descriptor.id
handler_name = descriptor.id
desc = getattr(descriptor.trigger, "description", None)
event_type: int | str = LEGACY_ADAPTER_MESSAGE_EVENT
if isinstance(descriptor.trigger, EventTrigger):
event_type = (
int(descriptor.trigger.event_type)
if descriptor.trigger.event_type.isdigit()
else descriptor.trigger.event_type
)
return {
"event_type": event_type,
"handler_full_name": descriptor.id,
"handler_name": handler_name,
"handler_module_path": module_path,
"desc": desc or "",
"extras_configs": {
"priority": descriptor.priority,
"require_admin": descriptor.permissions.require_admin,
"level": descriptor.permissions.level,
},
}
def _legacy_peer_from_handshake_payload(
self,
payload: dict[str, Any],
) -> tuple[str, str | None]:
first_star = next(iter(payload.values()), {})
if not isinstance(first_star, dict):
return self.legacy_peer_name, self.legacy_peer_version
peer_name = str(first_star.get("name") or self.legacy_peer_name)
version_value = first_star.get("version")
peer_version = None if version_value is None else str(version_value)
return peer_name, peer_version or self.legacy_peer_version
def parse_legacy_message(
payload: str | bytes | dict[str, Any] | LegacyMessage,
) -> LegacyMessage:
if isinstance(payload, (LegacyRequest, LegacySuccessResponse, LegacyErrorResponse)):
return payload
if isinstance(payload, bytes):
payload = payload.decode("utf-8")
if isinstance(payload, str):
payload = json.loads(payload)
if not isinstance(payload, dict):
raise ValueError("legacy JSON-RPC 消息必须是 JSON object")
if "method" in payload:
return LegacyRequest.model_validate(payload)
if "result" in payload:
return LegacySuccessResponse.model_validate(payload)
if "error" in payload:
return LegacyErrorResponse.model_validate(payload)
raise ValueError("未知 legacy JSON-RPC 消息类型")
def legacy_message_to_v4(
payload: str | bytes | dict[str, Any] | LegacyMessage,
) -> LegacyToV4Message:
return LegacyAdapter().legacy_to_v4(payload)
def legacy_request_to_invoke(
payload: str | bytes | dict[str, Any] | LegacyRequest,
) -> InvokeMessage:
message = LegacyAdapter().legacy_request_to_message(payload)
if not isinstance(message, InvokeMessage):
raise ValueError("legacy request 不能直接映射为 invoke")
return message
def legacy_response_to_message(
payload: str | bytes | dict[str, Any] | LegacySuccessResponse,
) -> InitializeMessage | ResultMessage:
message = LegacyAdapter().legacy_response_to_message(payload)
return message
def initialize_to_legacy_handshake_response(
message: InitializeMessage,
*,
request_id: str | None = None,
) -> dict[str, Any]:
return LegacyAdapter().initialize_to_legacy_handshake_response(
message,
request_id=request_id,
)
def invoke_to_legacy_request(message: InvokeMessage) -> dict[str, Any]:
return LegacyAdapter().invoke_to_legacy_request(message)
def result_to_legacy_response(message: ResultMessage) -> dict[str, Any]:
return LegacyAdapter().result_to_legacy_response(message)
def event_to_legacy_notification(
message: EventMessage,
*,
handler_full_name: str | None = None,
) -> dict[str, Any]:
adapter = LegacyAdapter()
if handler_full_name:
adapter.track_handler(message.id, handler_full_name)
return adapter.event_to_legacy_notification(message)
def cancel_to_legacy_request(message: CancelMessage) -> dict[str, Any]:
return LegacyAdapter().cancel_to_legacy_request(message)
__all__ = [
"LEGACY_ADAPTER_MESSAGE_EVENT",
"LEGACY_CONTEXT_CAPABILITY",
"LEGACY_HANDSHAKE_METADATA_KEY",
"LEGACY_PLUGIN_KEYS_METADATA_KEY",
"LEGACY_JSONRPC_VERSION",
"LegacyAdapter",
"LegacyErrorData",
"LegacyErrorResponse",
"LegacyMessage",
"LegacyRequest",
"LegacySuccessResponse",
"LegacyToV4Message",
"cancel_to_legacy_request",
"event_to_legacy_notification",
"initialize_to_legacy_handshake_response",
"invoke_to_legacy_request",
"legacy_message_to_v4",
"legacy_request_to_invoke",
"legacy_response_to_message",
"parse_legacy_message",
"result_to_legacy_response",
]
+1 -1
View File
@@ -2,7 +2,7 @@
这里仅暴露相对稳定的运行时构件:协议 `Peer`、传输抽象以及能力/处理器分发器。
大多数插件作者应优先使用顶层 `astrbot_sdk`。
`astrbot_sdk.api` 仅用于旧插件过渡迁移,不建议新代码依赖。
`loader` / `bootstrap` 等编排细节保留在各自子模块中,不作为根级稳定契约。
"""
+33 -199
View File
@@ -1,7 +1,7 @@
"""处理器分发模块。
定义 HandlerDispatcher 类,负责将能力调用分发到具体的处理器函数。
支持参数注入、流式执行、错误处理和生命周期回调。
支持参数注入、流式执行、错误处理。
核心职责:
- 根据处理器 ID 查找处理器
@@ -13,75 +13,24 @@
参数注入优先级:
1. 按类型注解注入(支持 Optional[Type])
2. 按参数名注入(兼容无类型注解)
3. 从 legacy_args 注入(命令参数等)
3. 从 args 注入(命令参数等)
支持的注入类型:
- MessageEvent: 消息事件
- Context: 运行时上下文
与旧版对比:
旧版 HandlerExecutor:
- 从 star_handlers_registry 获取处理器
- 直接调用 handler(event, **args)
- 无参数注入支持
- 通过 JSON-RPC notification 发送流式结果
- 错误通过 JSON-RPC error 响应
新版 HandlerDispatcher:
- 从 LoadedHandler 映射获取处理器
- 支持类型注解注入 (MessageEvent, Context)
- 支持参数名注入 (event, ctx, context)
- 支持 legacy_args 注入
- 支持 Optional[Type] 类型
- 支持默认值
- 统一的错误处理和 on_error 回调
处理器签名兼容:
# 旧版签名
def handler(event: AstrMessageEvent) -> str:
return "result"
# 新版签名(类型注入)
async def handler(event: MessageEvent, ctx: Context) -> None:
await event.reply("result")
# 新版签名(名字注入)
async def handler(event, ctx) -> None:
await ctx.platform.send(event.session_id, "result")
# 流式处理器
async def streaming_handler(event: MessageEvent):
yield "chunk 1"
yield "chunk 2"
结果处理:
- PlainTextResult: 调用 event.reply()
- str: 调用 event.reply()
- dict with "text": 调用 event.reply(str(item["text"]))
`HandlerDispatcher` 把运行时收到的 `handler.invoke` 请求转成真实 Python 调用。
它的职责只包括参数注入、legacy 返回值兼容和错误回调;不负责 handler 发现或
远端能力路由。
"""
from __future__ import annotations
import asyncio
import inspect
import traceback
import typing
from collections.abc import AsyncIterator
from typing import Any, get_type_hints
from .._legacy_runtime import (
bind_loaded_legacy_runtime,
get_legacy_runtime_adapter,
prepare_legacy_handler_runtime,
)
from .._session_waiter import SessionWaiterManager
from ..context import CancelToken, Context
from ..errors import AstrBotError
from ..events import MessageEvent, PlainTextResult
from ..events import MessageEvent
from ..star import Star
from .capability_router import StreamExecution
from .loader import LoadedCapability, LoadedHandler
@@ -93,7 +42,6 @@ class HandlerDispatcher:
self._peer = peer
self._handlers = {item.descriptor.id: item for item in handlers}
self._active: dict[str, tuple[asyncio.Task[Any], CancelToken]] = {}
self._session_waiters: dict[str, SessionWaiterManager] = {}
async def invoke(self, message, cancel_token: CancelToken) -> dict[str, Any]:
handler_id = str(message.input.get("handler_id", ""))
@@ -103,26 +51,13 @@ class HandlerDispatcher:
plugin_id = self._resolve_plugin_id(loaded)
ctx = Context(peer=self._peer, plugin_id=plugin_id, cancel_token=cancel_token)
session_waiters = self._session_waiters.setdefault(
plugin_id, SessionWaiterManager()
)
ctx._session_waiter_manager = session_waiters
event = MessageEvent.from_payload(message.input.get("event", {}), context=ctx)
event.bind_reply_handler(self._create_reply_handler(ctx, event))
if await session_waiters.dispatch(event):
return {}
legacy_preparation = await prepare_legacy_handler_runtime(
loaded,
runtime_context=ctx,
event=event,
)
if not legacy_preparation.should_run:
return {}
# 提取 legacy args 用于兼容旧版 handler 签名
legacy_args = message.input.get("args") or {}
# 提取 args 用于兼容 handler 签名
args = message.input.get("args") or {}
task = asyncio.create_task(self._run_handler(loaded, event, ctx, legacy_args))
task = asyncio.create_task(self._run_handler(loaded, event, ctx, args))
self._active[message.id] = (task, cancel_token)
try:
await task
@@ -165,38 +100,26 @@ class HandlerDispatcher:
loaded: LoadedHandler,
event: MessageEvent,
ctx: Context,
legacy_args: dict[str, Any] | None = None,
args: dict[str, Any] | None = None,
) -> None:
legacy_runtime = get_legacy_runtime_adapter(loaded)
try:
result = loaded.callable(
*self._build_args(loaded.callable, event, ctx, legacy_args)
*self._build_args(loaded.callable, event, ctx, args)
)
if inspect.isasyncgen(result):
async for item in result:
await self._consume_legacy_result(
item,
event,
ctx,
legacy_runtime=legacy_runtime,
)
await self._send_result(item, event, ctx)
return
if inspect.isawaitable(result):
result = await result
if result is not None:
await self._consume_legacy_result(
result,
event,
ctx,
legacy_runtime=legacy_runtime,
)
await self._send_result(result, event, ctx)
except Exception as exc:
await self._handle_error(
loaded.owner,
exc,
event,
ctx,
legacy_runtime=legacy_runtime,
handler_name=loaded.callable.__name__,
plugin_id=self._resolve_plugin_id(loaded),
)
@@ -207,31 +130,15 @@ class HandlerDispatcher:
handler,
event: MessageEvent,
ctx: Context,
legacy_args: dict[str, Any] | None = None,
args: dict[str, Any] | None = None,
) -> list[Any]:
"""构建 handler 参数列表。
注入优先级:
1. 按类型注解注入(支持 Optional[Type])
2. 按参数名注入(兼容无类型注解的情况)
3. 从 legacy_args 注入(命令参数、regex 捕获组等)
Args:
handler: Handler 可调用对象
event: 消息事件
ctx: 运行时上下文
legacy_args: 旧版参数字典
Returns:
参数列表
"""
"""构建 handler 参数列表。"""
from loguru import logger
signature = inspect.signature(handler)
args: list[Any] = []
legacy_args = legacy_args or {}
injected_args: list[Any] = []
args = args or {}
# 尝试获取类型注解
type_hints: dict[str, Any] = {}
try:
type_hints = get_type_hints(handler)
@@ -258,50 +165,36 @@ class HandlerDispatcher:
injected = event
elif parameter.name in {"ctx", "context"}:
injected = ctx
elif parameter.name in legacy_args:
injected = legacy_args[parameter.name]
elif parameter.name in args:
injected = args[parameter.name]
# 3. 检查是否有默认值
if injected is None:
if parameter.default is not parameter.empty:
# 有默认值,跳过注入
continue
# 无默认值且无法注入,警告并传 None
logger.warning(
f"Handler '{handler.__name__}': 参数 '{parameter.name}' "
f"无法注入(类型: {param_type or '未知'}),将传入 None"
f"无法注入,将传入 None"
)
args.append(None)
injected_args.append(None)
else:
args.append(injected)
injected_args.append(injected)
return args
return injected_args
def _inject_by_type(
self, param_type: Any, event: MessageEvent, ctx: Context
) -> Any:
"""根据类型注解注入参数。
支持 Optional[Type] 类型。
Args:
param_type: 参数类型注解
event: 消息事件
ctx: 运行时上下文
Returns:
注入的值,若无法注入则返回 None
"""
"""根据类型注解注入参数。"""
# 处理 Optional[Type] 情况
origin = typing.get_origin(param_type)
if origin is typing.Union:
args = typing.get_args(param_type)
non_none_types = [a for a in args if a is not type(None)]
type_args = typing.get_args(param_type)
non_none_types = [a for a in type_args if a is not type(None)]
if len(non_none_types) == 1:
param_type = non_none_types[0]
# 注入 MessageEvent 及其子类。旧版 compat 事件类型会通过
# from_message_event() 包装成带便捷方法的对象。
# 注入 MessageEvent 及其子类
if param_type is MessageEvent:
return event
if isinstance(param_type, type) and issubclass(param_type, MessageEvent):
@@ -320,70 +213,24 @@ class HandlerDispatcher:
return None
async def _consume_legacy_result(
self,
item: Any,
event: MessageEvent,
ctx: Context | None = None,
*,
legacy_runtime=None,
) -> None:
if legacy_runtime is not None:
await legacy_runtime.dispatch_result(
item,
event,
ctx,
sender=lambda prepared_item: self._send_normalized_result(
prepared_item,
event,
ctx,
),
)
return
await self._send_normalized_result(item, event, ctx)
async def _send_normalized_result(
async def _send_result(
self,
item: Any,
event: MessageEvent,
ctx: Context | None = None,
) -> bool:
from ..api.event.event_result import MessageEventResult
from ..api.message.chain import MessageChain
if isinstance(item, MessageEventResult):
if item.chain and ctx is not None and not item.is_plain_text_only():
await ctx.platform.send_chain(
event.session_ref or event.session_id,
item.to_payload(),
)
return True
plain_text = item.get_plain_text()
if plain_text:
await event.reply(plain_text)
return True
return False
if isinstance(item, MessageChain):
if item.chain and ctx is not None and not item.is_plain_text_only():
await ctx.platform.send_chain(
event.session_ref or event.session_id,
item.to_payload(),
)
return True
plain_text = item.get_plain_text()
if plain_text:
await event.reply(plain_text)
return True
return False
if isinstance(item, PlainTextResult):
await event.reply(item.text)
return True
"""发送处理器结果。"""
if isinstance(item, str):
await event.reply(item)
return True
if isinstance(item, dict) and "text" in item:
await event.reply(str(item["text"]))
return True
# 支持带 text 属性的对象
text = getattr(item, "text", None)
if isinstance(text, str):
await event.reply(text)
return True
return False
async def _handle_error(
@@ -393,21 +240,9 @@ class HandlerDispatcher:
event: MessageEvent,
ctx: Context,
*,
legacy_runtime=None,
handler_name: str = "",
plugin_id: str | None = None,
) -> None:
if legacy_runtime is not None:
await legacy_runtime.handle_error(
plugin_id=plugin_id or self._plugin_id,
handler_name=handler_name,
exc=exc,
event=event,
ctx=ctx,
traceback_text="".join(
traceback.TracebackException.from_exception(exc).format()
),
)
if hasattr(owner, "on_error") and callable(owner.on_error):
result = owner.on_error(exc, event, ctx)
if inspect.isawaitable(result):
@@ -444,7 +279,6 @@ class CapabilityDispatcher:
plugin_id=plugin_id,
cancel_token=cancel_token,
)
bind_loaded_legacy_runtime(loaded, ctx)
task = asyncio.create_task(
self._run_capability(
@@ -562,8 +396,8 @@ class CapabilityDispatcher:
) -> Any:
origin = typing.get_origin(param_type)
if origin is typing.Union:
args = typing.get_args(param_type)
non_none_types = [item for item in args if item is not type(None)]
type_args = typing.get_args(param_type)
non_none_types = [item for item in type_args if item is not type(None)]
if len(non_none_types) == 1:
param_type = non_none_types[0]
origin = typing.get_origin(param_type)
+69 -140
View File
@@ -1,7 +1,7 @@
"""插件加载模块。
定义插件发现、环境管理和加载的核心逻辑。
支持新旧两种 Star 组件的兼容加载。
仅支持 v4 新版 Star 组件。
核心概念:
PluginSpec: 插件规范,描述插件的基本信息
@@ -28,40 +28,9 @@
1. 将插件目录添加到 sys.path
2. 遍历 components 列表
3. 动态导入组件类
4. 判断是否为新版 Star
5. 创建实例(新版直接实例化,旧版传入 legacy_context)
6. 扫描处理器方法
7. 构建 HandlerDescriptor
新旧 Star 组件兼容:
新版 Star:
- 继承自 Star 基类
- __astrbot_is_new_star__ 返回 True
- 无参构造函数
- 通过 @handler 装饰器注册处理器
旧版 Star:
- 不继承或 __astrbot_is_new_star__ 返回 False
- 需要 legacy_context 参数
- 通过 @xxx_handler 装饰器注册处理器
- 使用 extras_configs 传递配置
与旧版对比:
旧版 StarManager:
- 通过 plugin.yaml 发现插件
- 动态导入组件类并实例化
- 注册到 star_handlers_registry
- 使用 functools.partial 绑定实例
- 无环境管理
- 无指纹缓存
新版 loader.py:
- PluginSpec 描述插件规范
- PluginEnvironmentManager 管理分组共享环境
- load_plugin() 加载并解析组件
- LoadedHandler 封装处理器和描述符
- 支持新旧 Star 组件兼容
- 支持环境指纹缓存
4. 直接实例化(无参构造函数)
5. 扫描处理器方法
6. 构建 HandlerDescriptor
plugin.yaml 格式:
name: my_plugin
@@ -78,8 +47,6 @@ plugin.yaml 格式:
- 从 `plugin.yaml` 解析出可运行的 `PluginSpec`
- 用 `uv` 为插件准备独立环境
- 把组件实例和 handler 元数据整理成 `LoadedPlugin`
legacy 兼容也集中放在这里,尤其是“同一插件共享一个 `LegacyContext`”这一旧语义。
"""
from __future__ import annotations
@@ -98,22 +65,6 @@ from typing import Any
import yaml
from .._legacy_loader import (
PLUGIN_MANIFEST_FILE,
load_legacy_main_component_classes,
load_plugin_manifest_payload,
looks_like_legacy_plugin,
resolve_plugin_component_classes,
)
from .._legacy_runtime import (
LegacyRuntimeAdapter,
build_capability_legacy_runtime,
build_handler_legacy_runtime,
finalize_legacy_component_instance,
is_new_star_component,
plan_legacy_component_construction,
)
from ..api.basic.astrbot_config import AstrBotConfig
from ..decorators import get_capability_meta, get_handler_meta
from ..protocol.descriptors import CapabilityDescriptor, HandlerDescriptor
from .environment_groups import (
@@ -123,9 +74,9 @@ from .environment_groups import (
GroupEnvironmentManager,
)
PLUGIN_MANIFEST_FILE = "plugin.yaml"
STATE_FILE_NAME = ".astrbot-worker-state.json"
CONFIG_SCHEMA_FILE = "_conf_schema.json"
LEGACY_MAIN_MANIFEST_KEY = "__legacy_main__"
PLUGIN_METADATA_ATTR = "__astrbot_plugin_metadata__"
@@ -161,21 +112,6 @@ class LoadedHandler:
callable: Any
owner: Any
plugin_id: str = ""
legacy_context: Any | None = None
compat_filters: list[Any] = field(default_factory=list)
legacy_runtime: LegacyRuntimeAdapter | None = field(
init=False, default=None, repr=False
)
def __post_init__(self) -> None:
if self.legacy_context is None:
return
self.legacy_runtime = build_handler_legacy_runtime(
self.legacy_context,
self.callable,
compat_filters=self.compat_filters or None,
)
self.compat_filters = list(self.legacy_runtime.filters)
@dataclass(slots=True)
@@ -184,15 +120,6 @@ class LoadedCapability:
callable: Any
owner: Any
plugin_id: str = ""
legacy_context: Any | None = None
legacy_runtime: LegacyRuntimeAdapter | None = field(
init=False, default=None, repr=False
)
def __post_init__(self) -> None:
if self.legacy_context is None:
return
self.legacy_runtime = build_capability_legacy_runtime(self.legacy_context)
@dataclass(slots=True)
@@ -339,10 +266,11 @@ def _normalize_config_value(field_schema: dict[str, Any], value: Any) -> Any:
return copy.deepcopy(value) if value is not None else default_value
def _load_plugin_config(plugin: PluginSpec) -> AstrBotConfig | None:
def _load_plugin_config(plugin: PluginSpec) -> dict[str, Any]:
"""加载插件配置,返回普通字典。"""
schema_path = plugin.plugin_dir / CONFIG_SCHEMA_FILE
if not schema_path.exists():
return None
return {}
try:
schema_payload = json.loads(schema_path.read_text(encoding="utf-8"))
@@ -365,40 +293,56 @@ def _load_plugin_config(plugin: PluginSpec) -> AstrBotConfig | None:
for key, field_schema in schema.items()
if isinstance(field_schema, dict)
}
config = AstrBotConfig(normalized, save_path=config_path)
if not config_path.exists() or normalized != existing:
config.save_config()
return config
config_path.parent.mkdir(parents=True, exist_ok=True)
config_path.write_text(
json.dumps(normalized, ensure_ascii=False, indent=2),
encoding="utf-8",
)
return normalized
def _legacy_component_classes(plugin: PluginSpec) -> list[type[Any]]:
return load_legacy_main_component_classes(
plugin_name=plugin.name,
plugin_dir=plugin.plugin_dir,
)
def _is_new_star_component(cls: type[Any]) -> bool:
"""检查组件类是否为 v4 新版 Star。"""
return bool(getattr(cls, "__astrbot_is_new_star__", False))
def _plugin_component_classes(plugin: PluginSpec) -> list[type[Any]]:
return resolve_plugin_component_classes(
plugin_name=plugin.name,
plugin_dir=plugin.plugin_dir,
manifest_data=plugin.manifest_data,
manifest_flag_key=LEGACY_MAIN_MANIFEST_KEY,
import_string=import_string,
)
"""解析插件组件类列表。"""
components = plugin.manifest_data.get("components") or []
if not isinstance(components, list):
return []
classes: list[type[Any]] = []
for component in components:
if not isinstance(component, dict):
continue
class_path = component.get("class")
if not isinstance(class_path, str) or ":" not in class_path:
continue
try:
cls = import_string(class_path, plugin.plugin_dir)
if isinstance(cls, type):
classes.append(cls)
except Exception:
continue
return classes
def load_plugin_spec(plugin_dir: Path) -> PluginSpec:
"""从插件目录加载插件规范。"""
plugin_dir = plugin_dir.resolve()
manifest_path = plugin_dir / PLUGIN_MANIFEST_FILE
requirements_path = plugin_dir / "requirements.txt"
manifest_path, manifest_data = load_plugin_manifest_payload(
plugin_dir,
read_yaml=_read_yaml,
default_python_version=_default_python_version(),
manifest_flag_key=LEGACY_MAIN_MANIFEST_KEY,
)
if not manifest_path.exists():
raise ValueError(f"missing {PLUGIN_MANIFEST_FILE}")
manifest_data = _read_yaml(manifest_path)
runtime = manifest_data.get("runtime") or {}
python_version = runtime.get("python") or _default_python_version()
return PluginSpec(
name=str(manifest_data.get("name") or plugin_dir.name),
plugin_dir=plugin_dir,
@@ -412,27 +356,23 @@ def load_plugin_spec(plugin_dir: Path) -> PluginSpec:
def validate_plugin_spec(plugin: PluginSpec) -> None:
"""校验单个插件规范,供 CLI 和发现流程复用。"""
manifest_data = plugin.manifest_data
is_legacy_main = bool(manifest_data.get(LEGACY_MAIN_MANIFEST_KEY))
if not is_legacy_main and not plugin.requirements_path.exists():
if not plugin.requirements_path.exists():
raise ValueError("missing requirements.txt")
raw_name = manifest_data.get("name")
if not is_legacy_main and (not isinstance(raw_name, str) or not raw_name):
if not isinstance(raw_name, str) or not raw_name:
raise ValueError("plugin name is required")
raw_runtime = manifest_data.get("runtime") or {}
raw_python = raw_runtime.get("python")
if not is_legacy_main and (not isinstance(raw_python, str) or not raw_python):
if not isinstance(raw_python, str) or not raw_python:
raise ValueError("runtime.python is required")
components = manifest_data.get("components")
if not isinstance(components, list):
raise ValueError("components must be a list")
if is_legacy_main:
return
for index, component in enumerate(components):
if not isinstance(component, dict):
raise ValueError(f"components[{index}] must be an object")
@@ -442,6 +382,7 @@ def validate_plugin_spec(plugin: PluginSpec) -> None:
def discover_plugins(plugins_dir: Path) -> PluginDiscoveryResult:
"""扫描目录发现所有插件。"""
plugins_root = plugins_dir.resolve()
skipped_plugins: dict[str, str] = {}
plugins: list[PluginSpec] = []
@@ -454,8 +395,9 @@ def discover_plugins(plugins_dir: Path) -> PluginDiscoveryResult:
if not entry.is_dir() or entry.name.startswith("."):
continue
manifest_path = entry / PLUGIN_MANIFEST_FILE
if not manifest_path.exists() and not looks_like_legacy_plugin(entry):
if not manifest_path.exists():
continue
plugin: PluginSpec | None = None
try:
plugin = load_plugin_spec(entry)
@@ -464,14 +406,11 @@ def discover_plugins(plugins_dir: Path) -> PluginDiscoveryResult:
skip_key = entry.name
if plugin is not None:
raw_name = plugin.manifest_data.get("name")
if (
isinstance(raw_name, str)
and raw_name
and str(exc) != "missing requirements.txt"
):
if isinstance(raw_name, str) and raw_name:
skip_key = raw_name
skipped_plugins[skip_key] = f"failed to parse plugin manifest: {exc}"
continue
plugin_name = plugin.name
if not isinstance(plugin_name, str) or not plugin_name:
skipped_plugins[entry.name] = "plugin name is required"
@@ -481,6 +420,7 @@ def discover_plugins(plugins_dir: Path) -> PluginDiscoveryResult:
continue
seen_names.add(plugin_name)
plugins.append(plugin)
return PluginDiscoveryResult(plugins=plugins, skipped_plugins=skipped_plugins)
@@ -521,7 +461,7 @@ class PluginEnvironmentManager:
"""返回该插件所属分组环境的解释器路径。
如果调用方还没有先对整批插件做规划,这里会自动创建一个至少包含当
前插件的最小规划,以保证旧的“单插件直接调用”模式仍然可用。
前插件的最小规划,以保证旧的"单插件直接调用"模式仍然可用。
"""
if (
self._plan_result is None
@@ -593,6 +533,10 @@ class PluginEnvironmentManager:
def load_plugin(plugin: PluginSpec) -> LoadedPlugin:
"""加载插件,返回处理器和能力列表。
仅支持 v4 新版 Star 组件(无参构造函数)。
"""
plugin_path = str(plugin.plugin_dir)
if plugin_path not in sys.path:
sys.path.insert(0, plugin_path)
@@ -600,32 +544,16 @@ def load_plugin(plugin: PluginSpec) -> LoadedPlugin:
instances: list[Any] = []
handlers: list[LoadedHandler] = []
capabilities: list[LoadedCapability] = []
shared_legacy_context = None
plugin_config = _load_plugin_config(plugin)
for component_cls in _plugin_component_classes(plugin):
legacy_context = None
if is_new_star_component(component_cls):
instance = component_cls()
else:
construction = plan_legacy_component_construction(
component_cls,
plugin_name=plugin.name,
shared_legacy_context=shared_legacy_context,
plugin_config=plugin_config,
default_config_factory=lambda: AstrBotConfig(
{},
save_path=_plugin_config_path(plugin.plugin_dir, plugin.name),
),
)
shared_legacy_context = construction.shared_legacy_context
legacy_context = construction.legacy_context
instance = component_cls(*construction.constructor_args)
finalize_legacy_component_instance(
instance,
legacy_context=legacy_context,
component_config=construction.component_config,
if not _is_new_star_component(component_cls):
raise ValueError(
f"组件 {component_cls.__name__} 不是 v4 Star 组件。"
"旧版插件请使用 AstrBot 主程序运行。"
)
instance = component_cls()
instances.append(instance)
for name in _iter_discoverable_names(instance):
resolved = _resolve_handler_candidate(instance, name)
if resolved is None:
@@ -639,10 +567,10 @@ def load_plugin(plugin: PluginSpec) -> LoadedPlugin:
callable=bound,
owner=instance,
plugin_id=plugin.name,
legacy_context=legacy_context,
)
)
continue
bound, meta = resolved
handler_id = f"{plugin.name}:{instance.__class__.__module__}.{instance.__class__.__name__}.{name}"
handlers.append(
@@ -658,9 +586,9 @@ def load_plugin(plugin: PluginSpec) -> LoadedPlugin:
callable=bound,
owner=instance,
plugin_id=plugin.name,
legacy_context=legacy_context,
)
)
return LoadedPlugin(
plugin=plugin,
handlers=handlers,
@@ -731,6 +659,7 @@ def _prepare_plugin_import(module_name: str, plugin_dir: Path | None) -> None:
def import_string(path: str, plugin_dir: Path | None = None) -> Any:
"""通过字符串路径导入对象。"""
module_name, attr = path.split(":", 1)
_prepare_plugin_import(module_name, plugin_dir)
module = import_module(module_name)
+16 -88
View File
@@ -25,6 +25,7 @@
from __future__ import annotations
import inspect
import json
from dataclasses import dataclass
from pathlib import Path
@@ -32,14 +33,6 @@ from typing import Any
from loguru import logger
from .._legacy_runtime import (
LegacyWorkerRuntimeBridge,
bind_legacy_runtime_contexts,
build_legacy_worker_runtime_bridge,
run_legacy_worker_shutdown_hooks,
run_legacy_worker_startup_hooks,
run_plugin_lifecycle,
)
from ..context import Context as RuntimeContext
from ..errors import AstrBotError
from ..protocol.messages import PeerInfo
@@ -103,6 +96,21 @@ def _load_group_plugin_specs(group_metadata_path: Path) -> tuple[str, list[Plugi
return group_id, plugins
async def run_plugin_lifecycle(
instances: list[Any],
method_name: str,
context: RuntimeContext,
) -> None:
"""运行插件生命周期方法。"""
for instance in instances:
method = getattr(instance, method_name, None)
if method is None:
continue
result = method(context)
if inspect.isawaitable(result):
await result
class GroupWorkerRuntime:
def __init__(self, *, group_metadata_path: Path, transport) -> None:
self.group_metadata_path = group_metadata_path.resolve()
@@ -134,10 +142,6 @@ class GroupWorkerRuntime:
continue
lifecycle_context = RuntimeContext(peer=self.peer, plugin_id=plugin.name)
bind_legacy_runtime_contexts(
[*loaded_plugin.handlers, *loaded_plugin.capabilities],
lifecycle_context,
)
self._plugin_states.append(
GroupPluginRuntimeState(
plugin=plugin,
@@ -208,12 +212,6 @@ class GroupWorkerRuntime:
],
metadata=self._initialize_metadata(),
)
for state in self._active_plugin_states:
await self._run_legacy_worker_startup_hooks(
state,
metadata=dict(state.plugin.manifest_data),
)
except Exception:
for state in reversed(started_states):
try:
@@ -232,10 +230,6 @@ class GroupWorkerRuntime:
try:
for state in reversed(self._active_plugin_states):
try:
await self._run_legacy_worker_shutdown_hooks(
state,
metadata=dict(state.plugin.manifest_data),
)
await self._run_lifecycle(state, "on_stop")
except Exception as exc:
if first_error is None:
@@ -286,36 +280,6 @@ class GroupWorkerRuntime:
state.loaded_plugin.instances, method_name, state.lifecycle_context
)
async def _run_legacy_worker_startup_hooks(
self,
state: GroupPluginRuntimeState,
*,
metadata: dict[str, Any],
) -> None:
await run_legacy_worker_startup_hooks(
[
*state.loaded_plugin.handlers,
*state.loaded_plugin.capabilities,
],
context=state.lifecycle_context,
metadata=metadata,
)
async def _run_legacy_worker_shutdown_hooks(
self,
state: GroupPluginRuntimeState,
*,
metadata: dict[str, Any],
) -> None:
await run_legacy_worker_shutdown_hooks(
[
*state.loaded_plugin.handlers,
*state.loaded_plugin.capabilities,
],
context=state.lifecycle_context,
metadata=metadata,
)
class PluginWorkerRuntime:
def __init__(self, *, plugin_dir: Path, transport) -> None:
@@ -339,15 +303,6 @@ class PluginWorkerRuntime:
self._lifecycle_context = RuntimeContext(
peer=self.peer, plugin_id=self.plugin.name
)
self._legacy_worker_runtime: LegacyWorkerRuntimeBridge = (
build_legacy_worker_runtime_bridge(
lambda: [
*self.loaded_plugin.handlers,
*self.loaded_plugin.capabilities,
]
)
)
self._bind_legacy_runtime_contexts(self._lifecycle_context)
self.peer.set_invoke_handler(self._handle_invoke)
self.peer.set_cancel_handler(self._handle_cancel)
@@ -373,9 +328,6 @@ class PluginWorkerRuntime:
},
},
)
await self._run_legacy_worker_startup_hooks(
metadata=dict(self.plugin.manifest_data),
)
except Exception:
if lifecycle_started:
try:
@@ -390,9 +342,6 @@ class PluginWorkerRuntime:
async def stop(self) -> None:
try:
await self._run_legacy_worker_shutdown_hooks(
metadata=dict(self.plugin.manifest_data),
)
await self._run_lifecycle("on_stop")
finally:
await self.peer.stop()
@@ -413,24 +362,3 @@ class PluginWorkerRuntime:
await run_plugin_lifecycle(
self.loaded_plugin.instances, method_name, self._lifecycle_context
)
def _bind_legacy_runtime_contexts(self, runtime_context: RuntimeContext) -> None:
self._legacy_worker_runtime.bind_runtime_contexts(runtime_context)
async def _run_legacy_worker_startup_hooks(
self, *, metadata: dict[str, Any]
) -> None:
await self._legacy_worker_runtime.run_startup_hooks(
context=self._lifecycle_context,
metadata=metadata,
)
async def _run_legacy_worker_shutdown_hooks(
self,
*,
metadata: dict[str, Any],
) -> None:
await self._legacy_worker_runtime.run_shutdown_hooks(
context=self._lifecycle_context,
metadata=metadata,
)
+310 -45
View File
@@ -1,10 +1,29 @@
"""V4 sample plugin used by integration tests.
This fixture intentionally exercises the currently supported public v4 API:
- top-level decorators: on_command/on_message/on_event/on_schedule/require_admin
- Context clients: llm, memory, db, platform
- MessageEvent helpers: reply/plain_result/target/to_payload
- plugin-provided capabilities: normal + stream
This fixture exercises the full public v4 API surface:
Decorators:
- on_command: command handling with aliases and description
- on_message: message handling with regex, keywords, platforms
- on_event: event subscription
- on_schedule: scheduled tasks
- require_admin: permission control
- provide_capability: custom capabilities (normal + stream)
Context clients:
- ctx.llm: LLM client (chat, chat_raw, stream_chat, with images)
- ctx.memory: Memory client (save, get, delete, search)
- ctx.db: DB client (get, set, delete, list, get_many, set_many, watch)
- ctx.platform: Platform client (send, send_image, send_chain, get_members)
- ctx.http: HTTP client (register_api, unregister_api, list_apis)
- ctx.metadata: Metadata client (get_plugin, list_plugins, get_plugin_config)
MessageEvent:
- reply(), plain_result(), target, to_payload()
- user_id, group_id, session_id, platform, text
Star lifecycle:
- on_start, on_stop, on_error
"""
from __future__ import annotations
@@ -26,89 +45,299 @@ from astrbot_sdk.context import CancelToken
class HelloPlugin(Star):
"""Representative v4 plugin fixture."""
"""Representative v4 plugin fixture covering all SDK capabilities."""
# ============================================================
# Lifecycle hooks
# ============================================================
async def on_start(self, ctx: Context) -> None:
"""Called when the plugin starts."""
ctx.logger.info("HelloPlugin starting up")
# Store startup timestamp
await ctx.db.set("demo:started", {"status": "ok"})
async def on_stop(self, ctx: Context) -> None:
"""Called when the plugin stops."""
ctx.logger.info("HelloPlugin shutting down")
# Cleanup
await ctx.db.delete("demo:started")
# ============================================================
# Command handlers
# ============================================================
@on_command("hello", aliases=["hi"], description="发送问候消息")
async def hello(self, event: MessageEvent, ctx: Context) -> None:
"""Basic command with LLM response."""
reply = await ctx.llm.chat(event.text)
await event.reply(reply)
chunks: list[str] = []
async for chunk in ctx.llm.stream_chat("stream"):
chunks.append(chunk)
await event.reply("".join(chunks))
@on_command("raw", description="调用 llm.chat_raw 并返回结构化信息")
async def raw(self, event: MessageEvent, ctx: Context):
"""LLM chat with full response metadata."""
response = await ctx.llm.chat_raw(
event.text,
system="be concise",
history=[{"role": "user", "content": "history"}],
)
payload = event.to_payload()
ctx.logger.info("raw handler for {}", payload.get("session_id"))
return event.plain_result(
f"raw={response.text}|finish={response.finish_reason}|"
f"cancelled={ctx.cancel_token.cancelled}"
f"usage={response.usage}"
)
@on_command("remember", description="覆盖 memory/db 全量基本操作")
@on_command("stream", description="流式 LLM 调用")
async def stream(self, event: MessageEvent, ctx: Context) -> None:
"""Streaming LLM response."""
chunks: list[str] = []
async for chunk in ctx.llm.stream_chat(event.text or "stream"):
chunks.append(chunk)
# Real-time feedback
await event.reply(f"[streaming...] {chunk}")
await event.reply(f"[完成] {''.join(chunks)}")
@on_command("vision", description="带图片的 LLM 调用")
async def vision(self, event: MessageEvent, ctx: Context) -> None:
"""LLM with image input."""
# Extract image URL from message or use default
image_url = "https://example.com/demo.png"
response = await ctx.llm.chat(
event.text or "描述这张图片",
image_urls=[image_url],
)
await event.reply(response)
# ============================================================
# Memory operations
# ============================================================
@on_command("remember", description="记忆操作演示")
async def remember(self, event: MessageEvent, ctx: Context):
"""Memory client full API demo."""
# Save with metadata
await ctx.memory.save(
"demo:last_message",
{"user_id": event.user_id or "", "text": event.text},
source="fixture",
tags=["demo"],
)
remembered = await ctx.memory.get("demo:last_message") or {}
searched = await ctx.memory.search("fixture")
await ctx.memory.delete("demo:last_message")
await ctx.db.set("demo:last_session", event.session_id)
session_value = await ctx.db.get("demo:last_session")
keys = await ctx.db.list("demo:")
await ctx.db.delete("demo:last_session")
# Get exact match
remembered = await ctx.memory.get("demo:last_message") or {}
# Semantic search
searched = await ctx.memory.search("demo")
# Delete
await ctx.memory.delete("demo:last_message")
return event.plain_result(
f"remembered={remembered.get('user_id', 'unknown')}|"
f"searched={len(searched)}|session={session_value}|keys={len(keys)}"
f"searched={len(searched)}"
)
@on_command("platforms", description="覆盖 platform 相关 API")
# ============================================================
# Database operations
# ============================================================
@on_command("db", description="数据库操作演示")
async def db_ops(self, event: MessageEvent, ctx: Context):
"""DB client full API demo."""
# Basic operations
await ctx.db.set("demo:key1", {"value": "data1"})
await ctx.db.set("demo:key2", {"value": "data2"})
await ctx.db.set("demo:key3", {"value": "data3"})
value1 = await ctx.db.get("demo:key1")
# List keys with prefix
keys = await ctx.db.list("demo:")
# Batch operations
values = await ctx.db.get_many(["demo:key1", "demo:key2"])
await ctx.db.set_many({
"demo:batch1": {"batch": True},
"demo:batch2": {"batch": True},
})
# Cleanup
for key in ["demo:key1", "demo:key2", "demo:key3", "demo:batch1", "demo:batch2"]:
await ctx.db.delete(key)
return event.plain_result(
f"value1={value1}|keys={len(keys)}|batch_get={len(values)}"
)
@on_command("watch", description="监听数据库变更")
async def watch_db(self, event: MessageEvent, ctx: Context) -> None:
"""Watch for DB changes (demonstration)."""
await event.reply("开始监听 demo: 前缀的变更 (5秒)...")
async def watcher():
count = 0
async for change in ctx.db.watch("demo:"):
count += 1
await event.reply(
f"变更: {change['op']} {change['key']}"
)
if count >= 3:
break
# Run watcher with timeout
try:
await asyncio.wait_for(watcher(), timeout=5.0)
except asyncio.TimeoutError:
await event.reply("监听超时结束")
# ============================================================
# Platform operations
# ============================================================
@on_command("platforms", description="平台操作演示")
async def platforms(self, event: MessageEvent, ctx: Context) -> None:
"""Platform client full API demo."""
target = event.target or event.session_id
members = await ctx.platform.get_members(target)
await ctx.platform.send_image(target, "https://example.com/demo.png")
await ctx.platform.send(
target,
f"members={len(members)} first={members[0]['user_id'] if members else 'none'}",
)
@on_command("announce", description="发送一条富消息链")
async def announce(self, event: MessageEvent, ctx: Context) -> None:
# Get group members
members = await ctx.platform.get_members(target)
# Send text
await ctx.platform.send(target, f"成员数: {len(members)}")
# Send image
await ctx.platform.send_image(target, "https://example.com/demo.png")
# Send message chain
await ctx.platform.send_chain(
event.target or event.session_id,
target,
[
{"type": "Plain", "text": "Demo "},
{"type": "Plain", "text": "消息链 "},
{"type": "Image", "file": "https://example.com/demo.png"},
],
)
@on_command("announce", description="发送富消息链")
async def announce(self, event: MessageEvent, ctx: Context) -> None:
"""Send rich message chain."""
await ctx.platform.send_chain(
event.target or event.session_id,
[
{"type": "Plain", "text": "公告: "},
{"type": "Plain", "text": event.text or "无内容"},
],
)
# ============================================================
# HTTP API operations
# ============================================================
@on_command("register_api", description="注册 HTTP API")
async def register_http_api(self, event: MessageEvent, ctx: Context) -> None:
"""Register a custom HTTP API endpoint."""
await ctx.http.register_api(
route="/demo/api",
handler_capability="demo.http_handler",
methods=["GET", "POST"],
description="Demo HTTP API",
)
apis = await ctx.http.list_apis()
return event.plain_result(f"已注册 API,当前共 {len(apis)} 个")
@on_command("unregister_api", description="注销 HTTP API")
async def unregister_http_api(self, event: MessageEvent, ctx: Context) -> None:
"""Unregister the HTTP API endpoint."""
await ctx.http.unregister_api("/demo/api")
return event.plain_result("已注销 API")
# ============================================================
# Metadata operations
# ============================================================
@on_command("plugins", description="列出所有插件")
async def list_plugins(self, event: MessageEvent, ctx: Context):
"""List all loaded plugins."""
plugins = await ctx.metadata.list_plugins()
names = [p.name for p in plugins]
return event.plain_result(f"插件: {', '.join(names)}")
@on_command("plugin_info", description="获取插件信息")
async def plugin_info(self, event: MessageEvent, ctx: Context):
"""Get current plugin metadata."""
me = await ctx.metadata.get_current_plugin()
if me:
return event.plain_result(
f"name={me.name}|version={me.version}|author={me.author}"
)
return event.plain_result("无法获取插件信息")
@on_command("config", description="获取插件配置")
async def get_config(self, event: MessageEvent, ctx: Context):
"""Get plugin configuration."""
config = await ctx.metadata.get_plugin_config()
if config:
return event.plain_result(f"config={config}")
return event.plain_result("无配置")
# ============================================================
# Permission control
# ============================================================
@require_admin
@on_command("secure", description="测试 require_admin")
@on_command("secure", description="管理员专用命令")
async def secure(self, event: MessageEvent):
"""Admin-only command."""
return event.plain_result(f"secure:{event.user_id or 'unknown'}")
# ============================================================
# Message handlers
# ============================================================
@on_message(regex=r"^ping$", keywords=["ping"], platforms=["test"])
async def ping(self, event: MessageEvent):
"""Regex and keyword matching."""
return event.plain_result("pong")
@on_message(keywords=["hello"])
async def on_hello(self, event: MessageEvent, ctx: Context) -> None:
"""Keyword-based message handler."""
await event.reply("检测到 hello 关键词!")
# ============================================================
# Event handlers
# ============================================================
@on_event("group_join")
async def on_group_join(self, event: MessageEvent, ctx: Context) -> None:
ctx.logger.info("event handler observed {}", event.text)
"""Handle group join events."""
ctx.logger.info("用户加入群组: {}", event.user_id)
await ctx.platform.send(
event.session_id,
f"欢迎 {event.user_id} 加入群组!"
)
@on_schedule(interval_seconds=60)
async def heartbeat(self, ctx: Context) -> None:
await ctx.db.set("demo:last_schedule", {"status": "ok"})
@on_event("group_leave")
async def on_group_leave(self, event: MessageEvent, ctx: Context) -> None:
"""Handle group leave events."""
ctx.logger.info("用户离开群组: {}", event.user_id)
# ============================================================
# Scheduled tasks
# ============================================================
@on_schedule(interval_seconds=3600)
async def hourly_heartbeat(self, ctx: Context) -> None:
"""Hourly scheduled task."""
await ctx.db.set("demo:last_heartbeat", {"time": "hourly"})
ctx.logger.info("执行每小时心跳")
@on_schedule(cron="0 9 * * *")
async def morning_greeting(self, ctx: Context) -> None:
"""Cron-based scheduled task (9 AM daily)."""
ctx.logger.info("早安问候任务触发")
# ============================================================
# Custom capabilities
# ============================================================
@provide_capability(
"demo.echo",
@@ -133,14 +362,12 @@ class HelloPlugin(Star):
ctx: Context,
cancel_token: CancelToken,
) -> dict[str, str]:
"""Simple echo capability."""
cancel_token.raise_if_cancelled()
await ctx.db.set(
"demo:capability_echo",
{"text": str(payload.get("text", ""))},
)
stored = await ctx.db.get("demo:capability_echo") or {}
text = str(payload.get("text", ""))
await ctx.db.set("demo:capability_echo", {"text": text})
return {
"echo": str(stored.get("text", "")),
"echo": text,
"plugin_id": ctx.plugin_id,
}
@@ -171,9 +398,47 @@ class HelloPlugin(Star):
ctx: Context,
cancel_token: CancelToken,
):
"""Streaming echo capability."""
text = str(payload.get("text", ""))
await ctx.db.set("demo:last_stream", {"text": text})
for char in text:
cancel_token.raise_if_cancelled()
await asyncio.sleep(0)
yield {"text": char}
@provide_capability(
"demo.http_handler",
description="处理 /demo/api HTTP 请求",
input_schema={
"type": "object",
"properties": {
"method": {"type": "string"},
"body": {"type": "object"},
},
},
output_schema={
"type": "object",
"properties": {
"status": {"type": "integer"},
"body": {"type": "object"},
},
},
)
async def http_handler_capability(
self,
payload: dict[str, object],
ctx: Context,
cancel_token: CancelToken,
) -> dict[str, object]:
"""Handle HTTP API requests."""
method = payload.get("method", "GET")
body = payload.get("body", {})
ctx.logger.info(f"HTTP {method} request: {body}")
return {
"status": 200,
"body": {
"message": "Hello from plugin!",
"method": method,
"plugin_id": ctx.plugin_id,
},
}
-267
View File
@@ -1,267 +0,0 @@
"""旧版插件兼容测试夹具。
这个样例故意覆盖当前仍被兼容层支持的旧 API:
- CommandComponent / AstrMessageEvent / MessageChain
- filter.command / regex / permission / permission_type /
event_message_type / platform_adapter_type
- LegacyContext 的 conversation_manager / llm_generate / tool_loop_agent /
send_message / put|get|delete_kv_data / call_context_function
- 消息组件及其旧字段别名/工厂方法
"""
from __future__ import annotations
from astrbot_sdk import provide_capability
from astrbot_sdk.api.components.command import CommandComponent
from astrbot_sdk.api.event import AstrMessageEvent, filter
from astrbot_sdk.api.event.filter import (
ADMIN,
EventMessageType,
PermissionType,
PlatformAdapterType,
)
from astrbot_sdk.api.message import MessageChain
from astrbot_sdk.api.message_components import (
At,
AtAll,
Face,
File,
Image,
Node,
Plain,
Record,
Reply,
Video,
)
from astrbot_sdk.api.star.context import Context
from loguru import logger
class CompatHelper:
__compat_component_name__ = "CompatHelper"
async def shout(self, text: str) -> str:
return text.upper()
class HelloCommand(CommandComponent):
"""测试旧版 CommandComponent 兼容性。"""
def __init__(self, context: Context):
self.context = context
self.context._register_component(CompatHelper())
@filter.command("hello", alias={"hi"}, priority=10, desc="问候命令")
async def hello(self, event: AstrMessageEvent):
"""基本命令测试。"""
ret = await self.context.conversation_manager.new_conversation("hello")
logger.info("New conversation created: {}", ret)
yield event.plain_result(f"Hello, Astrbot! Created conversation ID: {ret}")
@filter.command("echo")
async def echo(self, event: AstrMessageEvent):
"""测试消息获取和 extra 状态。"""
text = event.get_message_str()
event.set_extra("last_echo", text)
extra = event.get_extra("last_echo")
event.clear_extra()
group = await event.get_group()
yield event.plain_result(
f"Echo: {text}|sender={event.get_sender_id()}|sender_name={event.get_sender_name()}|"
f"platform={event.get_platform_name()}:{event.get_platform_id()}|"
f"type={event.get_message_type().value}|messages={len(event.get_messages())}|"
f"self={event.get_self_id()}|private={event.is_private_chat()}|"
f"wake={event.is_wake_up()}|group={group is not None}|extra={extra}"
)
@filter.command("chain")
async def test_chain(self, event: AstrMessageEvent):
"""测试 MessageChain 构建、发送与 react。"""
chain = (
MessageChain()
.message("Hello")
.message(" ")
.at("user", "12345")
.at_all()
.message(" check this image: ")
.url_image("https://example.com/test.png")
.file_image("C:/tmp/test.png")
.base64_image("base64://fixture")
.use_t2i(True)
.squash_plain()
)
await event.send(chain)
await event.react(":thumbsup:")
yield event.plain_result(
f"Chain sent with {len(chain.to_payload())} components and t2i={chain.use_t2i_}"
)
@filter.command("db")
async def test_db(self, event: AstrMessageEvent):
"""测试 KV 数据库操作。"""
await self.context.put_kv_data("test_key", {"value": "test_data"})
data = await self.context.get_kv_data("test_key")
await self.context.delete_kv_data("test_key")
deleted = await self.context.get_kv_data("test_key", "missing")
logger.info("Got data from db: {}", data)
yield event.plain_result(f"DB test: stored={data} deleted={deleted}")
@filter.command("conversation")
async def test_conversation(self, event: AstrMessageEvent):
"""测试会话管理器和 call_context_function。"""
umo = f"platform:{event.get_session_id()}"
cid = await self.context.conversation_manager.new_conversation(
unified_msg_origin=umo,
title="Compat Chat",
persona_id="assistant",
)
await self.context.conversation_manager.add_message_pair(cid, "hello", "world")
await self.context.conversation_manager.update_conversation(
unified_msg_origin=umo,
conversation_id=cid,
title="Compat Chat Updated",
)
await self.context.conversation_manager.update_conversation_title(umo, "Compat")
await self.context.conversation_manager.update_conversation_persona_id(
umo,
"compat-persona",
)
current_id = await self.context.conversation_manager.get_curr_conversation_id(
umo
)
conv = await self.context.conversation_manager.get_conversation(umo, cid)
all_conversations = await self.context.conversation_manager.get_conversations(
umo
)
helper_result = await self.context.call_context_function(
"CompatHelper.shout",
{"text": "compat"},
)
await self.context.conversation_manager.switch_conversation(umo, cid)
await self.context.conversation_manager.delete_conversation(umo, cid)
yield event.plain_result(
f"conversation={current_id}|messages={len((conv or {}).get('content', []))}|"
f"all={len(all_conversations)}|helper={helper_result['data']}"
)
@filter.command("ai")
async def test_ai(self, event: AstrMessageEvent):
"""测试旧版 AI compat 入口。"""
llm_resp = await self.context.llm_generate(
chat_provider_id="provider-demo",
prompt="legacy hello",
contexts=[{"role": "user", "content": "hi"}],
)
agent_resp = await self.context.tool_loop_agent(
chat_provider_id="provider-demo",
prompt="legacy hello",
tools=[{"name": "search"}],
max_steps=3,
)
yield event.plain_result(f"LLM:{llm_resp.text}|AGENT:{agent_resp.text}")
@filter.command("sendmsg")
async def test_send_message(self, event: AstrMessageEvent):
"""测试 LegacyContext.send_message。"""
chain = (
MessageChain()
.message("compat send ")
.at("legacy-user", "10001")
.url_image("https://example.com/send.png")
)
await self.context.send_message(event.get_session_id(), chain)
yield event.plain_result("send_message invoked")
@filter.command("components")
async def test_components(self, event: AstrMessageEvent):
"""测试消息组件和 chain_result/image_result。"""
components = [
Plain(text="Hello"),
At(qq="123", name="legacy_user"),
AtAll(),
Image.fromURL("https://example.com/img.png"),
Image.fromFileSystem("C:/tmp/local.png"),
Record.fromFileSystem("C:/tmp/sound.wav"),
Video.fromURL("https://example.com/video.mp4"),
File(name="demo.txt", file="https://example.com/demo.txt"),
Reply(id="reply-1"),
Node(uin="10001", name="node_user", content=[Plain(text="node")]),
Face(id=1),
]
component_dicts = [component.to_dict() for component in components]
logger.info("Components: {}", component_dicts)
yield event.chain_result(components)
yield event.image_result("https://example.com/components.png")
@filter.command("state")
async def test_event_state(self, event: AstrMessageEvent):
"""测试事件状态方法。"""
result = event.make_result().message("state-ok")
event.set_result(result)
before_stop = event.is_stopped()
event.stop_event()
after_stop = event.is_stopped()
event.continue_event()
after_continue = event.is_stopped()
event.should_call_llm(True)
stored = event.get_result()
event.clear_result()
yield event.plain_result(
f"before={before_stop}|after_stop={after_stop}|after_continue={after_continue}|"
f"call_llm={event.call_llm}|stored={stored is not None}"
)
@filter.regex(r"^ping.*")
async def ping_regex(self, event: AstrMessageEvent):
"""测试正则匹配。"""
yield event.plain_result("Pong from regex!")
@filter.permission(ADMIN)
@filter.command("admin")
async def admin_only(self, event: AstrMessageEvent):
"""测试 permission(ADMIN)。"""
yield event.plain_result(f"Admin command executed by {event.is_admin()}")
@filter.permission_type(PermissionType.ADMIN)
@filter.command("admin_type")
async def admin_type_only(self, event: AstrMessageEvent):
"""测试 permission_type(PermissionType.ADMIN)。"""
yield event.plain_result(f"Admin type command executed by {event.is_admin()}")
@filter.event_message_type(EventMessageType.GROUP_MESSAGE)
async def group_only(self, event: AstrMessageEvent):
"""测试消息类型过滤。"""
yield event.plain_result("Group message received")
@filter.platform_adapter_type(PlatformAdapterType.AIOCQHTTP)
async def cqhttp_only(self, event: AstrMessageEvent):
"""测试平台过滤。"""
yield event.plain_result("CQHttp platform detected")
@provide_capability(
"compat.echo",
description="使用 legacy Context 回显输入文本",
input_schema={
"type": "object",
"properties": {"text": {"type": "string"}},
"required": ["text"],
},
output_schema={
"type": "object",
"properties": {
"echo": {"type": "string"},
"plugin_id": {"type": "string"},
},
"required": ["echo", "plugin_id"],
},
)
async def echo_capability(self, payload: dict):
"""测试旧版插件 capability 能走 LegacyContext。"""
text = str(payload.get("text", ""))
await self.context.put_kv_data("compat_capability", {"text": text})
stored = await self.context.get_kv_data("compat_capability", {})
return {
"echo": str(stored.get("text", "")),
"plugin_id": self.context.plugin_id,
}
-24
View File
@@ -1,24 +0,0 @@
_schema_version: 2
name: astrbot_plugin_helloworld
display_name: HelloWorld 插件
desc: 一个简单的问候插件示例
author: Soulter
version: 0.1.0
runtime:
python: "3.12"
components: # 组件列表,将支持自动生成
- class: commands.hello:HelloCommand
type: command
name: hello
description: 发送问候消息
subcommands:
- name: wow
description: 发送 "Hello, Astrbot!" 消息
# - class: handlers.tools.echo:EchoTool
# type: llm_tool
# name: echo_tool
# description: 回显输入的消息
# - class: handlers.listeners.echo:EchoListener
# type: listener
# name: message_logger
# description: 监听并记录所有消息
-1
View File
@@ -1 +0,0 @@
@@ -1,561 +0,0 @@
# ruff: noqa: E402
from __future__ import annotations
import argparse
import asyncio
import contextlib
import json
import os
import re
import shutil
import stat
import subprocess
import sys
import tempfile
import textwrap
import time
from dataclasses import asdict, dataclass
from pathlib import Path
from typing import Any
PROJECT_ROOT = Path(__file__).resolve().parent.parent
SRC_NEW_DIR = PROJECT_ROOT / "src-new"
if str(PROJECT_ROOT) not in sys.path:
sys.path.insert(0, str(PROJECT_ROOT))
if str(SRC_NEW_DIR) not in sys.path:
sys.path.insert(0, str(SRC_NEW_DIR))
try:
import psutil
except ImportError: # pragma: no cover - optional dependency
psutil = None
from astrbot_sdk.protocol.messages import InitializeOutput, PeerInfo
from astrbot_sdk.runtime.bootstrap import SupervisorRuntime
from astrbot_sdk.runtime.loader import PluginEnvironmentManager
from astrbot_sdk.runtime.peer import Peer
from tests_v4.helpers import make_transport_pair
DEFAULT_MULTIPLIERS = [1, 2, 4, 8, 12]
DEFAULT_CONFLICT_COUNT = 1
DEFAULT_COMPATIBLE_COUNT = 5
SAMPLE_INTERVAL_SECONDS = 0.05
EXACT_PIN_PATTERN = re.compile(r"^([A-Za-z0-9_.-]+)==([^\s;]+)$")
@dataclass(slots=True)
class ProcessTreeSnapshot:
collector: str
process_count: int
total_rss_bytes: int
total_rss_mb: float
@dataclass(slots=True)
class BenchmarkCaseResult:
multiplier: int
conflict_plugins: int
compatible_plugins: int
total_plugins: int
group_count: int
skipped_plugins: int
startup_duration_ms: float
steady_rss_mb: float
peak_rss_mb: float
process_count: int
expected_groups: int
class SyntheticGroupedEnvManager(PluginEnvironmentManager):
"""用于 benchmark 的分组环境管理器。
这个实现保留真实的 supervisor 启动流程和插件分组规划,但把下面两类成
本较高、且对本地压力测试不稳定的动作替换掉:
- `uv pip compile` 改为直接生成可重复的伪 lockfile
- `uv venv` / `uv pip sync` 改为为每个分组创建一个指向当前解释器的路径
这样得到的结果更接近“分组规划 + worker 启动”的资源开销,而不是被
外网索引、包下载或磁盘安装速度主导。
"""
def __init__(self, repo_root: Path) -> None:
super().__init__(repo_root, uv_binary="synthetic-uv")
self._original_is_compatible = self._planner._is_compatible
self._planner._is_compatible = self._synthetic_is_compatible
self._planner._compile_lockfile = self._synthetic_compile_lockfile
self._group_manager.prepare = self._synthetic_prepare_environment
def _synthetic_is_compatible(self, plugins) -> bool:
requirement_lines = self._planner._collect_requirement_lines(plugins)
if not requirement_lines:
return True
merged = self._planner._merge_exact_requirements(requirement_lines)
if merged is not None:
return True
if all(EXACT_PIN_PATTERN.fullmatch(line) for line in requirement_lines):
return False
return self._original_is_compatible(plugins)
@staticmethod
def _synthetic_compile_lockfile(
*,
source_path: Path,
output_path: Path,
python_version: str,
) -> None:
lines = []
for raw_line in source_path.read_text(encoding="utf-8").splitlines():
line = raw_line.strip()
if not line or line.startswith("#"):
continue
lines.append(line)
output = [f"# synthetic lockfile for python {python_version}"]
output.extend(sorted(dict.fromkeys(lines)))
output_path.write_text("\n".join(output).rstrip() + "\n", encoding="utf-8")
@staticmethod
def _synthetic_prepare_environment(group) -> Path:
group.python_path.parent.mkdir(parents=True, exist_ok=True)
if group.python_path.exists():
return group.python_path
target = Path(sys.executable).resolve()
if os.name == "nt":
shutil.copy2(target, group.python_path)
current_mode = group.python_path.stat().st_mode
group.python_path.chmod(current_mode | stat.S_IEXEC)
else:
os.symlink(target, group.python_path)
return group.python_path
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(
description=(
"Benchmark v4 grouped plugin environments with configurable counts "
"for conflicting and compatible plugins."
)
)
parser.add_argument(
"--multipliers",
nargs="+",
type=int,
default=DEFAULT_MULTIPLIERS,
help="Scale factors applied to the base plugin counts.",
)
parser.add_argument(
"--conflict-count",
type=int,
default=DEFAULT_CONFLICT_COUNT,
help="Base count of conflicting plugins before applying multipliers.",
)
parser.add_argument(
"--compatible-count",
type=int,
default=DEFAULT_COMPATIBLE_COUNT,
help="Base count of compatible plugins before applying multipliers.",
)
parser.add_argument(
"--output-json",
type=Path,
default=None,
help="Optional path for the JSON benchmark report.",
)
parser.add_argument(
"--keep-temp-dir",
action="store_true",
help="Keep the generated temporary workspace for inspection.",
)
return parser.parse_args()
def write_benchmark_plugin(
*,
plugins_dir: Path,
plugin_name: str,
command_name: str,
requirement: str,
) -> None:
plugin_dir = plugins_dir / plugin_name
commands_dir = plugin_dir / "commands"
commands_dir.mkdir(parents=True, exist_ok=True)
(commands_dir / "__init__.py").write_text("", encoding="utf-8")
(plugin_dir / "requirements.txt").write_text(requirement, encoding="utf-8")
(plugin_dir / "plugin.yaml").write_text(
textwrap.dedent(
f"""\
_schema_version: 2
name: {plugin_name}
display_name: {plugin_name}
desc: grouped environment benchmark plugin
author: codex
version: 0.1.0
runtime:
python: "{sys.version_info.major}.{sys.version_info.minor}"
components:
- class: commands.main:BenchmarkCommand
type: command
name: {command_name}
description: {command_name}
"""
),
encoding="utf-8",
)
(commands_dir / "main.py").write_text(
textwrap.dedent(
f"""\
from astrbot_sdk.api.components.command import CommandComponent
from astrbot_sdk.api.event import AstrMessageEvent, filter
from astrbot_sdk.api.star.context import Context
class BenchmarkCommand(CommandComponent):
def __init__(self, context: Context):
self.context = context
@filter.command("{command_name}")
async def handle(self, event: AstrMessageEvent):
yield event.plain_result("{plugin_name}:{command_name}")
"""
),
encoding="utf-8",
)
def create_plugin_matrix(
*,
plugins_dir: Path,
multiplier: int,
conflict_base_count: int,
compatible_base_count: int,
) -> tuple[int, int]:
conflict_count = conflict_base_count * multiplier
compatible_count = compatible_base_count * multiplier
for index in range(compatible_count):
write_benchmark_plugin(
plugins_dir=plugins_dir,
plugin_name=f"compatible_{index:03d}",
command_name=f"compatible_{index:03d}",
requirement="shared-demo==1.0.0\n",
)
for index in range(conflict_count):
write_benchmark_plugin(
plugins_dir=plugins_dir,
plugin_name=f"conflict_{index:03d}",
command_name=f"conflict_{index:03d}",
requirement=f"shared-demo==2.0.{index}\n",
)
return conflict_count, compatible_count
def _snapshot_with_psutil(root_pid: int) -> ProcessTreeSnapshot:
assert psutil is not None
root = psutil.Process(root_pid)
processes = [root] + root.children(recursive=True)
total_rss = 0
seen = 0
for process in processes:
try:
total_rss += process.memory_info().rss
seen += 1
except (psutil.NoSuchProcess, psutil.AccessDenied):
continue
return ProcessTreeSnapshot(
collector="psutil",
process_count=seen,
total_rss_bytes=total_rss,
total_rss_mb=round(total_rss / 1024 / 1024, 2),
)
def _snapshot_with_ps(root_pid: int) -> ProcessTreeSnapshot:
result = subprocess.run(
["ps", "-axo", "pid,ppid,rss"],
check=True,
capture_output=True,
text=True,
)
children_by_parent: dict[int, list[int]] = {}
rss_by_pid: dict[int, int] = {}
for line in result.stdout.splitlines()[1:]:
parts = line.strip().split(None, 2)
if len(parts) != 3:
continue
pid, ppid, rss_kb = parts
pid_int = int(pid)
ppid_int = int(ppid)
rss_by_pid[pid_int] = int(rss_kb) * 1024
children_by_parent.setdefault(ppid_int, []).append(pid_int)
queue = [root_pid]
seen: set[int] = set()
total_rss = 0
while queue:
pid = queue.pop(0)
if pid in seen:
continue
seen.add(pid)
total_rss += rss_by_pid.get(pid, 0)
queue.extend(children_by_parent.get(pid, []))
return ProcessTreeSnapshot(
collector="ps",
process_count=len(seen),
total_rss_bytes=total_rss,
total_rss_mb=round(total_rss / 1024 / 1024, 2),
)
def collect_process_tree_snapshot(root_pid: int) -> ProcessTreeSnapshot:
if psutil is not None:
try:
return _snapshot_with_psutil(root_pid)
except (PermissionError, psutil.Error):
pass
return _snapshot_with_ps(root_pid)
async def sample_peak_rss(root_pid: int, stop_event: asyncio.Event) -> float:
peak_bytes = 0
while True:
snapshot = await asyncio.to_thread(collect_process_tree_snapshot, root_pid)
peak_bytes = max(peak_bytes, snapshot.total_rss_bytes)
try:
await asyncio.wait_for(stop_event.wait(), timeout=SAMPLE_INTERVAL_SECONDS)
break
except asyncio.TimeoutError:
continue
return round(peak_bytes / 1024 / 1024, 2)
async def start_core_peer() -> tuple[Peer, Any]:
left, right = make_transport_pair()
core = Peer(
transport=left,
peer_info=PeerInfo(name="benchmark-core", role="core", version="v4"),
)
core.set_initialize_handler(
lambda _message: asyncio.sleep(
0,
result=InitializeOutput(
peer=PeerInfo(name="benchmark-core", role="core", version="v4"),
capabilities=[],
metadata={},
),
)
)
await core.start()
return core, right
async def run_case(
case_root: Path,
multiplier: int,
*,
conflict_base_count: int,
compatible_base_count: int,
) -> BenchmarkCaseResult:
plugins_dir = case_root / "plugins"
conflict_count, compatible_count = create_plugin_matrix(
plugins_dir=plugins_dir,
multiplier=multiplier,
conflict_base_count=conflict_base_count,
compatible_base_count=compatible_base_count,
)
env_manager = SyntheticGroupedEnvManager(case_root)
core, supervisor_transport = await start_core_peer()
runtime = SupervisorRuntime(
transport=supervisor_transport,
plugins_dir=plugins_dir,
env_manager=env_manager,
)
peak_stop_event = asyncio.Event()
peak_task = asyncio.create_task(sample_peak_rss(os.getpid(), peak_stop_event))
started_at = time.perf_counter()
try:
await runtime.start()
await core.wait_until_remote_initialized()
startup_duration_ms = round((time.perf_counter() - started_at) * 1000, 2)
plan_result = env_manager._plan_result
if plan_result is None:
raise RuntimeError("benchmark plan result missing after runtime start")
steady_snapshot = await asyncio.to_thread(
collect_process_tree_snapshot, os.getpid()
)
peak_rss_mb = max(
steady_snapshot.total_rss_mb,
await _finish_peak_sampler(peak_stop_event, peak_task),
)
expected_groups = conflict_count + (1 if compatible_count else 0)
return BenchmarkCaseResult(
multiplier=multiplier,
conflict_plugins=conflict_count,
compatible_plugins=compatible_count,
total_plugins=conflict_count + compatible_count,
group_count=len(plan_result.groups),
skipped_plugins=len(runtime.skipped_plugins),
startup_duration_ms=startup_duration_ms,
steady_rss_mb=steady_snapshot.total_rss_mb,
peak_rss_mb=peak_rss_mb,
process_count=steady_snapshot.process_count,
expected_groups=expected_groups,
)
finally:
peak_stop_event.set()
with contextlib.suppress(Exception):
await peak_task
await stop_runtime_concurrently(runtime, core)
async def _finish_peak_sampler(
stop_event: asyncio.Event, peak_task: asyncio.Task[float]
) -> float:
stop_event.set()
return await peak_task
async def stop_runtime_concurrently(runtime: SupervisorRuntime, core: Peer) -> None:
session_stops = [
session.stop() for session in list(runtime.worker_sessions.values())
]
if session_stops:
await asyncio.gather(*session_stops, return_exceptions=True)
await runtime.peer.stop()
await core.stop()
def render_table(results: list[BenchmarkCaseResult]) -> str:
headers = [
"倍数",
"冲突",
"兼容",
"总插件",
"分组",
"预期分组",
"启动(ms)",
"稳态RSS(MB)",
"峰值RSS(MB)",
"进程数",
]
rows = [
[
str(item.multiplier),
str(item.conflict_plugins),
str(item.compatible_plugins),
str(item.total_plugins),
str(item.group_count),
str(item.expected_groups),
f"{item.startup_duration_ms:.2f}",
f"{item.steady_rss_mb:.2f}",
f"{item.peak_rss_mb:.2f}",
str(item.process_count),
]
for item in results
]
widths = [
max(len(headers[index]), *(len(row[index]) for row in rows))
for index in range(len(headers))
]
lines = [
" ".join(headers[index].ljust(widths[index]) for index in range(len(headers))),
" ".join("-" * widths[index] for index in range(len(headers))),
]
lines.extend(
" ".join(row[index].ljust(widths[index]) for index in range(len(headers)))
for row in rows
)
return "\n".join(lines)
async def run_benchmark(args: argparse.Namespace) -> list[BenchmarkCaseResult]:
temp_dir_context: Any
if args.keep_temp_dir:
workspace_root = PROJECT_ROOT / ".tmp-benchmark-grouped-env"
workspace_root.mkdir(parents=True, exist_ok=True)
temp_dir_context = contextlib.nullcontext(str(workspace_root))
else:
temp_dir_context = tempfile.TemporaryDirectory(prefix="astrbot-grouped-bench-")
results: list[BenchmarkCaseResult] = []
with temp_dir_context as temp_dir:
workspace_root = Path(temp_dir)
for multiplier in args.multipliers:
case_root = workspace_root / f"case_{multiplier:02d}"
if case_root.exists():
shutil.rmtree(case_root)
case_root.mkdir(parents=True, exist_ok=True)
results.append(
await run_case(
case_root,
multiplier,
conflict_base_count=args.conflict_count,
compatible_base_count=args.compatible_count,
)
)
return results
def write_json_report(
*,
output_path: Path,
conflict_base_count: int,
compatible_base_count: int,
results: list[BenchmarkCaseResult],
) -> None:
payload = {
"generated_at": time.strftime("%Y-%m-%d %H:%M:%S"),
"base_ratio": {
"conflict_plugins": conflict_base_count,
"compatible_plugins": compatible_base_count,
},
"multipliers": [item.multiplier for item in results],
"results": [asdict(item) for item in results],
}
output_path.parent.mkdir(parents=True, exist_ok=True)
output_path.write_text(
json.dumps(payload, ensure_ascii=False, indent=2, sort_keys=True),
encoding="utf-8",
)
async def async_main() -> int:
args = parse_args()
if args.conflict_count < 0 or args.compatible_count < 0:
raise SystemExit("conflict-count 和 compatible-count 不能为负数")
if any(multiplier <= 0 for multiplier in args.multipliers):
raise SystemExit("multipliers 必须全部大于 0")
results = await run_benchmark(args)
print(render_table(results))
if args.output_json is not None:
write_json_report(
output_path=args.output_json,
conflict_base_count=args.conflict_count,
compatible_base_count=args.compatible_count,
results=results,
)
print(f"\nJSON 报告已写入: {args.output_json}")
mismatched = [
item
for item in results
if item.group_count != item.expected_groups or item.skipped_plugins != 0
]
return 1 if mismatched else 0
def main() -> None:
raise SystemExit(asyncio.run(async_main()))
if __name__ == "__main__":
main()
-32
View File
@@ -1,32 +0,0 @@
{
"cases": [
{
"name": "hapi_connector",
"repo": "https://github.com/LiJinHao999/astrbot_plugin_hapi_connector.git",
"command": "hapi",
"event_text": "hapi",
"expected_text": "HAPI",
"known_unsupported": []
},
{
"name": "endfield",
"repo": "https://github.com/Entropy-Increase-Team/astrbot_plugin_endfield.git",
"command": "zmd",
"event_text": "zmd",
"expected_text": "终末地协议终端",
"known_unsupported": [
"依赖 playwright 时会退回纯文本帮助输出"
]
},
{
"name": "self_learning",
"repo": "https://github.com/NickCharlie/astrbot_plugin_self_learning.git",
"command": "learning_status",
"event_text": "learning_status",
"expected_text": "自学习插件状态报告",
"known_unsupported": [
"矩阵当前只验证管理命令,未覆盖需要真实 provider/persona 系统的深层能力"
]
}
]
}
-105
View File
@@ -1,105 +0,0 @@
from __future__ import annotations
import unittest
from unittest.mock import patch
from astrbot_sdk import MessageEvent, Star, on_command
from astrbot_sdk._legacy_api import (
CommandComponent,
LegacyContext,
_warned_methods,
)
from astrbot_sdk.clients._proxy import CapabilityProxy
from astrbot_sdk.clients.memory import MemoryClient
class _DummyPeer:
def __init__(self) -> None:
self.remote_capability_map = {}
self.calls: list[tuple[str, dict]] = []
async def invoke(self, name: str, payload: dict, *, stream: bool = False) -> dict:
self.calls.append((name, payload))
return {}
class _HandlerPlugin(Star):
@on_command("ping")
async def ping(self, event: MessageEvent) -> None:
return None
class ApiContractTest(unittest.IsolatedAsyncioTestCase):
async def test_star_lifecycle_hooks_exist(self) -> None:
star = Star()
self.assertIsNone(await star.on_start())
self.assertIsNone(await star.on_stop())
async def test_star_materializes_class_level_handlers(self) -> None:
self.assertEqual(_HandlerPlugin.__handlers__, ("ping",))
async def test_command_component_is_compat_star_subclass(self) -> None:
self.assertTrue(issubclass(CommandComponent, Star))
self.assertFalse(CommandComponent.__astrbot_is_new_star__())
async def test_message_event_reply_uses_bound_reply_handler(self) -> None:
sent: list[str] = []
event = MessageEvent(text="hello", session_id="session-1")
event.bind_reply_handler(lambda text: _collect_reply(sent, text))
await event.reply("pong")
self.assertEqual(sent, ["pong"])
async def test_memory_client_save_accepts_expanded_keyword_payload(self) -> None:
peer = _DummyPeer()
client = MemoryClient(CapabilityProxy(peer))
await client.save("memory-key", foo="bar", score=3)
self.assertEqual(
peer.calls,
[
(
"memory.save",
{
"key": "memory-key",
"value": {"foo": "bar", "score": 3},
},
)
],
)
async def test_add_llm_tools_accepts_empty_registration(self) -> None:
"""add_llm_tools() should keep the legacy entry point available."""
_warned_methods.clear()
legacy_context = LegacyContext("compat-plugin")
await legacy_context.add_llm_tools()
self.assertEqual(legacy_context.get_llm_tool_manager().func_list, [])
async def test_compat_llm_generate_warning_matches_chat_raw_mapping(self) -> None:
class _DummyLLM:
async def chat_raw(self, *args, **kwargs):
return {}
class _DummyRuntimeContext:
llm = _DummyLLM()
_warned_methods.clear()
legacy_context = LegacyContext("compat-plugin")
legacy_context.bind_runtime_context(_DummyRuntimeContext())
with patch("astrbot_sdk._legacy_api.logger.warning") as warning:
await legacy_context.llm_generate("provider-1", prompt="hi")
warning.assert_called_once()
self.assertEqual(warning.call_args.args[2], "ctx.llm.chat_raw(...)")
async def _collect_reply(sent: list[str], text: str) -> None:
sent.append(text)
if __name__ == "__main__":
unittest.main()
-418
View File
@@ -1,418 +0,0 @@
"""
Tests for api/event/filter.py - Event filter decorators and utilities.
"""
from __future__ import annotations
import pytest
from astrbot_sdk.api.event.filter import (
ADMIN,
CustomFilter,
EventMessageType,
PermissionType,
PlatformAdapterType,
command,
command_group,
custom_filter,
event_message_type,
filter,
get_compat_custom_filters,
get_compat_hook_metas,
get_compat_llm_tool_meta,
llm_tool,
on_llm_tool_respond,
on_plugin_error,
on_plugin_loaded,
on_plugin_unloaded,
on_using_llm_tool,
on_waiting_llm_request,
permission,
permission_type,
platform_adapter_type,
regex,
)
from astrbot_sdk.decorators import get_handler_meta
from astrbot_sdk.protocol.descriptors import CommandTrigger, MessageTrigger
class TestCommandFilter:
"""Tests for command() filter function."""
def test_command_creates_command_trigger(self):
"""command() should create a CommandTrigger."""
@command("hello")
async def hello_handler():
pass
meta = get_handler_meta(hello_handler)
assert meta is not None
assert isinstance(meta.trigger, CommandTrigger)
assert meta.trigger.command == "hello"
def test_command_is_decorator(self):
"""command() should be usable as a decorator."""
@command("test")
async def test_handler():
pass
assert hasattr(test_handler, "__astrbot_handler_meta__")
def test_command_supports_alias_and_priority(self):
"""command() should map legacy alias/priority arguments."""
@command("hello", alias={"hi", "hey"}, priority=3, desc="greeting")
async def hello_handler():
pass
meta = get_handler_meta(hello_handler)
assert meta is not None
assert meta.priority == 3
assert isinstance(meta.trigger, CommandTrigger)
assert sorted(meta.trigger.aliases) == ["hey", "hi"]
assert meta.trigger.description == "greeting"
class TestRegexFilter:
"""Tests for regex() filter function."""
def test_regex_creates_message_trigger_with_regex(self):
"""regex() should create a MessageTrigger with regex pattern."""
@regex(r"\d+")
async def number_handler():
pass
meta = get_handler_meta(number_handler)
assert meta is not None
assert isinstance(meta.trigger, MessageTrigger)
assert meta.trigger.regex == r"\d+"
def test_regex_pattern_is_stored(self):
"""regex() should store the pattern correctly."""
@regex(r"hello\s+world")
async def greeting_handler():
pass
meta = get_handler_meta(greeting_handler)
assert meta.trigger.regex == r"hello\s+world"
class TestPermissionFilter:
"""Tests for permission() filter function."""
def test_permission_admin_sets_require_admin(self):
"""permission(ADMIN) should set require_admin permission."""
@permission(ADMIN)
async def admin_handler():
pass
meta = get_handler_meta(admin_handler)
assert meta is not None
assert meta.permissions.require_admin is True
def test_permission_non_admin_passes_through(self):
"""permission() with non-ADMIN level should pass through."""
@permission("user")
async def user_handler():
pass
# Should not set admin permission
meta = get_handler_meta(user_handler)
assert meta is None or not meta.permissions.require_admin
def test_permission_can_be_combined_with_command(self):
"""permission() can be combined with other decorators."""
@command("admin")
@permission(ADMIN)
async def admin_command():
pass
meta = get_handler_meta(admin_command)
assert isinstance(meta.trigger, CommandTrigger)
assert meta.trigger.command == "admin"
assert meta.permissions.require_admin is True
def test_permission_type_admin_sets_require_admin(self):
"""permission_type(PermissionType.ADMIN) should map to admin permission."""
@permission_type(PermissionType.ADMIN)
async def admin_handler():
pass
meta = get_handler_meta(admin_handler)
assert meta is not None
assert meta.permissions.require_admin is True
def test_permission_type_member_passes_through(self):
"""permission_type(PermissionType.MEMBER) should be a no-op."""
@permission_type(PermissionType.MEMBER)
async def member_handler():
pass
meta = get_handler_meta(member_handler)
assert meta is None or not meta.permissions.require_admin
class TestFilterNamespace:
"""Tests for filter namespace object."""
def test_filter_namespace_has_command(self):
"""filter namespace should have command method."""
assert hasattr(filter, "command")
assert callable(filter.command)
def test_filter_namespace_has_regex(self):
"""filter namespace should have regex method."""
assert hasattr(filter, "regex")
assert callable(filter.regex)
def test_filter_namespace_has_permission(self):
"""filter namespace should have permission method."""
assert hasattr(filter, "permission")
assert callable(filter.permission)
def test_filter_command_works_as_decorator(self):
"""filter.command() should work as a decorator."""
@filter.command("ping")
async def ping_handler():
pass
meta = get_handler_meta(ping_handler)
assert meta.trigger.command == "ping"
def test_filter_regex_works_as_decorator(self):
"""filter.regex() should work as a decorator."""
@filter.regex(r"test")
async def test_handler():
pass
meta = get_handler_meta(test_handler)
assert meta.trigger.regex == r"test"
def test_filter_permission_admin_works(self):
"""filter.permission(ADMIN) should set admin permission."""
@filter.permission(ADMIN)
async def admin_handler():
pass
meta = get_handler_meta(admin_handler)
assert meta.permissions.require_admin is True
def test_filter_namespace_exposes_legacy_enum_constants(self):
"""filter namespace should carry the old enum/constant attributes."""
assert filter.ADMIN == ADMIN
assert filter.PermissionType is PermissionType
assert filter.EventMessageType is EventMessageType
class TestCompatFilterComposition:
"""Tests for legacy filter composition helpers."""
def test_event_message_type_creates_message_trigger(self):
"""event_message_type() should create a message trigger when missing."""
@event_message_type(EventMessageType.PRIVATE_MESSAGE)
async def private_handler():
pass
meta = get_handler_meta(private_handler)
assert isinstance(meta.trigger, MessageTrigger)
assert meta.trigger.message_types == ["private"]
def test_event_message_type_merges_into_command_trigger(self):
"""event_message_type() should preserve command triggers."""
@command("hello")
@event_message_type(EventMessageType.GROUP_MESSAGE)
async def group_command():
pass
meta = get_handler_meta(group_command)
assert isinstance(meta.trigger, CommandTrigger)
assert meta.trigger.command == "hello"
assert meta.trigger.message_types == ["group"]
def test_platform_adapter_type_creates_message_trigger(self):
"""platform_adapter_type() should create a message trigger when missing."""
@platform_adapter_type(PlatformAdapterType.AIOCQHTTP | PlatformAdapterType.KOOK)
async def platform_handler():
pass
meta = get_handler_meta(platform_handler)
assert isinstance(meta.trigger, MessageTrigger)
assert meta.trigger.platforms == ["aiocqhttp", "kook"]
def test_platform_adapter_type_merges_into_command_trigger(self):
"""platform_adapter_type() should preserve command triggers."""
@command("hello")
@platform_adapter_type(PlatformAdapterType.AIOCQHTTP)
async def platform_command():
pass
meta = get_handler_meta(platform_command)
assert isinstance(meta.trigger, CommandTrigger)
assert meta.trigger.command == "hello"
assert meta.trigger.platforms == ["aiocqhttp"]
def test_command_preserves_existing_platform_and_message_constraints(self):
"""command() should not discard previously-registered compat filters."""
@command("hello", alias={"hi"})
@platform_adapter_type(PlatformAdapterType.QQOFFICIAL)
@event_message_type(EventMessageType.PRIVATE_MESSAGE)
async def compat_command():
pass
meta = get_handler_meta(compat_command)
assert isinstance(meta.trigger, CommandTrigger)
assert meta.trigger.command == "hello"
assert meta.trigger.aliases == ["hi"]
assert meta.trigger.platforms == ["qq_official"]
assert meta.trigger.message_types == ["private"]
class TestAdminConstant:
"""Tests for ADMIN constant."""
def test_admin_constant_value(self):
"""ADMIN constant should be 'admin'."""
assert ADMIN == "admin"
class TestModuleExports:
"""Tests for module exports."""
def test_all_exports(self):
"""Module should export expected names."""
from astrbot_sdk.api.event.filter import __all__
assert "ADMIN" in __all__
assert "command" in __all__
assert "regex" in __all__
assert "permission" in __all__
assert "filter" in __all__
assert "llm_tool" in __all__
assert "on_waiting_llm_request" in __all__
assert "on_using_llm_tool" in __all__
class TestCommandGroupCompat:
"""Tests for legacy command group flattening."""
def test_command_group_flattens_subcommand_to_command_trigger(self):
"""command_group().command() should flatten to a space-joined command."""
group = command_group("ccl")
@group.command("排行榜")
async def leaderboard():
pass
meta = get_handler_meta(leaderboard)
assert isinstance(meta.trigger, CommandTrigger)
assert meta.trigger.command == "ccl 排行榜"
def test_nested_command_group_flattens_recursively(self):
"""command_group().group().command() should preserve the full path."""
root = command_group("math")
calc = root.group("calc")
@calc.command("add")
async def add():
pass
meta = get_handler_meta(add)
assert isinstance(meta.trigger, CommandTrigger)
assert meta.trigger.command == "math calc add"
class TestCompatHookMetadata:
"""Tests for legacy hook metadata capture."""
def test_hook_decorators_store_prioritized_metadata(self):
"""Legacy hook decorators should record runtime metadata instead of raising."""
@filter.on_llm_request(priority=5)
@on_waiting_llm_request(priority=1)
async def hook():
pass
metas = get_compat_hook_metas(hook)
assert [(item.name, item.priority) for item in metas] == [
("on_waiting_llm_request", 1),
("on_llm_request", 5),
]
@pytest.mark.parametrize(
("factory", "name"),
[
(on_using_llm_tool, "on_using_llm_tool"),
(on_llm_tool_respond, "on_llm_tool_respond"),
(on_plugin_error, "on_plugin_error"),
(on_plugin_loaded, "on_plugin_loaded"),
(on_plugin_unloaded, "on_plugin_unloaded"),
],
)
def test_hook_factories_attach_named_hook_metadata(self, factory, name):
"""Compat hook helpers should attach the expected hook name."""
@factory()
async def hook():
pass
metas = get_compat_hook_metas(hook)
assert [item.name for item in metas] == [name]
def test_llm_tool_builds_compat_tool_metadata(self):
"""llm_tool() should expose legacy tool metadata for runtime registration."""
@llm_tool(name="math.add")
async def add_tool(a: int, b: int, event=None):
"""Add two integers.
Args:
a: first addend
b: second addend
"""
return a + b
tool_meta = get_compat_llm_tool_meta(add_tool)
assert tool_meta is not None
assert tool_meta.name == "math.add"
assert tool_meta.description == "Add two integers."
assert tool_meta.parameters == [
{"type": "number", "name": "a", "description": "first addend"},
{"type": "number", "name": "b", "description": "second addend"},
]
def test_custom_filter_records_filter_instance_on_handler(self):
"""custom_filter() should keep legacy filter objects for dispatcher evaluation."""
class AllowAll(CustomFilter):
def filter(self, event, cfg) -> bool:
return True
@custom_filter(AllowAll)
async def handler():
pass
filters = get_compat_custom_filters(handler)
assert len(filters) == 1
assert isinstance(filters[0], AllowAll)
-748
View File
@@ -1,748 +0,0 @@
"""
Tests for _legacy_api.py - Legacy compatibility layer.
"""
from __future__ import annotations
from unittest.mock import AsyncMock, MagicMock
import pytest
from astrbot_sdk._legacy_api import (
MIGRATION_DOC_URL,
CommandComponent,
Context,
LegacyContext,
LegacyConversationManager,
LegacyStar,
)
from astrbot_sdk.api.event.filter import (
llm_tool,
on_llm_request,
on_llm_response,
on_llm_tool_respond,
on_using_llm_tool,
)
from astrbot_sdk.api.message import Comp, MessageChain
from astrbot_sdk.api.provider.entities import LLMResponse
from astrbot_sdk.star import Star
class TestLegacyContext:
"""Tests for LegacyContext."""
def test_init_with_plugin_id(self):
"""LegacyContext should store plugin_id."""
ctx = LegacyContext("test_plugin")
assert ctx.plugin_id == "test_plugin"
def test_has_conversation_manager(self):
"""LegacyContext should have conversation_manager."""
ctx = LegacyContext("test_plugin")
assert hasattr(ctx, "conversation_manager")
assert isinstance(ctx.conversation_manager, LegacyConversationManager)
def test_require_runtime_context_raises_when_not_bound(self):
"""require_runtime_context() should raise when not bound."""
ctx = LegacyContext("test_plugin")
with pytest.raises(RuntimeError, match="尚未绑定运行时 Context"):
ctx.require_runtime_context()
def test_bind_runtime_context(self):
"""bind_runtime_context() should bind a NewContext."""
mock_ctx = MagicMock()
mock_ctx.plugin_id = "test"
legacy_ctx = LegacyContext("test_plugin")
legacy_ctx.bind_runtime_context(mock_ctx)
assert legacy_ctx.require_runtime_context() is mock_ctx
def test_get_config_returns_empty_dict_without_runtime_context(self):
"""get_config() should gracefully degrade before runtime binding."""
legacy_ctx = LegacyContext("test_plugin")
assert legacy_ctx.get_config() == {}
def test_get_config_reads_runtime_context_config_dict(self):
"""get_config() should expose the bound runtime config mapping."""
mock_ctx = MagicMock()
mock_ctx._astrbot_config = {"admins_id": ["1001"]}
legacy_ctx = LegacyContext("test_plugin")
legacy_ctx.bind_runtime_context(mock_ctx)
assert legacy_ctx.get_config() == {"admins_id": ["1001"]}
def test_context_alias_is_legacy_context(self):
"""Context should be an alias for LegacyContext."""
assert Context is LegacyContext
def test_auto_registers_conversation_manager_with_legacy_name(self):
"""LegacyContext should expose ConversationManager.* legacy names."""
ctx = LegacyContext("test_plugin")
assert (
ctx._registered_managers["ConversationManager"] is ctx.conversation_manager
)
assert "ConversationManager.new_conversation" in ctx._registered_functions
def test_register_component_skips_property_side_effects(self):
"""_register_component() should not touch unrelated properties."""
class ComponentWithProperty:
@property
def explode(self):
raise RuntimeError("property should not be touched")
def greet(self) -> str:
return "hello"
ctx = LegacyContext("test_plugin")
ctx._register_component(ComponentWithProperty())
assert "ComponentWithProperty.greet" in ctx._registered_functions
@pytest.mark.asyncio
async def test_call_context_function_wraps_registered_result(self):
"""call_context_function() should preserve the legacy {data: ...} shape."""
class SyncComponent:
def greet(self, name: str) -> str:
return f"hello {name}"
ctx = LegacyContext("test_plugin")
ctx._register_component(SyncComponent())
result = await ctx.call_context_function(
"SyncComponent.greet",
{"name": "astrbot"},
)
assert result == {"data": "hello astrbot"}
@pytest.mark.asyncio
async def test_execute_registered_function_supports_async_methods(self):
"""execute_registered_function() should await async component methods."""
class AsyncComponent:
async def double(self, value: int) -> int:
return value * 2
ctx = LegacyContext("test_plugin")
ctx._register_component(AsyncComponent())
result = await ctx.execute_registered_function(
"AsyncComponent.double",
{"value": 21},
)
assert result == 42
class TestLegacyConversationManager:
"""Tests for LegacyConversationManager."""
def test_init_with_parent(self):
"""LegacyConversationManager should store parent reference."""
parent = LegacyContext("test_plugin")
manager = LegacyConversationManager(parent)
assert manager._parent is parent
@pytest.mark.asyncio
async def test_new_conversation_creates_id(self):
"""new_conversation() should create a conversation ID."""
mock_ctx = MagicMock()
mock_ctx.plugin_id = "my_plugin"
mock_ctx.db = AsyncMock()
mock_ctx.db.get = AsyncMock(return_value=None)
mock_ctx.db.set = AsyncMock()
legacy_ctx = LegacyContext("my_plugin")
legacy_ctx._runtime_context = mock_ctx
conv_id = await legacy_ctx.conversation_manager.new_conversation(
unified_msg_origin="session-1"
)
assert conv_id.startswith("my_plugin-conv-")
assert conv_id.endswith("-1")
@pytest.mark.asyncio
async def test_new_conversation_stores_metadata(self):
"""new_conversation() should store conversation metadata."""
stored_data = {}
async def mock_get(key):
return stored_data.get(key)
async def mock_set(key, value):
stored_data[key] = value
mock_ctx = MagicMock()
mock_ctx.plugin_id = "my_plugin"
mock_ctx.db = MagicMock()
mock_ctx.db.get = mock_get
mock_ctx.db.set = mock_set
legacy_ctx = LegacyContext("my_plugin")
legacy_ctx._runtime_context = mock_ctx
conv_id = await legacy_ctx.conversation_manager.new_conversation(
unified_msg_origin="session-1",
platform_id="telegram",
title="Test Chat",
persona_id="assistant",
)
# Verify stored data
assert "__compat_conversations__" in stored_data
assert conv_id in stored_data["__compat_conversations__"]
data = stored_data["__compat_conversations__"][conv_id]
assert data["platform_id"] == "telegram"
assert data["title"] == "Test Chat"
assert data["persona_id"] == "assistant"
@pytest.mark.asyncio
async def test_new_conversation_increments_counter(self):
"""new_conversation() should keep conversation IDs unique within a plugin."""
stored_data = {}
async def mock_get(key):
return stored_data.get(key)
async def mock_set(key, value):
stored_data[key] = value
mock_ctx = MagicMock()
mock_ctx.plugin_id = "my_plugin"
mock_ctx.db = MagicMock()
mock_ctx.db.get = mock_get
mock_ctx.db.set = mock_set
legacy_ctx = LegacyContext("my_plugin")
legacy_ctx._runtime_context = mock_ctx
id1 = await legacy_ctx.conversation_manager.new_conversation(
unified_msg_origin="session-1"
)
id2 = await legacy_ctx.conversation_manager.new_conversation(
unified_msg_origin="session-1"
)
id3 = await legacy_ctx.conversation_manager.new_conversation(
unified_msg_origin="session-2"
)
assert id1.endswith("-1")
assert id2.endswith("-2")
assert id3.endswith("-3")
assert len({id1, id2, id3}) == 3
@pytest.mark.asyncio
async def test_new_conversation_skips_persisted_id_collisions(self):
"""new_conversation() should not reuse IDs that already exist in storage."""
stored_data = {
"__compat_conversations__": {
"my_plugin-conv-1": {"unified_msg_origin": "session-1"},
}
}
async def mock_get(key):
return stored_data.get(key)
async def mock_set(key, value):
stored_data[key] = value
mock_ctx = MagicMock()
mock_ctx.plugin_id = "my_plugin"
mock_ctx.db = MagicMock()
mock_ctx.db.get = mock_get
mock_ctx.db.set = mock_set
legacy_ctx = LegacyContext("my_plugin")
legacy_ctx._runtime_context = mock_ctx
conv_id = await legacy_ctx.conversation_manager.new_conversation(
unified_msg_origin="session-1"
)
assert conv_id == "my_plugin-conv-2"
@pytest.mark.asyncio
async def test_get_filtered_conversations_filters_by_keyword(self):
"""get_filtered_conversations() should search over stored conversation payloads."""
stored_data = {
"__compat_conversations__": {
"conv-1": {
"unified_msg_origin": "session-1",
"platform_id": "qq",
"content": [{"role": "user", "content": "hello astrbot"}],
},
"conv-2": {
"unified_msg_origin": "session-1",
"platform_id": "qq",
"content": [{"role": "user", "content": "other topic"}],
},
}
}
async def mock_get(key):
return stored_data.get(key)
mock_ctx = MagicMock()
mock_ctx.plugin_id = "my_plugin"
mock_ctx.db = MagicMock()
mock_ctx.db.get = mock_get
legacy_ctx = LegacyContext("my_plugin")
legacy_ctx._runtime_context = mock_ctx
result = await legacy_ctx.conversation_manager.get_filtered_conversations(
unified_msg_origin="session-1",
keyword="astrbot",
)
assert [item["conversation_id"] for item in result] == ["conv-1"]
@pytest.mark.asyncio
async def test_get_human_readable_context_renders_conversation_content(self):
"""get_human_readable_context() should render stored message pairs as readable text."""
stored_data = {
"__compat_conversations__": {
"conv-1": {
"unified_msg_origin": "session-1",
"content": [
{"role": "user", "content": "hello"},
{"role": "assistant", "content": "world"},
],
}
}
}
async def mock_get(key):
return stored_data.get(key)
mock_ctx = MagicMock()
mock_ctx.plugin_id = "my_plugin"
mock_ctx.db = MagicMock()
mock_ctx.db.get = mock_get
legacy_ctx = LegacyContext("my_plugin")
legacy_ctx._runtime_context = mock_ctx
legacy_ctx.conversation_manager._current_conversations["session-1"] = "conv-1"
result = await legacy_ctx.conversation_manager.get_human_readable_context(
unified_msg_origin="session-1"
)
assert result == "user: hello\nassistant: world"
class TestLegacyContextMethods:
"""Tests for LegacyContext methods that delegate to NewContext."""
@pytest.mark.asyncio
async def test_put_kv_data(self):
"""put_kv_data() should delegate to db.set()."""
mock_db = AsyncMock()
mock_ctx = MagicMock()
mock_ctx.db = mock_db
legacy_ctx = LegacyContext("test_plugin")
legacy_ctx._runtime_context = mock_ctx
await legacy_ctx.put_kv_data("test_key", {"value": 123})
mock_db.set.assert_called_once_with("test_key", {"value": 123})
@pytest.mark.asyncio
async def test_put_kv_data_accepts_scalar_value(self):
"""put_kv_data() should support scalar JSON values."""
mock_db = AsyncMock()
mock_ctx = MagicMock()
mock_ctx.db = mock_db
legacy_ctx = LegacyContext("test_plugin")
legacy_ctx._runtime_context = mock_ctx
await legacy_ctx.put_kv_data("greeted", True)
mock_db.set.assert_called_once_with("greeted", True)
@pytest.mark.asyncio
async def test_get_kv_data(self):
"""get_kv_data() should delegate to db.get()."""
mock_db = AsyncMock()
mock_db.get = AsyncMock(return_value={"data": "hello"})
mock_ctx = MagicMock()
mock_ctx.db = mock_db
legacy_ctx = LegacyContext("test_plugin")
legacy_ctx._runtime_context = mock_ctx
result = await legacy_ctx.get_kv_data("my_key")
mock_db.get.assert_called_once_with("my_key")
assert result == {"data": "hello"}
@pytest.mark.asyncio
async def test_get_kv_data_returns_default_when_missing(self):
"""get_kv_data() should honor the legacy default parameter."""
mock_db = AsyncMock()
mock_db.get = AsyncMock(return_value=None)
mock_ctx = MagicMock()
mock_ctx.db = mock_db
legacy_ctx = LegacyContext("test_plugin")
legacy_ctx._runtime_context = mock_ctx
result = await legacy_ctx.get_kv_data("missing", False)
mock_db.get.assert_called_once_with("missing")
assert result is False
@pytest.mark.asyncio
async def test_delete_kv_data(self):
"""delete_kv_data() should delegate to db.delete()."""
mock_db = AsyncMock()
mock_ctx = MagicMock()
mock_ctx.db = mock_db
legacy_ctx = LegacyContext("test_plugin")
legacy_ctx._runtime_context = mock_ctx
await legacy_ctx.delete_kv_data("to_delete")
mock_db.delete.assert_called_once_with("to_delete")
class TestLegacyContextSendMessage:
"""Tests for LegacyContext.send_message()."""
@pytest.mark.asyncio
async def test_send_message_with_plain_string(self):
"""send_message() should handle plain string."""
mock_platform = AsyncMock()
mock_ctx = MagicMock()
mock_ctx.platform = mock_platform
legacy_ctx = LegacyContext("test_plugin")
legacy_ctx._runtime_context = mock_ctx
await legacy_ctx.send_message("session-1", "hello world")
mock_platform.send.assert_called_once_with("session-1", "hello world")
@pytest.mark.asyncio
async def test_send_message_with_get_plain_text_method(self):
"""send_message() should use get_plain_text() if available."""
class MockMessageChain:
def get_plain_text(self):
return "extracted text"
mock_platform = AsyncMock()
mock_ctx = MagicMock()
mock_ctx.platform = mock_platform
legacy_ctx = LegacyContext("test_plugin")
legacy_ctx._runtime_context = mock_ctx
await legacy_ctx.send_message("session-1", MockMessageChain())
mock_platform.send.assert_called_once_with("session-1", "extracted text")
@pytest.mark.asyncio
async def test_send_message_with_message_chain_uses_send_chain(self):
"""send_message() should preserve rich chains when MessageChain is available."""
mock_platform = AsyncMock()
mock_ctx = MagicMock()
mock_ctx.platform = mock_platform
legacy_ctx = LegacyContext("test_plugin")
legacy_ctx._runtime_context = mock_ctx
chain = MessageChain(
[
Comp.Plain(text="hello"),
Comp.Image(file="https://example.com/image.png"),
]
)
await legacy_ctx.send_message("session-1", chain)
mock_platform.send_chain.assert_called_once_with(
"session-1",
[
{"type": "Plain", "text": "hello"},
{"type": "Image", "file": "https://example.com/image.png"},
],
)
@pytest.mark.asyncio
async def test_send_message_with_to_text_method(self):
"""send_message() should use to_text() if get_plain_text() not available."""
class MockMessageChain:
def to_text(self):
return "to_text result"
mock_platform = AsyncMock()
mock_ctx = MagicMock()
mock_ctx.platform = mock_platform
legacy_ctx = LegacyContext("test_plugin")
legacy_ctx._runtime_context = mock_ctx
await legacy_ctx.send_message("session-1", MockMessageChain())
mock_platform.send.assert_called_once_with("session-1", "to_text result")
@pytest.mark.asyncio
async def test_send_message_falls_back_to_str(self):
"""send_message() should fall back to str() if no text method."""
class MockObject:
def __str__(self):
return "stringified"
mock_platform = AsyncMock()
mock_ctx = MagicMock()
mock_ctx.platform = mock_platform
legacy_ctx = LegacyContext("test_plugin")
legacy_ctx._runtime_context = mock_ctx
await legacy_ctx.send_message("session-1", MockObject())
mock_platform.send.assert_called_once_with("session-1", "stringified")
class TestLegacyContextLLMMethods:
"""Tests for LegacyContext LLM methods."""
@pytest.mark.asyncio
async def test_llm_generate_returns_compat_response_and_applies_hook_mutation(self):
"""llm_generate() should return legacy LLMResponse and honor hook-mutated request data."""
mock_llm = AsyncMock()
mock_llm.chat_raw = AsyncMock(
return_value={"role": "assistant", "text": "response"}
)
mock_ctx = MagicMock()
mock_ctx.llm = mock_llm
legacy_ctx = LegacyContext("test_plugin")
legacy_ctx._runtime_context = mock_ctx
seen_completion_texts = []
class CompatHooks:
@on_llm_request()
async def mutate_request(self, request):
request.model = "hook-model"
@on_llm_response()
async def capture_response(self, response: LLMResponse):
seen_completion_texts.append(response.completion_text)
legacy_ctx._register_component(CompatHooks())
result = await legacy_ctx.llm_generate(
chat_provider_id="provider-1",
prompt="hello",
system_prompt="be helpful",
contexts=[{"role": "user", "content": "hi"}],
)
mock_llm.chat_raw.assert_called_once()
call_kwargs = mock_llm.chat_raw.call_args[1]
assert call_kwargs["provider_id"] == "provider-1"
assert call_kwargs["model"] == "hook-model"
assert isinstance(result, LLMResponse)
assert result.completion_text == "response"
assert result.text == "response"
assert seen_completion_texts == ["response"]
@pytest.mark.asyncio
async def test_tool_loop_agent_runs_registered_compat_tools(self):
"""tool_loop_agent() should execute registered compat llm tools and continue the loop."""
mock_llm = AsyncMock()
mock_llm.chat_raw = AsyncMock(
side_effect=[
{
"role": "assistant",
"text": "",
"tool_calls": [
{
"id": "call-1",
"function": {
"name": "math.add",
"arguments": '{"a": 1, "b": 2}',
},
}
],
},
{
"role": "assistant",
"text": "result ready",
},
]
)
mock_ctx = MagicMock()
mock_ctx.llm = mock_llm
legacy_ctx = LegacyContext("test_plugin")
legacy_ctx._runtime_context = mock_ctx
seen_tool_events = []
class CompatToolComponent:
@llm_tool(name="math.add")
async def add(self, a: int, b: int):
return str(a + b)
@on_using_llm_tool()
async def before_tool(self, tool_args):
seen_tool_events.append(("before", dict(tool_args)))
@on_llm_tool_respond()
async def after_tool(self, tool_result):
seen_tool_events.append(("after", tool_result))
legacy_ctx._register_component(CompatToolComponent())
result = await legacy_ctx.tool_loop_agent(
chat_provider_id="provider-1",
prompt="hello",
max_steps=10,
)
assert mock_llm.chat_raw.await_count == 2
first_call = mock_llm.chat_raw.await_args_list[0]
second_call = mock_llm.chat_raw.await_args_list[1]
assert first_call.kwargs["provider_id"] == "provider-1"
assert first_call.kwargs["max_steps"] == 10
assert second_call.kwargs["history"][-1] == {
"role": "tool",
"tool_call_id": "call-1",
"name": "math.add",
"content": "3",
}
assert isinstance(result, LLMResponse)
assert result.completion_text == "result ready"
assert result.text == "result ready"
assert seen_tool_events == [
("before", {"a": 1, "b": 2}),
("after", "3"),
]
@pytest.mark.asyncio
async def test_add_llm_tools_registers_compat_tool_object(self):
"""add_llm_tools() should accept legacy tool objects and expose them via the tool manager."""
legacy_ctx = LegacyContext("test_plugin")
class ToolObject:
name = "demo.echo"
description = "Echo input"
parameters = {
"type": "object",
"properties": {"text": {"type": "string"}},
"required": ["text"],
}
async def handler(self, text: str) -> str:
return text
await legacy_ctx.add_llm_tools(ToolObject())
manager = legacy_ctx.get_llm_tool_manager()
tool = manager.get_func("demo.echo")
assert tool is not None
assert tool.description == "Echo input"
assert tool.parameters["required"] == ["text"]
class TestCommandComponent:
"""Tests for CommandComponent."""
def test_is_star_subclass(self):
"""CommandComponent should be a Star subclass."""
assert issubclass(CommandComponent, Star)
def test_is_not_new_star(self):
"""CommandComponent should NOT be recognized as new-style star."""
assert CommandComponent.__astrbot_is_new_star__() is False
def test_create_legacy_context(self):
"""_astrbot_create_legacy_context() should create LegacyContext."""
ctx = CommandComponent._astrbot_create_legacy_context("my_plugin")
assert isinstance(ctx, LegacyContext)
assert ctx.plugin_id == "my_plugin"
class TestLegacyStarDelegation:
"""Tests for LegacyStar methods that proxy to LegacyContext."""
@pytest.mark.asyncio
async def test_put_kv_data_delegates_to_context(self):
legacy_ctx = LegacyContext("test_plugin")
legacy_ctx.put_kv_data = AsyncMock()
star = LegacyStar(legacy_ctx)
await star.put_kv_data("key", 1)
legacy_ctx.put_kv_data.assert_awaited_once_with("key", 1)
@pytest.mark.asyncio
async def test_get_kv_data_delegates_to_context(self):
legacy_ctx = LegacyContext("test_plugin")
legacy_ctx.get_kv_data = AsyncMock(return_value=True)
star = LegacyStar(legacy_ctx)
result = await star.get_kv_data("key", False)
legacy_ctx.get_kv_data.assert_awaited_once_with("key", False)
assert result is True
def test_get_config_delegates_to_context(self):
legacy_ctx = LegacyContext("test_plugin")
legacy_ctx.get_config = MagicMock(return_value={"admins_id": ["42"]})
star = LegacyStar(legacy_ctx)
assert star.get_config() == {"admins_id": ["42"]}
def test_get_config_works_when_subclass_does_not_call_super_init(self):
"""LegacyStar proxy should stay lazy for old plugins that skip super().__init__()."""
legacy_ctx = LegacyContext("test_plugin")
legacy_ctx.get_config = MagicMock(return_value={"admins_id": ["7"]})
class LegacySubclass(LegacyStar):
def __init__(self, context):
self.context = context
star = LegacySubclass(legacy_ctx)
assert star.get_config() == {"admins_id": ["7"]}
class TestMigrationDocUrl:
"""Tests for migration documentation URL."""
def test_migration_doc_url_exists(self):
"""MIGRATION_DOC_URL should be defined."""
assert MIGRATION_DOC_URL is not None
assert "docs.astrbot.app" in MIGRATION_DOC_URL
-66
View File
@@ -1,66 +0,0 @@
"""
Tests for legacy message component compatibility helpers.
"""
from __future__ import annotations
from astrbot_sdk.api import message_components as Comp
class TestLegacyMessageComponentAliases:
"""Tests for legacy constructor aliases."""
def test_at_accepts_qq_alias(self):
"""At should accept the legacy qq field name."""
component = Comp.At(qq="123456", name="Tester")
assert component.user_id == "123456"
assert component.user_name == "Tester"
def test_file_accepts_name_alias(self):
"""File should accept the legacy name field name."""
component = Comp.File(file="/tmp/demo.txt", name="demo.txt")
assert component.file == "/tmp/demo.txt"
assert component.file_name == "demo.txt"
def test_node_accepts_uin_and_name_aliases(self):
"""Node should accept the legacy uin/name constructor fields."""
component = Comp.Node(uin="10001", name="AstrBot")
assert component.sender_id == "10001"
assert component.nickname == "AstrBot"
class TestLegacyMessageComponentFactories:
"""Tests for legacy media helper factories."""
def test_image_from_bytes(self):
"""Image.fromBytes() should encode bytes into a base64 payload string."""
component = Comp.Image.fromBytes(b"demo-image")
assert component.file.startswith("base64://")
def test_image_from_url(self):
"""Image.fromURL() should create a component with file payload."""
component = Comp.Image.fromURL("https://example.com/image.png")
assert component.file == "https://example.com/image.png"
def test_image_from_file_system(self):
"""Image.fromFileSystem() should create a component with file payload."""
component = Comp.Image.fromFileSystem("C:/tmp/image.png")
assert component.file == "C:/tmp/image.png"
def test_video_from_url(self):
"""Video.fromURL() should create a component with file payload."""
component = Comp.Video.fromURL("https://example.com/video.mp4")
assert component.file == "https://example.com/video.mp4"
def test_record_from_file_system(self):
"""Record.fromFileSystem() should create a component with file payload."""
component = Comp.Record.fromFileSystem("C:/tmp/audio.wav")
assert component.file == "C:/tmp/audio.wav"
-413
View File
@@ -1,413 +0,0 @@
"""
Tests for API module exports and re-exports.
"""
from __future__ import annotations
import pytest
class TestApiStarModule:
"""Tests for api/star module exports."""
def test_star_module_exports_context(self):
"""api.star should export Context."""
from astrbot_sdk.api.star import Context
assert Context is not None
def test_star_context_is_legacy_context(self):
"""api.star.Context should be LegacyContext."""
from astrbot_sdk._legacy_api import LegacyContext
from astrbot_sdk.api.star import Context
assert Context is LegacyContext
def test_star_module_exports_metadata(self):
"""api.star should export StarMetadata."""
from astrbot_sdk.api.star import StarMetadata
metadata = StarMetadata(name="demo", version="1.0.0")
assert metadata.name == "demo"
assert metadata.version == "1.0.0"
def test_star_module_exports_legacy_star_and_register(self):
"""api.star should expose legacy Star/register imports."""
from astrbot_sdk._legacy_api import LegacyStar
from astrbot_sdk.api.star import Star, StarTools, register
@register(name="demo", author="tester")
class DemoStar(Star):
pass
assert Star is LegacyStar
assert callable(StarTools.get_data_dir)
assert callable(register)
assert DemoStar.__astrbot_plugin_metadata__ == {
"name": "demo",
"author": "tester",
}
class TestApiComponentsModule:
"""Tests for api/components module exports."""
def test_components_module_exports_command_component(self):
"""api.components should export CommandComponent."""
from astrbot_sdk.api.components import CommandComponent
assert CommandComponent is not None
def test_command_component_is_from_legacy_api(self):
"""api.components.CommandComponent should be from _legacy_api."""
from astrbot_sdk._legacy_api import CommandComponent as LegacyCommandComponent
from astrbot_sdk.api.components import CommandComponent
assert CommandComponent is LegacyCommandComponent
class TestApiEventModule:
"""Tests for api/event module exports."""
def test_event_module_exports(self):
"""api.event should export expected names."""
from astrbot_sdk.api.event import ADMIN, AstrMessageEvent, MessageChain, filter
assert ADMIN == "admin"
assert filter is not None
assert AstrMessageEvent is not None
assert MessageChain is not None
def test_astr_message_event_is_message_event_subclass(self):
"""AstrMessageEvent should be a MessageEvent-compatible subclass."""
from astrbot_sdk.api.event import AstrMessageEvent
from astrbot_sdk.events import MessageEvent
assert issubclass(AstrMessageEvent, MessageEvent)
def test_all_exports(self):
"""api.event should export all expected names."""
from astrbot_sdk.api.event import __all__
assert "ADMIN" in __all__
assert "AstrMessageEvent" in __all__
assert "filter" in __all__
def test_event_module_exports_legacy_types(self):
"""api.event should expose common legacy helper types."""
from astrbot_sdk.api.event import (
MessageEventResult,
MessageSession,
MessageType,
)
assert MessageEventResult is not None
assert MessageSession is not None
assert MessageType is not None
def test_astr_message_event_preserves_legacy_message_str_and_private_group_none(
self,
):
"""AstrMessageEvent should expose message_str and return None for missing group."""
from astrbot_sdk.api.event import AstrMessageEvent
event = AstrMessageEvent(text="hello", user_id="user-1")
assert event.message_str == "hello"
assert event.get_group_id() is None
def test_message_chain_serializes_components(self):
"""MessageChain.to_payload() should preserve compat component fields."""
from astrbot_sdk.api.message import Comp, MessageChain
chain = MessageChain(
[
Comp.Plain(text="hello"),
Comp.Image(file="https://example.com/image.png"),
]
)
assert chain.to_payload() == [
{"type": "Plain", "text": "hello"},
{"type": "Image", "file": "https://example.com/image.png"},
]
@pytest.mark.asyncio
async def test_astr_message_event_send_uses_send_chain_when_context_bound(self):
"""AstrMessageEvent.send() should use platform.send_chain for rich messages."""
from unittest.mock import AsyncMock, MagicMock
from astrbot_sdk.api.event import AstrMessageEvent
from astrbot_sdk.api.message import Comp, MessageChain
from astrbot_sdk.protocol.descriptors import SessionRef
runtime_context = MagicMock()
runtime_context.platform = AsyncMock()
event = AstrMessageEvent(session_id="session-1", context=runtime_context)
chain = MessageChain(
[
Comp.Plain(text="hello"),
Comp.Image(file="https://example.com/image.png"),
]
)
await event.send(chain)
runtime_context.platform.send_chain.assert_called_once_with(
SessionRef(conversation_id="session-1"),
[
{"type": "Plain", "text": "hello"},
{"type": "Image", "file": "https://example.com/image.png"},
],
)
class TestApiModule:
"""Tests for top-level api module."""
def test_api_module_exists(self):
"""api module should be importable."""
import astrbot_sdk.api
assert astrbot_sdk.api is not None
def test_api_subpackages_exist(self):
"""New compat subpackages should be importable."""
from loguru import logger
from astrbot_sdk.api import (
AstrBotConfig,
basic,
message,
message_components,
platform,
provider,
)
assert AstrBotConfig is not None
assert basic is not None
assert logger is not None
assert message is not None
assert message_components is not None
assert platform is not None
assert provider is not None
class TestAstrbotImportAlias:
"""Tests for the legacy ``astrbot`` package-name alias."""
def test_legacy_astrbot_root_exports_logger(self):
"""astrbot root package should expose logger like the old package did."""
from astrbot import logger
assert logger is not None
def test_legacy_astrbot_api_exports(self):
"""astrbot.api should expose the old logger/config entrance."""
from astrbot.api import AstrBotConfig, llm_tool, logger, sp
assert AstrBotConfig is not None
assert logger is not None
assert sp is not None
assert callable(llm_tool)
def test_legacy_astrbot_event_exports(self):
"""astrbot.api.event should expose MessageChain from the legacy location."""
from astrbot.api.event import AstrMessageEvent, MessageChain, filter
assert AstrMessageEvent is not None
assert MessageChain is not None
assert filter is not None
def test_legacy_astrbot_star_exports(self):
"""astrbot.api.star should expose Context/Star/register/StarTools."""
from astrbot.api.star import Context, Star, StarTools, register
assert Context is not None
assert Star is not None
assert callable(StarTools.get_data_dir)
assert callable(register)
def test_legacy_astrbot_core_session_waiter_exports(self):
"""astrbot.core.utils.session_waiter should expose the compat waiter helpers."""
from astrbot.core.utils.session_waiter import (
SessionController,
SessionWaiter,
session_waiter,
)
assert SessionController is not None
assert callable(SessionWaiter.trigger)
assert callable(session_waiter)
def test_legacy_astrbot_core_root_exports(self):
"""astrbot.core root should expose common legacy root names."""
from astrbot.core import AstrBotConfig, logger, sp
from astrbot.core.message.components import At, Image, Node, Nodes, Plain
assert AstrBotConfig is not None
assert logger is not None
assert sp is not None
assert Plain(text="ok").text == "ok"
assert Image(file="https://example.com/test.png").file
assert At(qq="1").user_id == "1"
assert Node(uin="1", content=[]).sender_id == "1"
assert Nodes(nodes=[]).nodes == []
def test_legacy_astrbot_core_config_imports(self):
"""astrbot.core.config old import paths should remain available."""
from astrbot.core.config import AstrBotConfig
from astrbot.core.config.astrbot_config import AstrBotConfig as ConfigModel
assert AstrBotConfig is ConfigModel
def test_legacy_astrbot_core_platform_imports(self):
"""astrbot.core.platform old import paths should remain available."""
from astrbot.core.platform import (
AstrBotMessage,
AstrMessageEvent,
MessageType,
Platform,
PlatformMetadata,
)
from astrbot.core.platform.astr_message_event import MessageSession
from astrbot.core.platform.message_type import MessageType as MessageTypeModule
from astrbot.core.platform.platform_metadata import (
PlatformMetadata as MetaModule,
)
from astrbot.core.platform.register import register_platform_adapter
from astrbot.core.platform.sources.aiocqhttp import AiocqhttpMessageEvent
assert AstrBotMessage is not None
assert AstrMessageEvent is AiocqhttpMessageEvent
assert MessageSession is not None
assert MessageType is MessageTypeModule
assert PlatformMetadata is MetaModule
assert Platform is not None
with pytest.raises(NotImplementedError, match="register_platform_adapter"):
register_platform_adapter()
def test_legacy_astrbot_core_utils_astrbot_path_imports(self):
"""astrbot.core.utils.astrbot_path should expose legacy path helpers."""
from astrbot.core.utils.astrbot_path import (
get_astrbot_data_path,
get_astrbot_temp_path,
)
assert callable(get_astrbot_data_path)
assert callable(get_astrbot_temp_path)
def test_legacy_astrbot_core_provider_imports(self):
"""astrbot.core.provider old import paths should remain available."""
from astrbot.core.provider.entities import ProviderType, RerankResult
from astrbot.core.provider.provider import (
EmbeddingProvider,
Provider,
RerankProvider,
)
assert Provider is not None
assert EmbeddingProvider is not None
assert RerankProvider is not None
assert ProviderType.CHAT_COMPLETION.value == "chat_completion"
assert RerankResult(index=1, relevance_score=0.5).index == 1
def test_legacy_astrbot_core_agent_and_db_imports(self):
"""astrbot.core.agent/db old import paths should remain available."""
from astrbot.core.agent.message import TextPart
from astrbot.core.db.po import Personality
assert TextPart(text="hi").text == "hi"
assert Personality is not None
def test_legacy_astrbot_event_filter_module_exports(self):
"""astrbot.api.event.filter should be importable from the old module path."""
from astrbot.api.event.filter import EventMessageType, command, llm_tool
assert EventMessageType is not None
assert callable(command)
assert callable(llm_tool)
def test_legacy_astrbot_platform_exports(self):
"""astrbot.api.platform should expose the common legacy platform types."""
from astrbot.api.platform import (
AstrBotMessage,
AstrMessageEvent,
MessageType,
Platform,
PlatformMetadata,
register_platform_adapter,
)
assert AstrBotMessage is not None
assert AstrMessageEvent is not None
assert MessageType is not None
assert Platform is not None
assert PlatformMetadata is not None
with pytest.raises(NotImplementedError, match="register_platform_adapter"):
register_platform_adapter()
def test_legacy_astrbot_provider_exports(self):
"""astrbot.api.provider should expose the common legacy provider types."""
from astrbot.api.provider import (
LLMResponse,
Provider,
ProviderMetaData,
ProviderRequest,
ProviderType,
STTProvider,
)
meta = ProviderMetaData(id="demo")
req = ProviderRequest(prompt="hello")
assert LLMResponse is not None
assert Provider is not None
assert STTProvider is not None
assert meta.id == "demo"
assert req.prompt == "hello"
assert ProviderType.CHAT_COMPLETION.value == "chat_completion"
def test_legacy_astrbot_api_all_exports(self):
"""astrbot.api.all should remain importable from the old umbrella module."""
from astrbot.api.all import (
AstrMessageEvent,
Context,
LLMResponse,
MessageChain,
command,
llm_tool,
register,
sp,
)
assert AstrMessageEvent is not None
assert Context is not None
assert LLMResponse is not None
assert MessageChain is not None
assert callable(command)
assert callable(llm_tool)
assert callable(register)
assert sp is not None
def test_legacy_astrbot_util_exports(self):
"""astrbot.api.util should expose the waiter helpers from the old path."""
from astrbot.api.util import SessionController, SessionWaiter, session_waiter
assert SessionController is not None
assert callable(SessionWaiter.trigger)
assert callable(session_waiter)
@pytest.mark.asyncio
async def test_legacy_astrbot_sp_roundtrip(self):
"""astrbot.api.sp should provide a usable in-memory compat store."""
from astrbot.api import sp
await sp.global_put("feature_flag", True)
assert await sp.global_get("feature_flag", False) is True
await sp.session_put("umo:test", "counter", 3)
assert await sp.session_get("umo:test", "counter", 0) == 3
await sp.session_remove("umo:test", "counter")
assert await sp.session_get("umo:test", "counter", 0) == 0
File diff suppressed because it is too large Load Diff
-67
View File
@@ -1,67 +0,0 @@
"""Compatibility contract tests for the controlled ``astrbot`` facade."""
from __future__ import annotations
from importlib import import_module
import pytest
LEVEL_ONE_MODULES = [
"astrbot.api",
"astrbot.api.all",
"astrbot.api.components",
"astrbot.api.components.command",
"astrbot.api.message_components",
"astrbot.api.event",
"astrbot.api.event.filter",
"astrbot.api.star",
"astrbot.api.platform",
"astrbot.api.provider",
"astrbot.api.util",
]
LEVEL_TWO_MODULES = [
"astrbot.core",
"astrbot.core.config",
"astrbot.core.config.astrbot_config",
"astrbot.core.message",
"astrbot.core.message.components",
"astrbot.core.message.message_event_result",
"astrbot.core.agent",
"astrbot.core.agent.message",
"astrbot.core.db",
"astrbot.core.db.po",
"astrbot.core.platform",
"astrbot.core.platform.astr_message_event",
"astrbot.core.platform.astrbot_message",
"astrbot.core.platform.message_type",
"astrbot.core.platform.platform_metadata",
"astrbot.core.platform.register",
"astrbot.core.platform.sources.aiocqhttp",
"astrbot.core.provider",
"astrbot.core.provider.entities",
"astrbot.core.provider.provider",
"astrbot.core.utils",
"astrbot.core.utils.astrbot_path",
"astrbot.core.utils.session_waiter",
]
@pytest.mark.parametrize("module_name", LEVEL_ONE_MODULES)
def test_level_one_legacy_facade_modules_import(module_name: str):
"""一级 compat 合同中的旧公开模块必须始终可导入。"""
assert import_module(module_name) is not None
@pytest.mark.parametrize("module_name", LEVEL_TWO_MODULES)
def test_level_two_legacy_facade_modules_import(module_name: str):
"""二级 compat 合同中的高频深路径必须始终可导入。"""
assert import_module(module_name) is not None
def test_level_two_html_renderer_stays_loud_fail():
"""未实现的旧 HTML 渲染系统应保持显式失败,而不是静默伪兼容。"""
from astrbot.core import html_renderer
with pytest.raises(NotImplementedError, match="html_renderer"):
html_renderer()
-326
View File
@@ -1,326 +0,0 @@
"""可选的外部插件兼容 smoke 测试。
默认不跑;标准入口是仓库内的外部插件矩阵:
- ``ASTRBOT_EXTERNAL_PLUGIN_CASES=all``
- ``ASTRBOT_EXTERNAL_PLUGIN_CASES=hapi_connector,endfield``
也保留单仓库 ad-hoc 模式:
- ``ASTRBOT_EXTERNAL_PLUGIN_REPO=https://...``
如果还希望验证真实 handler 调用,而不是仅验证可加载,可以额外设置:
- ``ASTRBOT_EXTERNAL_PLUGIN_COMMAND=<command>``
- ``ASTRBOT_EXTERNAL_PLUGIN_EVENT_TEXT=<text>`` (可选,默认等于 command)
- ``ASTRBOT_EXTERNAL_PLUGIN_EXPECT_TEXT=<substring>`` (可选)
"""
from __future__ import annotations
import asyncio
import os
import subprocess
import tempfile
import textwrap
import json
from pathlib import Path
import pytest
from astrbot_sdk.protocol.messages import InitializeOutput, PeerInfo
from astrbot_sdk.runtime.bootstrap import SupervisorRuntime
from astrbot_sdk.runtime.loader import (
PluginEnvironmentManager,
load_plugin_spec,
)
from astrbot_sdk.runtime.peer import Peer
from tests_v4.helpers import make_transport_pair
EXTERNAL_PLUGIN_REPO_ENV = "ASTRBOT_EXTERNAL_PLUGIN_REPO"
EXTERNAL_PLUGIN_COMMAND_ENV = "ASTRBOT_EXTERNAL_PLUGIN_COMMAND"
EXTERNAL_PLUGIN_EVENT_TEXT_ENV = "ASTRBOT_EXTERNAL_PLUGIN_EVENT_TEXT"
EXTERNAL_PLUGIN_EXPECT_TEXT_ENV = "ASTRBOT_EXTERNAL_PLUGIN_EXPECT_TEXT"
EXTERNAL_PLUGIN_CASES_ENV = "ASTRBOT_EXTERNAL_PLUGIN_CASES"
EXTERNAL_PLUGIN_MATRIX_PATH = Path(__file__).with_name("external_plugin_matrix.json")
def _clone_external_plugin(
*,
project_root: Path,
repo_url: str,
clone_dir: Path,
) -> None:
subprocess.run(
["git", "clone", "--depth", "1", repo_url, str(clone_dir)],
check=True,
cwd=project_root,
capture_output=True,
text=True,
)
def _load_external_plugin_matrix() -> list[dict[str, str]]:
payload = json.loads(EXTERNAL_PLUGIN_MATRIX_PATH.read_text(encoding="utf-8"))
cases = payload.get("cases", [])
return [case for case in cases if isinstance(case, dict)]
def _selected_matrix_cases() -> list[dict[str, str]]:
selector = os.getenv(EXTERNAL_PLUGIN_CASES_ENV, "").strip()
if not selector:
return []
cases = _load_external_plugin_matrix()
if selector.lower() == "all":
return cases
selected_names = {item.strip() for item in selector.split(",") if item.strip()}
return [case for case in cases if case.get("name") in selected_names]
def _load_plugin_in_subprocess(
*,
project_root: Path,
clone_dir: Path,
) -> subprocess.CompletedProcess[str]:
spec = load_plugin_spec(clone_dir)
manager = PluginEnvironmentManager(project_root)
python_path = manager.prepare_environment(spec)
script = textwrap.dedent(
f"""
import asyncio
import sys
from pathlib import Path
repo_root = Path({str(project_root)!r})
plugin_dir = Path({str(clone_dir)!r})
sys.path.insert(0, str((repo_root / "src-new").resolve()))
from astrbot_sdk.runtime.loader import load_plugin, load_plugin_spec
async def main():
spec = load_plugin_spec(plugin_dir)
loaded = load_plugin(spec)
print("PLUGIN", loaded.plugin.name)
print("HANDLERS", len(loaded.handlers))
print("CAPS", len(loaded.capabilities))
asyncio.run(main())
"""
)
env = os.environ.copy()
env["PYTHONPATH"] = str((project_root / "src-new").resolve())
return subprocess.run(
[str(python_path), "-c", script],
check=False,
cwd=project_root,
env=env,
capture_output=True,
text=True,
)
async def _run_runtime_command_smoke(
*,
project_root: Path,
plugin_root: Path,
command_name: str,
event_text: str,
expected_text: str | None,
) -> None:
left, right = make_transport_pair()
core = Peer(
transport=left,
peer_info=PeerInfo(name="outer-core", role="core", version="v4"),
)
core.set_initialize_handler(
lambda _message: asyncio.sleep(
0,
result=InitializeOutput(
peer=PeerInfo(name="outer-core", role="core", version="v4"),
capabilities=[],
metadata={},
),
)
)
runtime = SupervisorRuntime(
transport=right,
plugins_dir=plugin_root.parent,
env_manager=PluginEnvironmentManager(project_root),
)
await core.start()
try:
await runtime.start()
await core.wait_until_remote_initialized()
handler = next(
(
item
for item in core.remote_handlers
if getattr(item.trigger, "command", None) == command_name
),
None,
)
assert handler is not None, (
f"command handler not found: {command_name}; "
f"available={[getattr(item.trigger, 'command', None) for item in core.remote_handlers]}"
)
await core.invoke(
"handler.invoke",
{
"handler_id": handler.id,
"event": {
"text": event_text,
"session_id": "external-smoke-session",
"user_id": "user-1",
"platform": "test",
},
},
request_id=f"external-runtime-command-{command_name}",
)
sent_messages = list(runtime.capability_router.sent_messages)
assert sent_messages, (
"external plugin command completed but did not emit any platform "
"message; this usually means the command path was not really exercised"
)
if expected_text is not None:
assert any(
expected_text in item.get("text", "")
for item in sent_messages
if "text" in item
), sent_messages
finally:
await runtime.stop()
await core.stop()
@pytest.mark.skipif(
not os.getenv(EXTERNAL_PLUGIN_REPO_ENV),
reason=f"set {EXTERNAL_PLUGIN_REPO_ENV} to enable external plugin smoke tests",
)
def test_external_plugin_load_smoke():
"""按需 clone 外部插件仓库并验证其能在独立环境里完成加载。"""
repo_url = os.environ[EXTERNAL_PLUGIN_REPO_ENV]
project_root = Path(__file__).resolve().parent.parent
with tempfile.TemporaryDirectory(prefix="astrbot-external-plugin-") as temp_dir:
clone_dir = Path(temp_dir) / "plugin"
_clone_external_plugin(
project_root=project_root,
repo_url=repo_url,
clone_dir=clone_dir,
)
result = _load_plugin_in_subprocess(
project_root=project_root, clone_dir=clone_dir
)
assert result.returncode == 0, (
f"stdout:\n{result.stdout}\n\nstderr:\n{result.stderr}"
)
assert "HANDLERS" in result.stdout
assert "PLUGIN" in result.stdout
@pytest.mark.skipif(
not (
os.getenv(EXTERNAL_PLUGIN_REPO_ENV) and os.getenv(EXTERNAL_PLUGIN_COMMAND_ENV)
),
reason=(
f"set {EXTERNAL_PLUGIN_REPO_ENV} and {EXTERNAL_PLUGIN_COMMAND_ENV} "
"to enable external plugin runtime command smoke tests"
),
)
@pytest.mark.asyncio
async def test_external_plugin_runtime_command_smoke():
"""按需拉起真实 supervisor/worker 链路并执行一个外部插件命令。"""
repo_url = os.environ[EXTERNAL_PLUGIN_REPO_ENV]
command_name = os.environ[EXTERNAL_PLUGIN_COMMAND_ENV]
event_text = os.getenv(EXTERNAL_PLUGIN_EVENT_TEXT_ENV) or command_name
expected_text = os.getenv(EXTERNAL_PLUGIN_EXPECT_TEXT_ENV)
project_root = Path(__file__).resolve().parent.parent
with tempfile.TemporaryDirectory(prefix="astrbot-external-runtime-") as temp_dir:
plugins_root = Path(temp_dir) / "plugins"
plugin_root = plugins_root / "external_plugin"
_clone_external_plugin(
project_root=project_root,
repo_url=repo_url,
clone_dir=plugin_root,
)
await _run_runtime_command_smoke(
project_root=project_root,
plugin_root=plugin_root,
command_name=command_name,
event_text=event_text,
expected_text=expected_text,
)
@pytest.mark.skipif(
not os.getenv(EXTERNAL_PLUGIN_CASES_ENV),
reason=f"set {EXTERNAL_PLUGIN_CASES_ENV} to enable matrix external plugin smoke tests",
)
def test_external_plugin_matrix_load_smoke():
"""按矩阵批量验证外部插件能在独立环境里完成真实加载。"""
project_root = Path(__file__).resolve().parent.parent
cases = _selected_matrix_cases()
assert cases, f"no matrix cases matched {EXTERNAL_PLUGIN_CASES_ENV}"
with tempfile.TemporaryDirectory(
prefix="astrbot-external-matrix-load-"
) as temp_dir:
temp_root = Path(temp_dir)
for case in cases:
clone_dir = temp_root / case["name"]
_clone_external_plugin(
project_root=project_root,
repo_url=case["repo"],
clone_dir=clone_dir,
)
result = _load_plugin_in_subprocess(
project_root=project_root,
clone_dir=clone_dir,
)
assert result.returncode == 0, (
f"case={case['name']}\nstdout:\n{result.stdout}\n\nstderr:\n{result.stderr}"
)
assert "HANDLERS" in result.stdout
assert "PLUGIN" in result.stdout
@pytest.mark.skipif(
not os.getenv(EXTERNAL_PLUGIN_CASES_ENV),
reason=f"set {EXTERNAL_PLUGIN_CASES_ENV} to enable matrix external plugin smoke tests",
)
@pytest.mark.asyncio
async def test_external_plugin_matrix_runtime_smoke():
"""按矩阵批量验证外部插件代表命令能走真实 supervisor/worker 链路。"""
project_root = Path(__file__).resolve().parent.parent
cases = _selected_matrix_cases()
assert cases, f"no matrix cases matched {EXTERNAL_PLUGIN_CASES_ENV}"
with tempfile.TemporaryDirectory(
prefix="astrbot-external-matrix-runtime-"
) as temp_dir:
temp_root = Path(temp_dir)
for case in cases:
plugins_root = temp_root / f"{case['name']}_plugins"
plugin_root = plugins_root / case["name"]
_clone_external_plugin(
project_root=project_root,
repo_url=case["repo"],
clone_dir=plugin_root,
)
await _run_runtime_command_smoke(
project_root=project_root,
plugin_root=plugin_root,
command_name=case["command"],
event_text=case.get("event_text", case["command"]),
expected_text=case.get("expected_text"),
)
-341
View File
@@ -1,341 +0,0 @@
"""grouped env 的真实 smoke 测试。
运行示例:
python -m pytest tests_v4/test_grouped_environment_smoke.py -m "slow and integration" -v
"""
from __future__ import annotations
import asyncio
import shutil
import subprocess
import sys
import tempfile
import textwrap
from pathlib import Path
import pytest
import yaml
from astrbot_sdk.protocol.messages import InitializeOutput, PeerInfo
from astrbot_sdk.runtime.bootstrap import SupervisorRuntime
from astrbot_sdk.runtime.loader import PluginEnvironmentManager
from astrbot_sdk.runtime.peer import Peer
from tests_v4.helpers import make_transport_pair
pytestmark = [pytest.mark.slow, pytest.mark.integration]
UV_BINARY = shutil.which("uv")
async def start_test_core_peer(transport) -> Peer:
"""Provide an initialize responder for supervisor startup."""
core = Peer(
transport=transport,
peer_info=PeerInfo(name="grouped-env-core", role="core", version="v4"),
)
core.set_initialize_handler(
lambda _message: asyncio.sleep(
0,
result=InitializeOutput(
peer=PeerInfo(name="grouped-env-core", role="core", version="v4"),
capabilities=[],
metadata={},
),
)
)
await core.start()
return core
def _build_local_wheel(
*,
packages_root: Path,
wheelhouse: Path,
project_name: str,
version: str,
module_name: str,
) -> Path:
package_root = packages_root / f"{project_name}-{version}"
source_dir = package_root / "src" / module_name
source_dir.mkdir(parents=True, exist_ok=True)
(source_dir / "__init__.py").write_text(
f'__version__ = "{version}"\n',
encoding="utf-8",
)
(package_root / "pyproject.toml").write_text(
textwrap.dedent(
f"""\
[build-system]
requires = ["setuptools>=80", "wheel"]
build-backend = "setuptools.build_meta"
[project]
name = "{project_name}"
version = "{version}"
[tool.setuptools.packages.find]
where = ["src"]
"""
),
encoding="utf-8",
)
result = subprocess.run(
[
sys.executable,
"-m",
"pip",
"wheel",
str(package_root),
"--no-build-isolation",
"--no-deps",
"-w",
str(wheelhouse),
],
check=False,
capture_output=True,
text=True,
)
if result.returncode != 0:
raise RuntimeError(
f"failed to build local wheel {project_name}=={version}:\n"
f"stdout:\n{result.stdout}\n\nstderr:\n{result.stderr}"
)
candidates = sorted(
wheelhouse.glob(f"{module_name}-{version}-*.whl"),
key=lambda path: path.name,
)
if not candidates:
raise RuntimeError(f"local wheel not found for {project_name}=={version}")
return candidates[-1]
def build_local_wheelhouse(root: Path) -> dict[str, Path]:
"""Build offline wheels used by the smoke test."""
packages_root = root / "packages"
wheelhouse = root / "wheelhouse"
packages_root.mkdir(parents=True, exist_ok=True)
wheelhouse.mkdir(parents=True, exist_ok=True)
return {
"alpha-1": _build_local_wheel(
packages_root=packages_root,
wheelhouse=wheelhouse,
project_name="alpha-pkg",
version="1.0.0",
module_name="alpha_pkg",
),
"alpha-2": _build_local_wheel(
packages_root=packages_root,
wheelhouse=wheelhouse,
project_name="alpha-pkg",
version="2.0.0",
module_name="alpha_pkg",
),
"beta-1": _build_local_wheel(
packages_root=packages_root,
wheelhouse=wheelhouse,
project_name="beta-pkg",
version="1.0.0",
module_name="beta_pkg",
),
}
def write_smoke_plugin(
*,
plugins_dir: Path,
plugin_name: str,
command_name: str,
requirement_line: str,
import_module: str,
expected_text: str,
) -> Path:
plugin_dir = plugins_dir / plugin_name
commands_dir = plugin_dir / "commands"
commands_dir.mkdir(parents=True, exist_ok=True)
(commands_dir / "__init__.py").write_text("", encoding="utf-8")
(plugin_dir / "requirements.txt").write_text(
requirement_line + "\n",
encoding="utf-8",
)
(plugin_dir / "plugin.yaml").write_text(
yaml.dump(
{
"_schema_version": 2,
"name": plugin_name,
"display_name": plugin_name,
"desc": "grouped env smoke plugin",
"author": "codex",
"version": "0.1.0",
"runtime": {
"python": f"{sys.version_info.major}.{sys.version_info.minor}"
},
"components": [
{
"class": "commands.main:SmokeCommand",
"type": "command",
"name": command_name,
"description": command_name,
}
],
},
sort_keys=False,
),
encoding="utf-8",
)
(commands_dir / "main.py").write_text(
textwrap.dedent(
f"""\
from {import_module} import __version__ as DEP_VERSION
from astrbot_sdk.api.components.command import CommandComponent
from astrbot_sdk.api.event import AstrMessageEvent, filter
from astrbot_sdk.api.star.context import Context
class SmokeCommand(CommandComponent):
def __init__(self, context: Context):
self.context = context
@filter.command("{command_name}")
async def handle(self, event: AstrMessageEvent):
yield event.plain_result("{expected_text} " + DEP_VERSION)
"""
),
encoding="utf-8",
)
return plugin_dir
async def invoke_command(
runtime: SupervisorRuntime, core: Peer, command_name: str
) -> str:
"""Invoke one remote command and return the emitted text payload."""
runtime.capability_router.sent_messages.clear()
handler = next(
(
item
for item in core.remote_handlers
if getattr(item.trigger, "command", None) == command_name
),
None,
)
assert handler is not None, (
f"command handler not found: {command_name}; "
f"available={[getattr(item.trigger, 'command', None) for item in core.remote_handlers]}"
)
await core.invoke(
"handler.invoke",
{
"handler_id": handler.id,
"event": {
"text": command_name,
"session_id": f"smoke-session-{command_name}",
"user_id": "user-1",
"platform": "test",
},
},
request_id=f"grouped-env-smoke-{command_name}",
)
sent_messages = list(runtime.capability_router.sent_messages)
assert sent_messages, f"command {command_name} did not emit any message"
return str(sent_messages[-1].get("text", ""))
@pytest.mark.skipif(
UV_BINARY is None, reason="uv is required for grouped env smoke tests"
)
@pytest.mark.asyncio
async def test_grouped_environment_smoke_handles_shared_and_conflicting_dependencies():
"""Real uv-backed smoke test for shared and conflicting plugin environments."""
with tempfile.TemporaryDirectory(prefix="astrbot-grouped-env-smoke-") as temp_dir:
root = Path(temp_dir)
wheel_paths = build_local_wheelhouse(root)
plugins_dir = root / "plugins"
write_smoke_plugin(
plugins_dir=plugins_dir,
plugin_name="plugin_a",
command_name="probe_alpha_v1",
requirement_line=f"alpha-pkg @ {wheel_paths['alpha-1'].as_uri()}",
import_module="alpha_pkg",
expected_text="alpha-pkg",
)
write_smoke_plugin(
plugins_dir=plugins_dir,
plugin_name="plugin_b",
command_name="probe_beta_v1",
requirement_line=f"beta-pkg @ {wheel_paths['beta-1'].as_uri()}",
import_module="beta_pkg",
expected_text="beta-pkg",
)
write_smoke_plugin(
plugins_dir=plugins_dir,
plugin_name="plugin_c",
command_name="probe_alpha_v2",
requirement_line=f"alpha-pkg @ {wheel_paths['alpha-2'].as_uri()}",
import_module="alpha_pkg",
expected_text="alpha-pkg",
)
env_manager = PluginEnvironmentManager(root)
left, right = make_transport_pair()
core = await start_test_core_peer(left)
runtime = SupervisorRuntime(
transport=right,
plugins_dir=plugins_dir,
env_manager=env_manager,
)
shared_venv_path = None
isolated_venv_path = None
try:
await runtime.start()
await core.wait_until_remote_initialized()
assert sorted(runtime.loaded_plugins) == [
"plugin_a",
"plugin_b",
"plugin_c",
]
assert runtime.skipped_plugins == {}
assert env_manager._plan_result is not None
assert len(env_manager._plan_result.groups) == 2
shared_group = env_manager._plan_result.plugin_to_group["plugin_a"]
isolated_group = env_manager._plan_result.plugin_to_group["plugin_c"]
assert (
shared_group.id
== env_manager._plan_result.plugin_to_group["plugin_b"].id
)
assert shared_group.id != isolated_group.id
assert len(runtime.worker_sessions) == 2
assert core.remote_metadata["worker_group_count"] == 2
shared_venv_path = shared_group.venv_path
isolated_venv_path = isolated_group.venv_path
assert shared_venv_path.exists()
assert isolated_venv_path.exists()
alpha_v1_text = await invoke_command(runtime, core, "probe_alpha_v1")
beta_v1_text = await invoke_command(runtime, core, "probe_beta_v1")
alpha_v2_text = await invoke_command(runtime, core, "probe_alpha_v2")
assert "alpha-pkg 1.0.0" in alpha_v1_text
assert "beta-pkg 1.0.0" in beta_v1_text
assert "alpha-pkg 2.0.0" in alpha_v2_text
assert alpha_v1_text != alpha_v2_text
finally:
await runtime.stop()
await core.stop()
env_manager._planner.cleanup_artifacts([])
assert shared_venv_path is not None
assert isolated_venv_path is not None
assert not shared_venv_path.exists()
assert not isolated_venv_path.exists()
File diff suppressed because it is too large Load Diff

Some files were not shown because too many files have changed in this diff Show More