mirror of
https://github.com/AstrBotDevs/AstrBot.git
synced 2026-09-24 16:39:52 +08:00
clean it
This commit is contained in:
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"permissions": {
|
||||
"allow": [
|
||||
"Bash(cd:*)",
|
||||
"Bash(python:*)"
|
||||
]
|
||||
}
|
||||
}
|
||||
+49
-11
@@ -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 兼容层
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -1,7 +0,0 @@
|
||||
"""旧版 ``astrbot`` 包名兼容入口。"""
|
||||
|
||||
from loguru import logger
|
||||
|
||||
from . import api, core
|
||||
|
||||
__all__ = ["api", "core", "logger"]
|
||||
@@ -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",
|
||||
]
|
||||
@@ -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"]
|
||||
@@ -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
|
||||
@@ -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",
|
||||
]
|
||||
@@ -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",
|
||||
]
|
||||
@@ -1,5 +0,0 @@
|
||||
"""旧版 ``astrbot.api.star`` 导入路径兼容入口。"""
|
||||
|
||||
from astrbot_sdk.api.star import Context, Star, StarMetadata, StarTools, register
|
||||
|
||||
__all__ = ["Context", "Star", "StarMetadata", "StarTools", "register"]
|
||||
@@ -1,5 +0,0 @@
|
||||
"""旧版 ``astrbot.api.star.context`` 导入路径兼容入口。"""
|
||||
|
||||
from astrbot_sdk.api.star.context import Context
|
||||
|
||||
__all__ = ["Context"]
|
||||
@@ -1,9 +0,0 @@
|
||||
"""旧版 ``astrbot.api.util`` 导入路径兼容入口。"""
|
||||
|
||||
from astrbot.core.utils.session_waiter import (
|
||||
SessionController,
|
||||
SessionWaiter,
|
||||
session_waiter,
|
||||
)
|
||||
|
||||
__all__ = ["SessionController", "SessionWaiter", "session_waiter"]
|
||||
@@ -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",
|
||||
]
|
||||
@@ -1,5 +0,0 @@
|
||||
"""旧版 ``astrbot.core.agent`` 兼容入口。"""
|
||||
|
||||
from .message import TextPart
|
||||
|
||||
__all__ = ["TextPart"]
|
||||
@@ -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"]
|
||||
@@ -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"]
|
||||
@@ -1,5 +0,0 @@
|
||||
"""旧版 ``astrbot.core.db`` 兼容入口。"""
|
||||
|
||||
from .po import Personality
|
||||
|
||||
__all__ = ["Personality"]
|
||||
@@ -1,5 +0,0 @@
|
||||
"""旧版 ``astrbot.core.db.po`` 兼容入口。"""
|
||||
|
||||
from astrbot.api.provider import Personality
|
||||
|
||||
__all__ = ["Personality"]
|
||||
@@ -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",
|
||||
]
|
||||
@@ -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"]
|
||||
@@ -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"]
|
||||
@@ -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",
|
||||
]
|
||||
@@ -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",
|
||||
]
|
||||
@@ -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",
|
||||
]
|
||||
@@ -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"]
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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,
|
||||
)
|
||||
@@ -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 []
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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",
|
||||
]
|
||||
@@ -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"]
|
||||
@@ -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"]
|
||||
@@ -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"]
|
||||
@@ -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()
|
||||
@@ -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",
|
||||
]
|
||||
@@ -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
|
||||
@@ -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"]
|
||||
@@ -1,5 +0,0 @@
|
||||
"""旧版 ``Context`` 的兼容导出。"""
|
||||
|
||||
from ..._legacy_api import Context
|
||||
|
||||
__all__ = ["Context"]
|
||||
@@ -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)
|
||||
@@ -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,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",
|
||||
]
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
这里仅暴露相对稳定的运行时构件:协议 `Peer`、传输抽象以及能力/处理器分发器。
|
||||
大多数插件作者应优先使用顶层 `astrbot_sdk`。
|
||||
`astrbot_sdk.api` 仅用于旧插件过渡迁移,不建议新代码依赖。
|
||||
|
||||
`loader` / `bootstrap` 等编排细节保留在各自子模块中,不作为根级稳定契约。
|
||||
"""
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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 +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()
|
||||
@@ -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 系统的深层能力"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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()
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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"
|
||||
@@ -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
@@ -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()
|
||||
@@ -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"),
|
||||
)
|
||||
@@ -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
Reference in New Issue
Block a user