diff --git a/.claude/settings.local.json b/.claude/settings.local.json new file mode 100644 index 000000000..2b2cceab5 --- /dev/null +++ b/.claude/settings.local.json @@ -0,0 +1,8 @@ +{ + "permissions": { + "allow": [ + "Bash(cd:*)", + "Bash(python:*)" + ] + } +} diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 521b636c1..a2acd72d0 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -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 兼容层 diff --git a/CLAUDE.md b/CLAUDE.md index bed121ef8..ff7edb1f7 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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. diff --git a/src-new/astrbot/__init__.py b/src-new/astrbot/__init__.py deleted file mode 100644 index 14ae71511..000000000 --- a/src-new/astrbot/__init__.py +++ /dev/null @@ -1,7 +0,0 @@ -"""旧版 ``astrbot`` 包名兼容入口。""" - -from loguru import logger - -from . import api, core - -__all__ = ["api", "core", "logger"] diff --git a/src-new/astrbot/api/__init__.py b/src-new/astrbot/api/__init__.py deleted file mode 100644 index 498376297..000000000 --- a/src-new/astrbot/api/__init__.py +++ /dev/null @@ -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", -] diff --git a/src-new/astrbot/api/all.py b/src-new/astrbot/api/all.py deleted file mode 100644 index 87fcc3cac..000000000 --- a/src-new/astrbot/api/all.py +++ /dev/null @@ -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", -] diff --git a/src-new/astrbot/api/components/__init__.py b/src-new/astrbot/api/components/__init__.py deleted file mode 100644 index 08ed90ecf..000000000 --- a/src-new/astrbot/api/components/__init__.py +++ /dev/null @@ -1,5 +0,0 @@ -"""旧版 ``astrbot.api.components`` 导入路径兼容入口。""" - -from astrbot_sdk.api.components.command import CommandComponent - -__all__ = ["CommandComponent"] diff --git a/src-new/astrbot/api/components/command.py b/src-new/astrbot/api/components/command.py deleted file mode 100644 index 82ad6f8d7..000000000 --- a/src-new/astrbot/api/components/command.py +++ /dev/null @@ -1,5 +0,0 @@ -"""旧版 ``astrbot.api.components.command`` 导入路径兼容入口。""" - -from astrbot_sdk.api.components.command import CommandComponent - -__all__ = ["CommandComponent"] diff --git a/src-new/astrbot/api/event/__init__.py b/src-new/astrbot/api/event/__init__.py deleted file mode 100644 index ee4be7086..000000000 --- a/src-new/astrbot/api/event/__init__.py +++ /dev/null @@ -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", -] diff --git a/src-new/astrbot/api/event/filter/__init__.py b/src-new/astrbot/api/event/filter/__init__.py deleted file mode 100644 index dfda3b922..000000000 --- a/src-new/astrbot/api/event/filter/__init__.py +++ /dev/null @@ -1,3 +0,0 @@ -"""旧版 ``astrbot.api.event.filter`` 导入路径兼容入口。""" - -from astrbot_sdk.api.event.filter import * # noqa: F403 diff --git a/src-new/astrbot/api/message_components.py b/src-new/astrbot/api/message_components.py deleted file mode 100644 index bcf68e05d..000000000 --- a/src-new/astrbot/api/message_components.py +++ /dev/null @@ -1,3 +0,0 @@ -"""旧版 ``astrbot.api.message_components`` 导入路径兼容入口。""" - -from astrbot_sdk.api.message_components import * # noqa: F403 diff --git a/src-new/astrbot/api/platform/__init__.py b/src-new/astrbot/api/platform/__init__.py deleted file mode 100644 index b3689ebae..000000000 --- a/src-new/astrbot/api/platform/__init__.py +++ /dev/null @@ -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", -] diff --git a/src-new/astrbot/api/provider/__init__.py b/src-new/astrbot/api/provider/__init__.py deleted file mode 100644 index 08c1a353d..000000000 --- a/src-new/astrbot/api/provider/__init__.py +++ /dev/null @@ -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", -] diff --git a/src-new/astrbot/api/star/__init__.py b/src-new/astrbot/api/star/__init__.py deleted file mode 100644 index 561d5b840..000000000 --- a/src-new/astrbot/api/star/__init__.py +++ /dev/null @@ -1,5 +0,0 @@ -"""旧版 ``astrbot.api.star`` 导入路径兼容入口。""" - -from astrbot_sdk.api.star import Context, Star, StarMetadata, StarTools, register - -__all__ = ["Context", "Star", "StarMetadata", "StarTools", "register"] diff --git a/src-new/astrbot/api/star/context.py b/src-new/astrbot/api/star/context.py deleted file mode 100644 index 439cf806e..000000000 --- a/src-new/astrbot/api/star/context.py +++ /dev/null @@ -1,5 +0,0 @@ -"""旧版 ``astrbot.api.star.context`` 导入路径兼容入口。""" - -from astrbot_sdk.api.star.context import Context - -__all__ = ["Context"] diff --git a/src-new/astrbot/api/util/__init__.py b/src-new/astrbot/api/util/__init__.py deleted file mode 100644 index 6d473d8e1..000000000 --- a/src-new/astrbot/api/util/__init__.py +++ /dev/null @@ -1,9 +0,0 @@ -"""旧版 ``astrbot.api.util`` 导入路径兼容入口。""" - -from astrbot.core.utils.session_waiter import ( - SessionController, - SessionWaiter, - session_waiter, -) - -__all__ = ["SessionController", "SessionWaiter", "session_waiter"] diff --git a/src-new/astrbot/core/__init__.py b/src-new/astrbot/core/__init__.py deleted file mode 100644 index aabde13a5..000000000 --- a/src-new/astrbot/core/__init__.py +++ /dev/null @@ -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", -] diff --git a/src-new/astrbot/core/agent/__init__.py b/src-new/astrbot/core/agent/__init__.py deleted file mode 100644 index 090dd6469..000000000 --- a/src-new/astrbot/core/agent/__init__.py +++ /dev/null @@ -1,5 +0,0 @@ -"""旧版 ``astrbot.core.agent`` 兼容入口。""" - -from .message import TextPart - -__all__ = ["TextPart"] diff --git a/src-new/astrbot/core/agent/message.py b/src-new/astrbot/core/agent/message.py deleted file mode 100644 index 4f0fe8e9e..000000000 --- a/src-new/astrbot/core/agent/message.py +++ /dev/null @@ -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"] diff --git a/src-new/astrbot/core/config/__init__.py b/src-new/astrbot/core/config/__init__.py deleted file mode 100644 index 7307cbd04..000000000 --- a/src-new/astrbot/core/config/__init__.py +++ /dev/null @@ -1,5 +0,0 @@ -"""旧版 ``astrbot.core.config`` 导入路径兼容入口。""" - -from astrbot_sdk.api.basic import AstrBotConfig - -__all__ = ["AstrBotConfig"] diff --git a/src-new/astrbot/core/config/astrbot_config.py b/src-new/astrbot/core/config/astrbot_config.py deleted file mode 100644 index 79b4b05da..000000000 --- a/src-new/astrbot/core/config/astrbot_config.py +++ /dev/null @@ -1,5 +0,0 @@ -"""旧版 ``astrbot.core.config.astrbot_config`` 导入路径兼容入口。""" - -from astrbot_sdk.api.basic import AstrBotConfig - -__all__ = ["AstrBotConfig"] diff --git a/src-new/astrbot/core/db/__init__.py b/src-new/astrbot/core/db/__init__.py deleted file mode 100644 index 16f5418f0..000000000 --- a/src-new/astrbot/core/db/__init__.py +++ /dev/null @@ -1,5 +0,0 @@ -"""旧版 ``astrbot.core.db`` 兼容入口。""" - -from .po import Personality - -__all__ = ["Personality"] diff --git a/src-new/astrbot/core/db/po.py b/src-new/astrbot/core/db/po.py deleted file mode 100644 index c6d69d232..000000000 --- a/src-new/astrbot/core/db/po.py +++ /dev/null @@ -1,5 +0,0 @@ -"""旧版 ``astrbot.core.db.po`` 兼容入口。""" - -from astrbot.api.provider import Personality - -__all__ = ["Personality"] diff --git a/src-new/astrbot/core/message/__init__.py b/src-new/astrbot/core/message/__init__.py deleted file mode 100644 index 8069fe464..000000000 --- a/src-new/astrbot/core/message/__init__.py +++ /dev/null @@ -1,4 +0,0 @@ -"""旧版 ``astrbot.core.message`` 导入路径兼容入口。""" - -from .components import * # noqa: F403 -from .message_event_result import * # noqa: F403 diff --git a/src-new/astrbot/core/message/components.py b/src-new/astrbot/core/message/components.py deleted file mode 100644 index 005c08301..000000000 --- a/src-new/astrbot/core/message/components.py +++ /dev/null @@ -1,3 +0,0 @@ -"""旧版 ``astrbot.core.message.components`` 导入路径兼容入口。""" - -from astrbot_sdk.api.message.components import * # noqa: F403 diff --git a/src-new/astrbot/core/message/message_event_result.py b/src-new/astrbot/core/message/message_event_result.py deleted file mode 100644 index c445d10f5..000000000 --- a/src-new/astrbot/core/message/message_event_result.py +++ /dev/null @@ -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", -] diff --git a/src-new/astrbot/core/platform/__init__.py b/src-new/astrbot/core/platform/__init__.py deleted file mode 100644 index f378a67f2..000000000 --- a/src-new/astrbot/core/platform/__init__.py +++ /dev/null @@ -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", -] diff --git a/src-new/astrbot/core/platform/astr_message_event.py b/src-new/astrbot/core/platform/astr_message_event.py deleted file mode 100644 index f50c674ca..000000000 --- a/src-new/astrbot/core/platform/astr_message_event.py +++ /dev/null @@ -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", -] diff --git a/src-new/astrbot/core/platform/astrbot_message.py b/src-new/astrbot/core/platform/astrbot_message.py deleted file mode 100644 index 08d071fdc..000000000 --- a/src-new/astrbot/core/platform/astrbot_message.py +++ /dev/null @@ -1,5 +0,0 @@ -"""旧版 ``astrbot.core.platform.astrbot_message`` 导入路径兼容入口。""" - -from astrbot_sdk.api.event import AstrBotMessage, Group, MessageMember, MessageType - -__all__ = ["AstrBotMessage", "Group", "MessageMember", "MessageType"] diff --git a/src-new/astrbot/core/platform/message_type.py b/src-new/astrbot/core/platform/message_type.py deleted file mode 100644 index b0f6227e7..000000000 --- a/src-new/astrbot/core/platform/message_type.py +++ /dev/null @@ -1,5 +0,0 @@ -"""旧版 ``astrbot.core.platform.message_type`` 导入路径兼容入口。""" - -from astrbot_sdk.api.event import MessageType - -__all__ = ["MessageType"] diff --git a/src-new/astrbot/core/platform/platform.py b/src-new/astrbot/core/platform/platform.py deleted file mode 100644 index ef9e219bd..000000000 --- a/src-new/astrbot/core/platform/platform.py +++ /dev/null @@ -1,8 +0,0 @@ -"""旧版 ``astrbot.core.platform.platform`` 导入路径兼容入口。""" - - -class Platform: - """旧版平台适配器基类占位。""" - - -__all__ = ["Platform"] diff --git a/src-new/astrbot/core/platform/platform_metadata.py b/src-new/astrbot/core/platform/platform_metadata.py deleted file mode 100644 index 5f871ce7f..000000000 --- a/src-new/astrbot/core/platform/platform_metadata.py +++ /dev/null @@ -1,5 +0,0 @@ -"""旧版 ``astrbot.core.platform.platform_metadata`` 导入路径兼容入口。""" - -from astrbot_sdk.api.platform import PlatformMetadata - -__all__ = ["PlatformMetadata"] diff --git a/src-new/astrbot/core/platform/register.py b/src-new/astrbot/core/platform/register.py deleted file mode 100644 index ba31e0e84..000000000 --- a/src-new/astrbot/core/platform/register.py +++ /dev/null @@ -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"] diff --git a/src-new/astrbot/core/platform/sources/__init__.py b/src-new/astrbot/core/platform/sources/__init__.py deleted file mode 100644 index bfa63f2e4..000000000 --- a/src-new/astrbot/core/platform/sources/__init__.py +++ /dev/null @@ -1 +0,0 @@ -"""旧版 ``astrbot.core.platform.sources`` 导入路径兼容入口。""" diff --git a/src-new/astrbot/core/platform/sources/aiocqhttp/__init__.py b/src-new/astrbot/core/platform/sources/aiocqhttp/__init__.py deleted file mode 100644 index 5ff189e27..000000000 --- a/src-new/astrbot/core/platform/sources/aiocqhttp/__init__.py +++ /dev/null @@ -1,5 +0,0 @@ -"""旧版 ``astrbot.core.platform.sources.aiocqhttp`` 导入路径兼容入口。""" - -from .aiocqhttp_message_event import AiocqhttpMessageEvent - -__all__ = ["AiocqhttpMessageEvent"] diff --git a/src-new/astrbot/core/platform/sources/aiocqhttp/aiocqhttp_message_event.py b/src-new/astrbot/core/platform/sources/aiocqhttp/aiocqhttp_message_event.py deleted file mode 100644 index 54a68bf43..000000000 --- a/src-new/astrbot/core/platform/sources/aiocqhttp/aiocqhttp_message_event.py +++ /dev/null @@ -1,7 +0,0 @@ -"""旧版 aiocqhttp 事件类型的最小导入兼容入口。""" - -from astrbot.core.platform.astr_message_event import AstrMessageEvent - -AiocqhttpMessageEvent = AstrMessageEvent - -__all__ = ["AiocqhttpMessageEvent"] diff --git a/src-new/astrbot/core/provider/__init__.py b/src-new/astrbot/core/provider/__init__.py deleted file mode 100644 index 7657e5f95..000000000 --- a/src-new/astrbot/core/provider/__init__.py +++ /dev/null @@ -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", -] diff --git a/src-new/astrbot/core/provider/entities.py b/src-new/astrbot/core/provider/entities.py deleted file mode 100644 index e9df85caf..000000000 --- a/src-new/astrbot/core/provider/entities.py +++ /dev/null @@ -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", -] diff --git a/src-new/astrbot/core/provider/provider.py b/src-new/astrbot/core/provider/provider.py deleted file mode 100644 index 40a348da5..000000000 --- a/src-new/astrbot/core/provider/provider.py +++ /dev/null @@ -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", -] diff --git a/src-new/astrbot/core/utils/__init__.py b/src-new/astrbot/core/utils/__init__.py deleted file mode 100644 index 2a7cba0fd..000000000 --- a/src-new/astrbot/core/utils/__init__.py +++ /dev/null @@ -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", -] diff --git a/src-new/astrbot/core/utils/astrbot_path.py b/src-new/astrbot/core/utils/astrbot_path.py deleted file mode 100644 index 7d7dfeca7..000000000 --- a/src-new/astrbot/core/utils/astrbot_path.py +++ /dev/null @@ -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 层保持 - 这个约定,方便旧插件继续把数据写到 ``/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") diff --git a/src-new/astrbot/core/utils/session_waiter.py b/src-new/astrbot/core/utils/session_waiter.py deleted file mode 100644 index 057986bb5..000000000 --- a/src-new/astrbot/core/utils/session_waiter.py +++ /dev/null @@ -1,5 +0,0 @@ -"""旧版 ``astrbot.core.utils.session_waiter`` 导入路径兼容入口。""" - -from astrbot_sdk._session_waiter import SessionController, SessionWaiter, session_waiter - -__all__ = ["SessionController", "SessionWaiter", "session_waiter"] diff --git a/src-new/astrbot_sdk/__init__.py b/src-new/astrbot_sdk/__init__.py index f0befb161..e75c28567 100644 --- a/src-new/astrbot_sdk/__init__.py +++ b/src-new/astrbot_sdk/__init__.py @@ -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 diff --git a/src-new/astrbot_sdk/_legacy_api.py b/src-new/astrbot_sdk/_legacy_api.py deleted file mode 100644 index 4eb4a0e3f..000000000 --- a/src-new/astrbot_sdk/_legacy_api.py +++ /dev/null @@ -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", -] diff --git a/src-new/astrbot_sdk/_legacy_context.py b/src-new/astrbot_sdk/_legacy_context.py deleted file mode 100644 index 3f2330317..000000000 --- a/src-new/astrbot_sdk/_legacy_context.py +++ /dev/null @@ -1,1014 +0,0 @@ -"""旧版 API 兼容层 — 会话管理与 Context 实现。 - -这个模块承接旧 ``Context`` / ``ConversationManager`` 的运行时行为, -把仍然可映射到 v4 的能力落到 v4 ``Context`` 客户端上, -无法等价支持的旧接口则显式给出迁移错误,而不是静默降级。 - -不要从 ``_legacy_star`` 导入任何符号(避免循环依赖)。 -外部代码应通过 ``_legacy_api`` 聚合入口导入。 -""" - -from __future__ import annotations - -import inspect -import json -from collections import defaultdict -from collections.abc import Callable -from dataclasses import dataclass -from typing import TYPE_CHECKING, Any - -from loguru import logger - -from ._legacy_llm import ( - CompatLLMToolManager, - _CompatProviderRequest, - _legacy_llm_response, - _tool_parameters_from_legacy_args, -) -from .context import Context as NewContext - -if TYPE_CHECKING: - from .api.provider.entities import LLMResponse - -MIGRATION_DOC_URL = "https://docs.astrbot.app/migration/v3" -COMPAT_CONVERSATIONS_KEY = "__compat_conversations__" -_warned_methods: set[str] = set() - - -def _warn_once(old_name: str, replacement: str) -> None: - if old_name in _warned_methods: - return - _warned_methods.add(old_name) - logger.warning( - "[AstrBot] 警告:{} 已过时。请替换为:{}\n迁移文档:{}", - old_name, - replacement, - MIGRATION_DOC_URL, - ) - - -def _iter_registered_component_methods( - component: Any, -) -> list[tuple[str, Callable[..., Any]]]: - methods: list[tuple[str, Callable[..., Any]]] = [] - for attr_name, static_attr in inspect.getmembers_static(component): - if attr_name.startswith("_") or isinstance(static_attr, property): - continue - if not callable(static_attr) and not isinstance( - static_attr, (staticmethod, classmethod) - ): - continue - try: - bound_attr = getattr(component, attr_name) - except Exception: - continue - if callable(bound_attr): - methods.append((attr_name, bound_attr)) - return methods - - -@dataclass(slots=True) -class _CompatHookEntry: - name: str - priority: int - handler: Callable[..., Any] - - -class LegacyConversationManager: - """旧版会话管理器的兼容实现。 - - 会话数据通过 ``ctx.db`` 存在统一 key 下。 - 数据是否持久化取决于当前 db capability 的后端实现,而不是 compat 层本身。 - """ - - __compat_component_name__ = "ConversationManager" - - def __init__(self, parent: "LegacyContext") -> None: - self._parent = parent - self._counters: defaultdict[str, int] = defaultdict(int) - # 记录每个 unified_msg_origin 的当前会话 ID - self._current_conversations: dict[str, str] = {} - - def _ctx(self) -> NewContext: - return self._parent.require_runtime_context() - - async def _get_stored(self) -> dict[str, dict[str, Any]]: - """获取存储的所有会话数据。""" - ctx = self._ctx() - stored = await ctx.db.get(COMPAT_CONVERSATIONS_KEY) - return stored if isinstance(stored, dict) else {} - - async def _set_stored(self, stored: dict[str, dict[str, Any]]) -> None: - """保存会话数据。""" - ctx = self._ctx() - await ctx.db.set(COMPAT_CONVERSATIONS_KEY, stored) - - async def new_conversation( - self, - unified_msg_origin: str, - platform_id: str | None = None, - content: list[dict] | None = None, - title: str | None = None, - persona_id: str | None = None, - ) -> str: - """创建新会话并返回会话 ID。""" - ctx = self._ctx() - stored = await self._get_stored() - next_counter = self._counters[unified_msg_origin] - while True: - next_counter += 1 - conversation_id = f"{ctx.plugin_id}-conv-{next_counter}" - if conversation_id not in stored: - break - self._counters[unified_msg_origin] = next_counter - stored[conversation_id] = { - "unified_msg_origin": unified_msg_origin, - "platform_id": platform_id, - "content": content or [], - "title": title, - "persona_id": persona_id, - } - await self._set_stored(stored) - # 设置为当前会话 - self._current_conversations[unified_msg_origin] = conversation_id - return conversation_id - - async def switch_conversation( - self, unified_msg_origin: str, conversation_id: str - ) -> None: - """切换到指定会话。 - - Args: - unified_msg_origin: 统一消息来源 - conversation_id: 要切换到的会话 ID - """ - stored = await self._get_stored() - if conversation_id not in stored: - return - # 验证会话属于该 unified_msg_origin - conv_data = stored[conversation_id] - if conv_data.get("unified_msg_origin") != unified_msg_origin: - return - self._current_conversations[unified_msg_origin] = conversation_id - - async def delete_conversation( - self, - unified_msg_origin: str, - conversation_id: str | None = None, - ) -> None: - """删除指定会话。 - - 当 conversation_id 为 None 时,删除当前会话。 - - Args: - unified_msg_origin: 统一消息来源 - conversation_id: 要删除的会话 ID,为 None 时删除当前会话 - """ - # 如果 conversation_id 为 None,使用当前会话 - if conversation_id is None: - conversation_id = self._current_conversations.get(unified_msg_origin) - if conversation_id is None: - return - - stored = await self._get_stored() - if conversation_id not in stored: - return - conv_data = stored[conversation_id] - if conv_data.get("unified_msg_origin") != unified_msg_origin: - return - del stored[conversation_id] - await self._set_stored(stored) - # 如果删除的是当前会话,清除当前会话记录 - if self._current_conversations.get(unified_msg_origin) == conversation_id: - del self._current_conversations[unified_msg_origin] - - async def get_curr_conversation_id(self, unified_msg_origin: str) -> str | None: - """获取当前会话 ID。 - - Args: - unified_msg_origin: 统一消息来源 - - Returns: - 当前会话 ID,若无则返回 None - """ - return self._current_conversations.get(unified_msg_origin) - - async def get_conversation( - self, - unified_msg_origin: str, - conversation_id: str, - create_if_not_exists: bool = False, - ) -> dict[str, Any] | None: - """获取指定会话的数据。 - - Args: - unified_msg_origin: 统一消息来源 - conversation_id: 会话 ID - create_if_not_exists: 如果会话不存在,是否创建新会话 - - Returns: - 会话数据字典,不存在则返回 None - """ - stored = await self._get_stored() - conv = stored.get(conversation_id) - if conv is None and create_if_not_exists: - # 创建新会话 - conv = { - "unified_msg_origin": unified_msg_origin, - "platform_id": None, - "content": [], - "title": None, - "persona_id": None, - } - stored[conversation_id] = conv - await self._set_stored(stored) - self._current_conversations[unified_msg_origin] = conversation_id - return conv - - async def get_conversations( - self, - unified_msg_origin: str | None = None, - platform_id: str | None = None, - ) -> list[dict[str, Any]]: - """获取会话列表。 - - Args: - unified_msg_origin: 统一消息来源,可选 - platform_id: 平台 ID,可选 - - Returns: - 会话列表,每个元素包含 conversation_id 和会话数据 - """ - stored = await self._get_stored() - result = [] - for conv_id, conv_data in stored.items(): - # 按 unified_msg_origin 过滤 - if unified_msg_origin is not None: - if conv_data.get("unified_msg_origin") != unified_msg_origin: - continue - # 按 platform_id 过滤 - if platform_id is not None: - if conv_data.get("platform_id") != platform_id: - continue - result.append({"conversation_id": conv_id, **conv_data}) - return result - - async def update_conversation( - self, - unified_msg_origin: str, - conversation_id: str | None = None, - history: list[dict] | None = None, - title: str | None = None, - persona_id: str | None = None, - ) -> None: - """更新会话数据。 - - Args: - unified_msg_origin: 统一消息来源 - conversation_id: 会话 ID,为 None 时更新当前会话 - history: 对话历史记录 - title: 会话标题 - persona_id: Persona ID - """ - # 如果 conversation_id 为 None,使用当前会话 - if conversation_id is None: - conversation_id = self._current_conversations.get(unified_msg_origin) - if conversation_id is None: - return - - stored = await self._get_stored() - if conversation_id not in stored: - return - - updates: dict[str, Any] = {} - if history is not None: - updates["content"] = history - if title is not None: - updates["title"] = title - if persona_id is not None: - updates["persona_id"] = persona_id - - stored[conversation_id].update(updates) - await self._set_stored(stored) - - async def delete_conversations_by_user_id(self, unified_msg_origin: str) -> None: - """删除指定用户的所有会话。 - - Args: - unified_msg_origin: 统一消息来源 - """ - stored = await self._get_stored() - to_delete = [ - conv_id - for conv_id, conv_data in stored.items() - if conv_data.get("unified_msg_origin") == unified_msg_origin - ] - for conv_id in to_delete: - del stored[conv_id] - await self._set_stored(stored) - # 清除当前会话记录 - if unified_msg_origin in self._current_conversations: - del self._current_conversations[unified_msg_origin] - - async def add_message_pair( - self, - cid: str, - user_message: str | dict, - assistant_message: str | dict, - ) -> None: - """向会话添加消息对。 - - Args: - cid: 会话 ID - user_message: 用户消息 - assistant_message: 助手消息 - """ - stored = await self._get_stored() - if cid not in stored: - return - content = stored[cid].get("content", []) - # 处理消息格式 - user_msg = ( - user_message - if isinstance(user_message, dict) - else {"role": "user", "content": user_message} - ) - assistant_msg = ( - assistant_message - if isinstance(assistant_message, dict) - else {"role": "assistant", "content": assistant_message} - ) - content.append(user_msg) - content.append(assistant_msg) - stored[cid]["content"] = content - await self._set_stored(stored) - - async def update_conversation_title( - self, - unified_msg_origin: str, - title: str, - conversation_id: str | None = None, - ) -> None: - """更新会话标题。 - - Args: - unified_msg_origin: 统一消息来源 - title: 会话标题 - conversation_id: 会话 ID,为 None 时更新当前会话 - - Deprecated: - 请使用 update_conversation() 的 title 参数。 - """ - await self.update_conversation(unified_msg_origin, conversation_id, title=title) - - async def update_conversation_persona_id( - self, - unified_msg_origin: str, - persona_id: str, - conversation_id: str | None = None, - ) -> None: - """更新会话 Persona ID。 - - Args: - unified_msg_origin: 统一消息来源 - persona_id: Persona ID - conversation_id: 会话 ID,为 None 时更新当前会话 - - Deprecated: - 请使用 update_conversation() 的 persona_id 参数。 - """ - await self.update_conversation( - unified_msg_origin, conversation_id, persona_id=persona_id - ) - - async def get_filtered_conversations(self, *args: Any, **kwargs: Any) -> Any: - """兼容旧版会话过滤接口。""" - unified_msg_origin = kwargs.get("unified_msg_origin") - platform_id = kwargs.get("platform_id") - keyword = kwargs.get("keyword") or kwargs.get("query") - conversations = await self.get_conversations( - unified_msg_origin=unified_msg_origin, - platform_id=platform_id, - ) - if not isinstance(keyword, str) or not keyword: - return conversations - filtered: list[dict[str, Any]] = [] - for conversation in conversations: - haystack = json.dumps(conversation, ensure_ascii=False) - if keyword in haystack: - filtered.append(conversation) - return filtered - - async def get_human_readable_context(self, *args: Any, **kwargs: Any) -> Any: - """把兼容会话内容格式化为可读文本。""" - unified_msg_origin = kwargs.get("unified_msg_origin") - conversation_id = kwargs.get("conversation_id") - if conversation_id is None and isinstance(unified_msg_origin, str): - conversation_id = await self.get_curr_conversation_id(unified_msg_origin) - if not isinstance(conversation_id, str) or not conversation_id: - return "" - conversation = await self.get_conversation( - unified_msg_origin or "", - conversation_id, - create_if_not_exists=False, - ) - if not isinstance(conversation, dict): - return "" - lines: list[str] = [] - for item in conversation.get("content", []): - if not isinstance(item, dict): - continue - role = str(item.get("role") or "unknown") - content = item.get("content") - if isinstance(content, list): - rendered = json.dumps(content, ensure_ascii=False) - else: - rendered = str(content or "") - lines.append(f"{role}: {rendered}".rstrip()) - return "\n".join(lines) - - -class LegacyContext: - """旧版 ``Context`` 的兼容外观。""" - - def __init__(self, plugin_id: str) -> None: - self.plugin_id = plugin_id - self._runtime_context: NewContext | None = None - self._registered_managers: dict[str, Any] = {} - self._registered_functions: dict[str, Callable[..., Any]] = {} - self._compat_hooks: defaultdict[str, list[_CompatHookEntry]] = defaultdict(list) - self._llm_tools = CompatLLMToolManager() - self.conversation_manager = LegacyConversationManager(self) - self._register_component(self.conversation_manager) - - def bind_runtime_context(self, runtime_context: NewContext) -> None: - self._runtime_context = runtime_context - - def require_runtime_context(self) -> NewContext: - if self._runtime_context is None: - raise RuntimeError("LegacyContext 尚未绑定运行时 Context") - return self._runtime_context - - def get_llm_tool_manager(self) -> CompatLLMToolManager: - return self._llm_tools - - def activate_llm_tool(self, name: str) -> bool: - return self._llm_tools.activate_llm_tool(name) - - def deactivate_llm_tool(self, name: str) -> bool: - return self._llm_tools.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._llm_tools.add_func(name, func_args, desc, func_obj) - - def unregister_llm_tool(self, name: str) -> None: - self._llm_tools.remove_func(name) - - def get_config(self) -> dict[str, Any]: - runtime_context = self._runtime_context - if runtime_context is None: - return {} - config = getattr(runtime_context, "_astrbot_config", None) - return dict(config) if isinstance(config, dict) else {} - - def _runtime_config(self) -> Any: - from .api.basic.astrbot_config import AstrBotConfig - - runtime_context = self._runtime_context - config = ( - getattr(runtime_context, "_astrbot_config", None) - if runtime_context - else None - ) - if isinstance(config, AstrBotConfig): - return config - if isinstance(config, dict): - return AstrBotConfig(dict(config)) - return AstrBotConfig({}) - - @staticmethod - def _merge_llm_kwargs( - *, - chat_provider_id: str, - kwargs: dict[str, Any], - ) -> dict[str, Any]: - merged = dict(kwargs) - if chat_provider_id: - merged.setdefault("provider_id", chat_provider_id) - return merged - - @staticmethod - def _apply_request_overrides( - call_kwargs: dict[str, Any], - request: _CompatProviderRequest, - ) -> dict[str, Any]: - updated = dict(call_kwargs) - if request.model: - updated["model"] = request.model - return updated - - @staticmethod - def _component_names(component: Any) -> list[str]: - names = [component.__class__.__name__] - compat_name = getattr(component, "__compat_component_name__", None) - if isinstance(compat_name, str) and compat_name and compat_name not in names: - names.insert(0, compat_name) - return names - - def _register_hook( - self, - name: str, - handler: Callable[..., Any], - *, - priority: int = 0, - ) -> None: - self._compat_hooks[name].append( - _CompatHookEntry(name=name, priority=priority, handler=handler) - ) - self._compat_hooks[name].sort(key=lambda item: item.priority, reverse=True) - - def _register_compat_component(self, component: Any) -> None: - from .api.event.filter import ( - get_compat_hook_metas, - get_compat_llm_tool_meta, - ) - - for _attr_name, attr in _iter_registered_component_methods(component): - tool_meta = get_compat_llm_tool_meta(attr) - if tool_meta is not None: - self._llm_tools.add_tool( - name=tool_meta.name, - description=tool_meta.description, - parameters=_tool_parameters_from_legacy_args(tool_meta.parameters), - handler=attr, - ) - for hook_meta in get_compat_hook_metas(attr): - self._register_hook( - hook_meta.name, - attr, - priority=hook_meta.priority, - ) - - @staticmethod - def _legacy_event(event: Any | None): - if event is None: - return None - from .api.event.astr_message_event import AstrMessageEvent - - if isinstance(event, AstrMessageEvent): - return event - return AstrMessageEvent.from_message_event(event) - - @staticmethod - def _hook_type_injection( - annotation: Any, - available: dict[str, Any], - ) -> Any: - from .api.event.astr_message_event import AstrMessageEvent - from .api.provider.entities import LLMResponse - from .context import Context as RuntimeContext - - if annotation is Any or annotation is inspect.Signature.empty: - return None - if annotation is AstrMessageEvent: - return available.get("event") - if annotation is RuntimeContext or annotation is NewContext: - return available.get("context") - if annotation is LegacyContext: - return available.get("legacy_context") - if annotation is LLMResponse: - return available.get("response") - return None - - async def _call_with_available( - self, - handler: Callable[..., Any], - available: dict[str, Any], - ) -> Any: - signature = inspect.signature(handler) - args: list[Any] = [] - kwargs: dict[str, Any] = {} - for parameter in signature.parameters.values(): - injected = None - if parameter.name in available: - injected = available[parameter.name] - else: - injected = self._hook_type_injection(parameter.annotation, available) - if injected is None: - if parameter.default is not parameter.empty: - continue - continue - if parameter.kind in ( - inspect.Parameter.POSITIONAL_ONLY, - inspect.Parameter.POSITIONAL_OR_KEYWORD, - ): - args.append(injected) - elif parameter.kind == inspect.Parameter.KEYWORD_ONLY: - kwargs[parameter.name] = injected - result = handler(*args, **kwargs) - if inspect.isasyncgen(result): - final_value = None - async for item in result: - final_value = item - await self._consume_tool_result( - available.get("event"), - available.get("context"), - item, - ) - return final_value - if inspect.isawaitable(result): - return await result - return result - - async def _run_compat_hook( - self, - name: str, - **available: Any, - ) -> list[Any]: - hook_results: list[Any] = [] - for entry in self._compat_hooks.get(name, []): - hook_results.append( - await self._call_with_available(entry.handler, available) - ) - return hook_results - - async def _consume_tool_result( - self, - event: Any | None, - runtime_context: NewContext | None, - item: Any, - ) -> None: - if event is None: - return - from .api.event.event_result import MessageEventResult - from .api.message.chain import MessageChain - - legacy_event = self._legacy_event(event) - if legacy_event is None: - return - if isinstance(item, MessageEventResult): - if ( - item.chain - and runtime_context is not None - and not item.is_plain_text_only() - ): - await runtime_context.platform.send_chain( - legacy_event.session_ref or legacy_event.session_id, - item.to_payload(), - ) - return - plain_text = item.get_plain_text() - if plain_text: - await legacy_event.reply(plain_text) - return - if isinstance(item, MessageChain): - if ( - item.chain - and runtime_context is not None - and not item.is_plain_text_only() - ): - await runtime_context.platform.send_chain( - legacy_event.session_ref or legacy_event.session_id, - item.to_payload(), - ) - return - plain_text = item.get_plain_text() - if plain_text: - await legacy_event.reply(plain_text) - return - if isinstance(item, str): - await legacy_event.reply(item) - - async def _invoke_llm_tool( - self, - *, - tool_name: str, - tool_args: dict[str, Any], - event: Any | None, - ) -> str: - tool = self._llm_tools.get_func(tool_name) - if tool is None or not tool.active: - return f"tool '{tool_name}' not found" - legacy_event = self._legacy_event(event) - runtime_context = self.require_runtime_context() - await self._run_compat_hook( - "on_using_llm_tool", - event=legacy_event, - context=runtime_context, - legacy_context=self, - tool=tool, - tool_args=tool_args, - ) - tool_result = await self._call_with_available( - tool.handler, - { - **tool_args, - "event": legacy_event, - "context": runtime_context, - "ctx": runtime_context, - "legacy_context": self, - }, - ) - if isinstance(tool_result, str): - normalized = tool_result - elif tool_result is None: - normalized = "" - else: - normalized = str(tool_result) - await self._run_compat_hook( - "on_llm_tool_respond", - event=legacy_event, - context=runtime_context, - legacy_context=self, - tool=tool, - tool_args=tool_args, - tool_result=normalized, - ) - return normalized - - def _register_component(self, *components: Any) -> None: - """保留旧版按名称暴露组件方法的兼容链路。""" - for component in components: - for class_name in self._component_names(component): - self._registered_managers[class_name] = component - for attr_name, attr in _iter_registered_component_methods(component): - self._registered_functions[f"{class_name}.{attr_name}"] = attr - self._register_compat_component(component) - - async def execute_registered_function( - self, - func_full_name: str, - args: dict[str, Any] | None = None, - ) -> Any: - if args is None: - call_args: dict[str, Any] = {} - elif isinstance(args, dict): - call_args = args - else: - raise TypeError("LegacyContext 调用参数必须是 dict") - - func = self._registered_functions.get(func_full_name) - if func is None: - raise ValueError(f"Function not found: {func_full_name}") - - result = func(**call_args) - if inspect.isawaitable(result): - return await result - return result - - async def call_context_function( - self, - func_full_name: str, - args: dict[str, Any] | None = None, - ) -> dict[str, Any]: - return { - "data": await self.execute_registered_function(func_full_name, args), - } - - async def llm_generate( - self, - chat_provider_id: str, - prompt: str | None = None, - image_urls: list[str] | None = None, - tools: Any | None = None, - system_prompt: str | None = None, - contexts: list[dict] | None = None, - event: Any | None = None, - **kwargs: Any, - ) -> LLMResponse: - _warn_once("context.llm_generate()", "ctx.llm.chat_raw(...)") - ctx = self.require_runtime_context() - call_kwargs = self._merge_llm_kwargs( - chat_provider_id=chat_provider_id, - kwargs=kwargs, - ) - legacy_event = self._legacy_event(event) - request = _CompatProviderRequest( - prompt=prompt or "", - session_id=legacy_event.session_id if legacy_event is not None else "", - image_urls=list(image_urls or []), - contexts=list(contexts or []), - system_prompt=system_prompt or "", - model=call_kwargs.get("model"), - ) - await self._run_compat_hook( - "on_waiting_llm_request", - event=legacy_event, - context=ctx, - legacy_context=self, - ) - await self._run_compat_hook( - "on_llm_request", - event=legacy_event, - context=ctx, - legacy_context=self, - request=request, - ) - call_kwargs = self._apply_request_overrides(call_kwargs, request) - response = await ctx.llm.chat_raw( - request.prompt or "", - system=request.system_prompt or None, - history=request.contexts or [], - image_urls=request.image_urls or [], - tools=tools, - **call_kwargs, - ) - legacy_response = _legacy_llm_response(response) - await self._run_compat_hook( - "on_llm_response", - event=legacy_event, - context=ctx, - legacy_context=self, - response=legacy_response, - ) - return legacy_response - - async def tool_loop_agent( - self, - chat_provider_id: str, - prompt: str | None = None, - image_urls: list[str] | None = None, - tools: Any | None = None, - system_prompt: str | None = None, - contexts: list[dict] | None = None, - max_steps: int = 30, - event: Any | None = None, - **kwargs: Any, - ) -> LLMResponse: - from .api.provider.entities import LLMResponse - - _warn_once("context.tool_loop_agent()", "compat local tool loop") - ctx = self.require_runtime_context() - call_kwargs = self._merge_llm_kwargs( - chat_provider_id=chat_provider_id, - kwargs=kwargs, - ) - legacy_event = self._legacy_event(event) - history = list(contexts or []) - request_prompt = prompt or "" - combined_tools = list(self._llm_tools.get_func_desc_openai_style()) - if isinstance(tools, list): - combined_tools.extend(item for item in tools if isinstance(item, dict)) - elif tools is not None: - openai_schema = getattr(tools, "openai_schema", None) - if callable(openai_schema): - extra_tools = openai_schema() - if isinstance(extra_tools, list): - combined_tools.extend( - item for item in extra_tools if isinstance(item, dict) - ) - - final_response = LLMResponse(role="assistant") - for _step in range(max_steps): - request = _CompatProviderRequest( - prompt=request_prompt, - session_id=legacy_event.session_id if legacy_event is not None else "", - image_urls=list(image_urls or []), - contexts=list(history), - system_prompt=system_prompt or "", - model=call_kwargs.get("model"), - ) - await self._run_compat_hook( - "on_waiting_llm_request", - event=legacy_event, - context=ctx, - legacy_context=self, - ) - await self._run_compat_hook( - "on_llm_request", - event=legacy_event, - context=ctx, - legacy_context=self, - request=request, - ) - call_kwargs = self._apply_request_overrides(call_kwargs, request) - response = await ctx.llm.chat_raw( - request.prompt or "", - system=request.system_prompt or None, - history=request.contexts or [], - image_urls=request.image_urls or [], - tools=combined_tools or None, - max_steps=max_steps, - **call_kwargs, - ) - final_response = _legacy_llm_response(response) - await self._run_compat_hook( - "on_llm_response", - event=legacy_event, - context=ctx, - legacy_context=self, - response=final_response, - ) - if not final_response.tools_call_name: - return final_response - - history.append( - { - "role": "assistant", - "content": final_response.completion_text, - "tool_calls": final_response.to_openai_tool_calls(), - } - ) - for tool_name, tool_args, tool_call_id in zip( - final_response.tools_call_name, - final_response.tools_call_args, - final_response.tools_call_ids, - strict=False, - ): - tool_result = await self._invoke_llm_tool( - tool_name=tool_name, - tool_args=tool_args, - event=legacy_event, - ) - history.append( - { - "role": "tool", - "tool_call_id": tool_call_id, - "name": tool_name, - "content": tool_result, - } - ) - request_prompt = "" - - return final_response - - async def send_message(self, session: str, message_chain: Any) -> None: - _warn_once( - "context.send_message()", - "ctx.platform.send(...) / ctx.platform.send_chain(...)", - ) - ctx = self.require_runtime_context() - chain = getattr(message_chain, "chain", None) - to_payload = getattr(message_chain, "to_payload", None) - is_plain_text_only = getattr(message_chain, "is_plain_text_only", None) - if ( - isinstance(chain, list) - and callable(to_payload) - and not (callable(is_plain_text_only) and is_plain_text_only()) - ): - await ctx.platform.send_chain(session, to_payload()) - return - - # 旧版插件也可能传纯文本对象,compat 层保留文本兜底。 - if hasattr(message_chain, "get_plain_text") and callable( - message_chain.get_plain_text - ): - text = message_chain.get_plain_text() - elif hasattr(message_chain, "to_text") and callable(message_chain.to_text): - text = message_chain.to_text() - else: - text = str(message_chain) - await ctx.platform.send(session, text) - - async def add_llm_tools(self, *tools: Any) -> None: - for tool in tools: - name = getattr(tool, "name", None) - if not isinstance(name, str) or not name: - raise TypeError("add_llm_tools() 需要带 name 的工具对象") - handler = getattr(tool, "handler", None) - if not callable(handler): - raise TypeError("add_llm_tools() 需要工具对象提供可调用的 handler") - parameters = getattr(tool, "parameters", None) - if not isinstance(parameters, dict): - func_args = getattr(tool, "func_args", None) - if isinstance(func_args, list): - parameters = _tool_parameters_from_legacy_args(func_args) - else: - parameters = {"type": "object", "properties": {}, "required": []} - description = str(getattr(tool, "description", "") or "") - self._llm_tools.add_tool( - name=name, - description=description, - parameters=parameters, - handler=handler, - ) - - async def put_kv_data(self, key: str, value: Any) -> None: - _warn_once("context.put_kv_data()", "ctx.db.set(key, value)") - ctx = self.require_runtime_context() - await ctx.db.set(key, value) - - async def get_kv_data(self, key: str, default: Any = None) -> Any: - _warn_once("context.get_kv_data()", "ctx.db.get(key)") - ctx = self.require_runtime_context() - value = await ctx.db.get(key) - return default if value is None else value - - async def delete_kv_data(self, key: str) -> None: - _warn_once("context.delete_kv_data()", "ctx.db.delete(key)") - ctx = self.require_runtime_context() - await ctx.db.delete(key) - - async def get_registered_star(self, star_name: str) -> Any: - ctx = self.require_runtime_context() - return await ctx.metadata.get_plugin(star_name) - - async def get_all_stars(self) -> list[Any]: - ctx = self.require_runtime_context() - return await ctx.metadata.list_plugins() diff --git a/src-new/astrbot_sdk/_legacy_llm.py b/src-new/astrbot_sdk/_legacy_llm.py deleted file mode 100644 index ccf0f0324..000000000 --- a/src-new/astrbot_sdk/_legacy_llm.py +++ /dev/null @@ -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, - ) diff --git a/src-new/astrbot_sdk/_legacy_loader.py b/src-new/astrbot_sdk/_legacy_loader.py deleted file mode 100644 index c6a233792..000000000 --- a/src-new/astrbot_sdk/_legacy_loader.py +++ /dev/null @@ -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 [] diff --git a/src-new/astrbot_sdk/_legacy_runtime.py b/src-new/astrbot_sdk/_legacy_runtime.py deleted file mode 100644 index 49ac2c898..000000000 --- a/src-new/astrbot_sdk/_legacy_runtime.py +++ /dev/null @@ -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 diff --git a/src-new/astrbot_sdk/_legacy_star.py b/src-new/astrbot_sdk/_legacy_star.py deleted file mode 100644 index 7a2554be8..000000000 --- a/src-new/astrbot_sdk/_legacy_star.py +++ /dev/null @@ -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 diff --git a/src-new/astrbot_sdk/_session_waiter.py b/src-new/astrbot_sdk/_session_waiter.py deleted file mode 100644 index 2ba85e60b..000000000 --- a/src-new/astrbot_sdk/_session_waiter.py +++ /dev/null @@ -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", -] diff --git a/src-new/astrbot_sdk/_shared_preferences.py b/src-new/astrbot_sdk/_shared_preferences.py deleted file mode 100644 index 75d6fe52c..000000000 --- a/src-new/astrbot_sdk/_shared_preferences.py +++ /dev/null @@ -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"] diff --git a/src-new/astrbot_sdk/api/__init__.py b/src-new/astrbot_sdk/api/__init__.py deleted file mode 100644 index 5589c4694..000000000 --- a/src-new/astrbot_sdk/api/__init__.py +++ /dev/null @@ -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", -] diff --git a/src-new/astrbot_sdk/api/basic/__init__.py b/src-new/astrbot_sdk/api/basic/__init__.py deleted file mode 100644 index 2a6cdcd0d..000000000 --- a/src-new/astrbot_sdk/api/basic/__init__.py +++ /dev/null @@ -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"] diff --git a/src-new/astrbot_sdk/api/basic/astrbot_config.py b/src-new/astrbot_sdk/api/basic/astrbot_config.py deleted file mode 100644 index 0fd6a0c11..000000000 --- a/src-new/astrbot_sdk/api/basic/astrbot_config.py +++ /dev/null @@ -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 diff --git a/src-new/astrbot_sdk/api/basic/conversation_mgr.py b/src-new/astrbot_sdk/api/basic/conversation_mgr.py deleted file mode 100644 index 00fa43c9f..000000000 --- a/src-new/astrbot_sdk/api/basic/conversation_mgr.py +++ /dev/null @@ -1,5 +0,0 @@ -"""旧版会话管理器兼容导出。""" - -from ..._legacy_api import LegacyConversationManager as BaseConversationManager - -__all__ = ["BaseConversationManager"] diff --git a/src-new/astrbot_sdk/api/basic/entities.py b/src-new/astrbot_sdk/api/basic/entities.py deleted file mode 100644 index 7ac4b433c..000000000 --- a/src-new/astrbot_sdk/api/basic/entities.py +++ /dev/null @@ -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 diff --git a/src-new/astrbot_sdk/api/components/__init__.py b/src-new/astrbot_sdk/api/components/__init__.py deleted file mode 100644 index ed0e8a30a..000000000 --- a/src-new/astrbot_sdk/api/components/__init__.py +++ /dev/null @@ -1,5 +0,0 @@ -"""过渡期 ``astrbot_sdk.api.components`` compat facade。""" - -from .command import CommandComponent - -__all__ = ["CommandComponent"] diff --git a/src-new/astrbot_sdk/api/components/command.py b/src-new/astrbot_sdk/api/components/command.py deleted file mode 100644 index 0e4396f71..000000000 --- a/src-new/astrbot_sdk/api/components/command.py +++ /dev/null @@ -1,5 +0,0 @@ -"""旧版 ``CommandComponent`` 的兼容导出。""" - -from ..._legacy_api import CommandComponent - -__all__ = ["CommandComponent"] diff --git a/src-new/astrbot_sdk/api/event/__init__.py b/src-new/astrbot_sdk/api/event/__init__.py deleted file mode 100644 index 1c6d33599..000000000 --- a/src-new/astrbot_sdk/api/event/__init__.py +++ /dev/null @@ -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", -] diff --git a/src-new/astrbot_sdk/api/event/astr_message_event.py b/src-new/astrbot_sdk/api/event/astr_message_event.py deleted file mode 100644 index f0adb87f0..000000000 --- a/src-new/astrbot_sdk/api/event/astr_message_event.py +++ /dev/null @@ -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 diff --git a/src-new/astrbot_sdk/api/event/astrbot_message.py b/src-new/astrbot_sdk/api/event/astrbot_message.py deleted file mode 100644 index 6bb1ea640..000000000 --- a/src-new/astrbot_sdk/api/event/astrbot_message.py +++ /dev/null @@ -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 diff --git a/src-new/astrbot_sdk/api/event/event_result.py b/src-new/astrbot_sdk/api/event/event_result.py deleted file mode 100644 index 0ffb4399e..000000000 --- a/src-new/astrbot_sdk/api/event/event_result.py +++ /dev/null @@ -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 diff --git a/src-new/astrbot_sdk/api/event/event_type.py b/src-new/astrbot_sdk/api/event/event_type.py deleted file mode 100644 index 4ef4b76b2..000000000 --- a/src-new/astrbot_sdk/api/event/event_type.py +++ /dev/null @@ -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() diff --git a/src-new/astrbot_sdk/api/event/filter.py b/src-new/astrbot_sdk/api/event/filter.py deleted file mode 100644 index 7e001fa6e..000000000 --- a/src-new/astrbot_sdk/api/event/filter.py +++ /dev/null @@ -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", -] diff --git a/src-new/astrbot_sdk/api/event/message_session.py b/src-new/astrbot_sdk/api/event/message_session.py deleted file mode 100644 index 605d800bb..000000000 --- a/src-new/astrbot_sdk/api/event/message_session.py +++ /dev/null @@ -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 diff --git a/src-new/astrbot_sdk/api/event/message_type.py b/src-new/astrbot_sdk/api/event/message_type.py deleted file mode 100644 index bb5115947..000000000 --- a/src-new/astrbot_sdk/api/event/message_type.py +++ /dev/null @@ -1,11 +0,0 @@ -"""旧版消息类型兼容枚举。""" - -from __future__ import annotations - -from enum import Enum - - -class MessageType(Enum): - GROUP_MESSAGE = "GroupMessage" - FRIEND_MESSAGE = "FriendMessage" - OTHER_MESSAGE = "OtherMessage" diff --git a/src-new/astrbot_sdk/api/message/__init__.py b/src-new/astrbot_sdk/api/message/__init__.py deleted file mode 100644 index c7a8583a6..000000000 --- a/src-new/astrbot_sdk/api/message/__init__.py +++ /dev/null @@ -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", -] diff --git a/src-new/astrbot_sdk/api/message/chain.py b/src-new/astrbot_sdk/api/message/chain.py deleted file mode 100644 index f00a10f78..000000000 --- a/src-new/astrbot_sdk/api/message/chain.py +++ /dev/null @@ -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 diff --git a/src-new/astrbot_sdk/api/message/components.py b/src-new/astrbot_sdk/api/message/components.py deleted file mode 100644 index 35d9e37a0..000000000 --- a/src-new/astrbot_sdk/api/message/components.py +++ /dev/null @@ -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, -} diff --git a/src-new/astrbot_sdk/api/message_components.py b/src-new/astrbot_sdk/api/message_components.py deleted file mode 100644 index 061902240..000000000 --- a/src-new/astrbot_sdk/api/message_components.py +++ /dev/null @@ -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", -] diff --git a/src-new/astrbot_sdk/api/platform/__init__.py b/src-new/astrbot_sdk/api/platform/__init__.py deleted file mode 100644 index 812706abf..000000000 --- a/src-new/astrbot_sdk/api/platform/__init__.py +++ /dev/null @@ -1,5 +0,0 @@ -"""过渡期 ``astrbot_sdk.api.platform`` compat facade。""" - -from .platform_metadata import PlatformMetadata - -__all__ = ["PlatformMetadata"] diff --git a/src-new/astrbot_sdk/api/platform/platform_metadata.py b/src-new/astrbot_sdk/api/platform/platform_metadata.py deleted file mode 100644 index bf7b471b8..000000000 --- a/src-new/astrbot_sdk/api/platform/platform_metadata.py +++ /dev/null @@ -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 diff --git a/src-new/astrbot_sdk/api/provider/__init__.py b/src-new/astrbot_sdk/api/provider/__init__.py deleted file mode 100644 index e0429b567..000000000 --- a/src-new/astrbot_sdk/api/provider/__init__.py +++ /dev/null @@ -1,5 +0,0 @@ -"""过渡期 ``astrbot_sdk.api.provider`` compat facade。""" - -from .entities import LLMResponse - -__all__ = ["LLMResponse"] diff --git a/src-new/astrbot_sdk/api/provider/entities.py b/src-new/astrbot_sdk/api/provider/entities.py deleted file mode 100644 index 537093c8b..000000000 --- a/src-new/astrbot_sdk/api/provider/entities.py +++ /dev/null @@ -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 diff --git a/src-new/astrbot_sdk/api/star/__init__.py b/src-new/astrbot_sdk/api/star/__init__.py deleted file mode 100644 index a054060e4..000000000 --- a/src-new/astrbot_sdk/api/star/__init__.py +++ /dev/null @@ -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"] diff --git a/src-new/astrbot_sdk/api/star/context.py b/src-new/astrbot_sdk/api/star/context.py deleted file mode 100644 index d90cb425d..000000000 --- a/src-new/astrbot_sdk/api/star/context.py +++ /dev/null @@ -1,5 +0,0 @@ -"""旧版 ``Context`` 的兼容导出。""" - -from ..._legacy_api import Context - -__all__ = ["Context"] diff --git a/src-new/astrbot_sdk/api/star/star.py b/src-new/astrbot_sdk/api/star/star.py deleted file mode 100644 index cf1c50b11..000000000 --- a/src-new/astrbot_sdk/api/star/star.py +++ /dev/null @@ -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) diff --git a/src-new/astrbot_sdk/compat.py b/src-new/astrbot_sdk/compat.py deleted file mode 100644 index 7879b21ae..000000000 --- a/src-new/astrbot_sdk/compat.py +++ /dev/null @@ -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", -] diff --git a/src-new/astrbot_sdk/protocol/__init__.py b/src-new/astrbot_sdk/protocol/__init__.py index 19eb44141..4a98b52c6 100644 --- a/src-new/astrbot_sdk/protocol/__init__.py +++ b/src-new/astrbot_sdk/protocol/__init__.py @@ -1,8 +1,6 @@ """AstrBot v4 协议公共入口。 -这里优先暴露 v4 原生协议的消息模型、描述符和解析函数。 -legacy JSON-RPC 兼容保留在 `astrbot_sdk.protocol.legacy_adapter` 子模块中, -供迁移和适配场景显式使用,而不是作为主协议根入口的一部分。 +这里暴露 v4 原生协议的消息模型、描述符和解析函数。 握手阶段由 `InitializeMessage` 发起,返回值不是另一条 initialize 消息,而是 `ResultMessage(kind="initialize_result")`,其 `output` 负载可解析为 diff --git a/src-new/astrbot_sdk/protocol/legacy_adapter.py b/src-new/astrbot_sdk/protocol/legacy_adapter.py deleted file mode 100644 index ba05ff1ad..000000000 --- a/src-new/astrbot_sdk/protocol/legacy_adapter.py +++ /dev/null @@ -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", -] diff --git a/src-new/astrbot_sdk/runtime/__init__.py b/src-new/astrbot_sdk/runtime/__init__.py index 49d0b6eea..ef17b3fce 100644 --- a/src-new/astrbot_sdk/runtime/__init__.py +++ b/src-new/astrbot_sdk/runtime/__init__.py @@ -2,7 +2,7 @@ 这里仅暴露相对稳定的运行时构件:协议 `Peer`、传输抽象以及能力/处理器分发器。 大多数插件作者应优先使用顶层 `astrbot_sdk`。 -`astrbot_sdk.api` 仅用于旧插件过渡迁移,不建议新代码依赖。 + `loader` / `bootstrap` 等编排细节保留在各自子模块中,不作为根级稳定契约。 """ diff --git a/src-new/astrbot_sdk/runtime/handler_dispatcher.py b/src-new/astrbot_sdk/runtime/handler_dispatcher.py index 2bd2e1096..d3c59affc 100644 --- a/src-new/astrbot_sdk/runtime/handler_dispatcher.py +++ b/src-new/astrbot_sdk/runtime/handler_dispatcher.py @@ -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) diff --git a/src-new/astrbot_sdk/runtime/loader.py b/src-new/astrbot_sdk/runtime/loader.py index c56eb83f3..249686964 100644 --- a/src-new/astrbot_sdk/runtime/loader.py +++ b/src-new/astrbot_sdk/runtime/loader.py @@ -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) diff --git a/src-new/astrbot_sdk/runtime/worker.py b/src-new/astrbot_sdk/runtime/worker.py index 445aa69ca..67addd138 100644 --- a/src-new/astrbot_sdk/runtime/worker.py +++ b/src-new/astrbot_sdk/runtime/worker.py @@ -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, - ) diff --git a/test_plugin/new/commands/hello.py b/test_plugin/new/commands/hello.py index 476e811d5..71994527b 100644 --- a/test_plugin/new/commands/hello.py +++ b/test_plugin/new/commands/hello.py @@ -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, + }, + } diff --git a/test_plugin/old/commands/hello.py b/test_plugin/old/commands/hello.py deleted file mode 100644 index a9e364b06..000000000 --- a/test_plugin/old/commands/hello.py +++ /dev/null @@ -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, - } diff --git a/test_plugin/old/plugin.yaml b/test_plugin/old/plugin.yaml deleted file mode 100644 index 25f4f533e..000000000 --- a/test_plugin/old/plugin.yaml +++ /dev/null @@ -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: 监听并记录所有消息 diff --git a/test_plugin/old/requirements.txt b/test_plugin/old/requirements.txt deleted file mode 100644 index 8b1378917..000000000 --- a/test_plugin/old/requirements.txt +++ /dev/null @@ -1 +0,0 @@ - diff --git a/tests_v4/benchmark_grouped_environment_stress.py b/tests_v4/benchmark_grouped_environment_stress.py deleted file mode 100644 index e21de4087..000000000 --- a/tests_v4/benchmark_grouped_environment_stress.py +++ /dev/null @@ -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() diff --git a/tests_v4/external_plugin_matrix.json b/tests_v4/external_plugin_matrix.json deleted file mode 100644 index 47d5bd345..000000000 --- a/tests_v4/external_plugin_matrix.json +++ /dev/null @@ -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 系统的深层能力" - ] - } - ] -} diff --git a/tests_v4/test_api_contract.py b/tests_v4/test_api_contract.py deleted file mode 100644 index 52738f247..000000000 --- a/tests_v4/test_api_contract.py +++ /dev/null @@ -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() diff --git a/tests_v4/test_api_event_filter.py b/tests_v4/test_api_event_filter.py deleted file mode 100644 index 383feb075..000000000 --- a/tests_v4/test_api_event_filter.py +++ /dev/null @@ -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) diff --git a/tests_v4/test_api_legacy_context.py b/tests_v4/test_api_legacy_context.py deleted file mode 100644 index e2a4e9b1d..000000000 --- a/tests_v4/test_api_legacy_context.py +++ /dev/null @@ -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 diff --git a/tests_v4/test_api_message_components.py b/tests_v4/test_api_message_components.py deleted file mode 100644 index bc6509f18..000000000 --- a/tests_v4/test_api_message_components.py +++ /dev/null @@ -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" diff --git a/tests_v4/test_api_modules.py b/tests_v4/test_api_modules.py deleted file mode 100644 index a5c9c36e6..000000000 --- a/tests_v4/test_api_modules.py +++ /dev/null @@ -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 diff --git a/tests_v4/test_bootstrap.py b/tests_v4/test_bootstrap.py deleted file mode 100644 index ac5a77a89..000000000 --- a/tests_v4/test_bootstrap.py +++ /dev/null @@ -1,1230 +0,0 @@ -""" -Tests for runtime/bootstrap.py - Bootstrap and runtime classes. -""" - -from __future__ import annotations - -import asyncio -import sys -import tempfile -from io import StringIO -from pathlib import Path -from types import SimpleNamespace -from unittest.mock import AsyncMock, MagicMock, patch - -import pytest -import yaml - -from astrbot_sdk._legacy_api import LegacyContext -from astrbot_sdk._legacy_runtime import LegacyRuntimeAdapter -from astrbot_sdk.context import CancelToken -from astrbot_sdk.errors import AstrBotError -from astrbot_sdk.protocol.descriptors import ( - CapabilityDescriptor, - CommandTrigger, - HandlerDescriptor, -) -from astrbot_sdk.protocol.messages import ( - EventMessage, - InitializeMessage, - InitializeOutput, - InvokeMessage, - PeerInfo, -) -from astrbot_sdk.runtime.bootstrap import ( - PluginWorkerRuntime, - SupervisorRuntime, - WorkerSession, - _install_signal_handlers, - _prepare_stdio_transport, - _wait_for_shutdown, -) -from astrbot_sdk.runtime.capability_router import CapabilityRouter -from astrbot_sdk.runtime.loader import LoadedHandler, PluginSpec -from astrbot_sdk.runtime.peer import Peer - -from tests_v4.helpers import FakeEnvManager, MemoryTransport, make_transport_pair - - -async def start_test_core_peer(transport: MemoryTransport) -> Peer: - """Provide an initialize responder so transport-pair startup tests do not deadlock.""" - core = Peer( - transport=transport, - peer_info=PeerInfo(name="test-core", role="core", version="v4"), - ) - core.set_initialize_handler( - lambda _message: asyncio.sleep( - 0, - result=InitializeOutput( - peer=PeerInfo(name="test-core", role="core", version="v4"), - capabilities=[], - metadata={}, - ), - ) - ) - await core.start() - return core - - -def write_bootstrap_plugin( - plugins_dir: Path, - name: str, - *, - python_version: str | None = None, -) -> Path: - plugin_dir = plugins_dir / name - plugin_dir.mkdir(parents=True, exist_ok=True) - (plugin_dir / "plugin.yaml").write_text( - yaml.dump( - { - "name": name, - "runtime": { - "python": python_version - or f"{sys.version_info.major}.{sys.version_info.minor}" - }, - "components": [], - } - ), - encoding="utf-8", - ) - (plugin_dir / "requirements.txt").write_text("", encoding="utf-8") - return plugin_dir - - -class PlanningFakeEnvManager(FakeEnvManager): - def __init__(self, *, skipped_plugins: dict[str, str] | None = None) -> None: - self.skipped_plugins = dict(skipped_plugins or {}) - self.events: list[tuple[str, object]] = [] - self.prepared_paths: dict[str, Path] = {} - - def plan(self, plugins): - plugin_names = [plugin.name for plugin in plugins] - self.events.append(("plan", plugin_names)) - planned_plugins = [ - plugin for plugin in plugins if plugin.name not in self.skipped_plugins - ] - return SimpleNamespace( - groups=[], - plugins=planned_plugins, - plugin_to_group={}, - skipped_plugins=dict(self.skipped_plugins), - ) - - def prepare_environment(self, plugin) -> Path: - self.events.append(("prepare", plugin.name)) - path = Path(sys.executable) - self.prepared_paths[plugin.name] = path - return path - - -class TestInstallSignalHandlers: - """Tests for _install_signal_handlers function.""" - - @pytest.mark.asyncio - async def test_installs_handlers(self): - """_install_signal_handlers should install signal handlers.""" - stop_event = asyncio.Event() - _install_signal_handlers(stop_event) - # Just verify it doesn't raise on platforms that support it - - @pytest.mark.asyncio - async def test_handles_not_implemented(self): - """_install_signal_handlers should handle NotImplementedError.""" - stop_event = asyncio.Event() - with patch.object( - asyncio.get_running_loop(), - "add_signal_handler", - side_effect=NotImplementedError, - ): - _install_signal_handlers(stop_event) - - -class TestPrepareStdioTransport: - """Tests for _prepare_stdio_transport function.""" - - def test_with_both_streams(self): - """_prepare_stdio_transport should use provided streams.""" - stdin = StringIO() - stdout = StringIO() - - in_stream, out_stream, original = _prepare_stdio_transport(stdin, stdout) - - assert in_stream is stdin - assert out_stream is stdout - assert original is None - - def test_without_streams(self): - """_prepare_stdio_transport should use sys.stdin/stdout.""" - # 保存原始值 - original_stdin = sys.stdin - original_stdout = sys.stdout - - try: - in_stream, out_stream, original = _prepare_stdio_transport(None, None) - - # in_stream 应该是原始的 sys.stdin - assert in_stream is original_stdin - # out_stream 应该是原始的 sys.stdout(在修改前) - assert out_stream is original_stdout - # original 也应该是原始的 sys.stdout - assert original is original_stdout - # 函数会修改 sys.stdout 为 sys.stderr - assert sys.stdout is sys.stderr - finally: - # 恢复 - sys.stdout = original_stdout - - def test_redirects_stdout(self): - """_prepare_stdio_transport should redirect sys.stdout to stderr.""" - original_stdout = sys.stdout - - _prepare_stdio_transport(None, None) - - assert sys.stdout is sys.stderr - - # Restore - sys.stdout = original_stdout - - -class TestWaitForShutdown: - """Tests for _wait_for_shutdown function.""" - - @pytest.mark.asyncio - async def test_waits_for_stop_event(self): - """_wait_for_shutdown should wait for stop_event.""" - peer = MagicMock() - - # wait_closed 应该返回一个永不完成的协程 - async def never_complete(): - await asyncio.sleep(3600) - - peer.wait_closed = MagicMock(return_value=never_complete()) - - stop_event = asyncio.Event() - - async def set_event(): - await asyncio.sleep(0.05) - stop_event.set() - - asyncio.create_task(set_event()) - - await _wait_for_shutdown(peer, stop_event) - - assert stop_event.is_set() - - @pytest.mark.asyncio - async def test_waits_for_peer_closed(self): - """_wait_for_shutdown should wait for peer.wait_closed().""" - peer = MagicMock() - - # wait_closed 应该返回一个会完成的协程 - async def complete_soon(): - await asyncio.sleep(0.05) - - peer.wait_closed = MagicMock(return_value=complete_soon()) - - stop_event = asyncio.Event() - - await _wait_for_shutdown(peer, stop_event) - - peer.wait_closed.assert_called_once() - - -class TestWorkerSessionInit: - """Tests for WorkerSession initialization.""" - - def test_init(self): - """WorkerSession should store all parameters.""" - plugin = PluginSpec( - name="test", - plugin_dir=Path("/tmp"), - manifest_path=Path("/tmp/plugin.yaml"), - requirements_path=Path("/tmp/requirements.txt"), - python_version="3.12", - manifest_data={}, - ) - router = CapabilityRouter() - env_manager = FakeEnvManager() - - session = WorkerSession( - plugin=plugin, - repo_root=Path("/repo"), - env_manager=env_manager, - capability_router=router, - ) - - assert session.plugin == plugin - assert session.capability_router == router - assert session.peer is None - assert session.handlers == [] - - -class TestWorkerSessionMethods: - """Tests for WorkerSession methods.""" - - @pytest.mark.asyncio - async def test_invoke_handler_without_peer_raises(self): - """invoke_handler should raise if peer is None.""" - plugin = PluginSpec( - name="test", - plugin_dir=Path("/tmp"), - manifest_path=Path("/tmp/plugin.yaml"), - requirements_path=Path("/tmp/requirements.txt"), - python_version="3.12", - manifest_data={}, - ) - session = WorkerSession( - plugin=plugin, - repo_root=Path("/tmp"), - env_manager=FakeEnvManager(), - capability_router=CapabilityRouter(), - ) - - with pytest.raises(RuntimeError, match="not running"): - await session.invoke_handler("handler.id", {}, request_id="req-1") - - @pytest.mark.asyncio - async def test_cancel_without_peer_does_nothing(self): - """cancel should do nothing if peer is None.""" - plugin = PluginSpec( - name="test", - plugin_dir=Path("/tmp"), - manifest_path=Path("/tmp/plugin.yaml"), - requirements_path=Path("/tmp/requirements.txt"), - python_version="3.12", - manifest_data={}, - ) - session = WorkerSession( - plugin=plugin, - repo_root=Path("/tmp"), - env_manager=FakeEnvManager(), - capability_router=CapabilityRouter(), - ) - - # Should not raise - await session.cancel("req-1") - - @pytest.mark.asyncio - async def test_handle_initialize(self): - """_handle_initialize should return InitializeOutput.""" - plugin = PluginSpec( - name="test_plugin", - plugin_dir=Path("/tmp"), - manifest_path=Path("/tmp/plugin.yaml"), - requirements_path=Path("/tmp/requirements.txt"), - python_version="3.12", - manifest_data={}, - ) - router = CapabilityRouter() - session = WorkerSession( - plugin=plugin, - repo_root=Path("/tmp"), - env_manager=FakeEnvManager(), - capability_router=router, - ) - - message = InitializeMessage( - id="init-1", - protocol_version="1.0", - peer=PeerInfo(name="test", role="plugin"), - ) - - output = await session._handle_initialize(message) - - assert output.peer.name == "astrbot-supervisor" - assert output.peer.role == "core" - assert len(output.capabilities) > 0 - - @pytest.mark.asyncio - async def test_handle_capability_invoke(self): - """_handle_capability_invoke should route to capability_router.""" - plugin = PluginSpec( - name="test", - plugin_dir=Path("/tmp"), - manifest_path=Path("/tmp/plugin.yaml"), - requirements_path=Path("/tmp/requirements.txt"), - python_version="3.12", - manifest_data={}, - ) - router = CapabilityRouter() - session = WorkerSession( - plugin=plugin, - repo_root=Path("/tmp"), - env_manager=FakeEnvManager(), - capability_router=router, - ) - - message = InvokeMessage( - id="invoke-1", - capability="llm.chat", - input={"prompt": "hello"}, - ) - token = CancelToken() - - result = await session._handle_capability_invoke(message, token) - - assert result["text"] == "Echo: hello" - - @pytest.mark.asyncio - async def test_register_plugin_capability_routes_through_worker_session(self): - """SupervisorRuntime should expose plugin-provided capabilities via router.""" - transport = MemoryTransport() - - with tempfile.TemporaryDirectory() as temp_dir: - runtime = SupervisorRuntime( - transport=transport, - plugins_dir=Path(temp_dir), - ) - session = MagicMock() - session.invoke_capability = AsyncMock(return_value={"echo": "hi"}) - descriptor = CapabilityDescriptor( - name="demo.echo", - description="Echo text", - input_schema={ - "type": "object", - "properties": {"text": {"type": "string"}}, - }, - output_schema={ - "type": "object", - "properties": {"echo": {"type": "string"}}, - }, - ) - - runtime._register_plugin_capability(descriptor, session, "demo_plugin") - result = await runtime.capability_router.execute( - "demo.echo", - {"text": "hi"}, - stream=False, - cancel_token=CancelToken(), - request_id="req-1", - ) - - assert result == {"echo": "hi"} - session.invoke_capability.assert_awaited_once_with( - "demo.echo", - {"text": "hi"}, - request_id="req-1", - ) - - @pytest.mark.asyncio - async def test_register_plugin_stream_capability_preserves_completed_output(self): - """SupervisorRuntime should preserve plugin stream capability finalize output.""" - transport = MemoryTransport() - - async def stream_events(): - yield EventMessage(id="req-1", phase="delta", data={"chunk": 1}) - yield EventMessage(id="req-1", phase="delta", data={"chunk": 2}) - yield EventMessage( - id="req-1", - phase="completed", - output={"count": 2}, - ) - - with tempfile.TemporaryDirectory() as temp_dir: - runtime = SupervisorRuntime( - transport=transport, - plugins_dir=Path(temp_dir), - ) - session = MagicMock() - session.invoke_capability_stream = MagicMock(return_value=stream_events()) - descriptor = CapabilityDescriptor( - name="demo.stream", - description="Stream text", - supports_stream=True, - output_schema={ - "type": "object", - "properties": {"count": {"type": "integer"}}, - "required": ["count"], - }, - ) - - runtime._register_plugin_capability(descriptor, session, "demo_plugin") - result = await runtime.capability_router.execute( - "demo.stream", - {}, - stream=True, - cancel_token=CancelToken(), - request_id="req-1", - ) - - chunks = [] - async for chunk in result.iterator: - chunks.append(chunk) - - assert chunks == [{"chunk": 1}, {"chunk": 2}] - assert result.finalize(chunks) == {"count": 2} - - -class TestSupervisorRuntimeInit: - """Tests for SupervisorRuntime initialization.""" - - def test_init(self): - """SupervisorRuntime should initialize correctly.""" - transport = MemoryTransport() - - with tempfile.TemporaryDirectory() as temp_dir: - runtime = SupervisorRuntime( - transport=transport, - plugins_dir=Path(temp_dir), - ) - - assert runtime.transport is transport - assert runtime.worker_sessions == {} - assert runtime.handler_to_worker == {} - assert runtime.capability_to_worker == {} - assert runtime.active_requests == {} - assert runtime.loaded_plugins == [] - assert isinstance(runtime.capability_router, CapabilityRouter) - - def test_registers_internal_capabilities(self): - """SupervisorRuntime should register internal capabilities.""" - transport = MemoryTransport() - - with tempfile.TemporaryDirectory() as temp_dir: - runtime = SupervisorRuntime( - transport=transport, - plugins_dir=Path(temp_dir), - ) - - # handler.invoke should be registered (but not exposed) - assert "handler.invoke" in runtime.capability_router._registrations - # Should not be in descriptors (exposed=False) - names = [d.name for d in runtime.capability_router.descriptors()] - assert "handler.invoke" not in names - - -class TestSupervisorRuntimeMethods: - """Tests for SupervisorRuntime methods.""" - - @pytest.mark.asyncio - async def test_start_with_empty_plugins_dir(self): - """SupervisorRuntime.start should work with empty plugins dir.""" - left, right = make_transport_pair() - core = await start_test_core_peer(left) - - with tempfile.TemporaryDirectory() as temp_dir: - runtime = SupervisorRuntime( - transport=right, - plugins_dir=Path(temp_dir), - env_manager=FakeEnvManager(), - ) - - try: - await runtime.start() - await core.wait_until_remote_initialized() - assert runtime.loaded_plugins == [] - assert runtime.skipped_plugins == {} - finally: - await runtime.stop() - await core.stop() - - @pytest.mark.asyncio - async def test_start_calls_plan_before_prepare_and_reuses_python_path(self): - """SupervisorRuntime should plan plugins before starting worker sessions.""" - left, right = make_transport_pair() - core = await start_test_core_peer(left) - - with tempfile.TemporaryDirectory() as temp_dir: - plugins_dir = Path(temp_dir) / "plugins" - write_bootstrap_plugin(plugins_dir, "plugin_one") - write_bootstrap_plugin(plugins_dir, "plugin_two") - env_manager = PlanningFakeEnvManager() - runtime = SupervisorRuntime( - transport=right, - plugins_dir=plugins_dir, - env_manager=env_manager, - ) - - try: - await runtime.start() - await core.wait_until_remote_initialized() - - assert env_manager.events[0] == ("plan", ["plugin_one", "plugin_two"]) - assert ("prepare", "plugin_one") in env_manager.events - assert ("prepare", "plugin_two") in env_manager.events - assert env_manager.prepared_paths["plugin_one"] == Path(sys.executable) - assert env_manager.prepared_paths["plugin_two"] == Path(sys.executable) - assert runtime.loaded_plugins == ["plugin_one", "plugin_two"] - finally: - await runtime.stop() - await core.stop() - - @pytest.mark.asyncio - async def test_start_merges_planning_skips_into_runtime_metadata(self): - """SupervisorRuntime should expose planning-stage skipped plugins.""" - left, right = make_transport_pair() - core = await start_test_core_peer(left) - - with tempfile.TemporaryDirectory() as temp_dir: - plugins_dir = Path(temp_dir) / "plugins" - write_bootstrap_plugin(plugins_dir, "plugin_one") - write_bootstrap_plugin(plugins_dir, "plugin_two") - env_manager = PlanningFakeEnvManager( - skipped_plugins={"plugin_two": "compile lockfile failed"} - ) - runtime = SupervisorRuntime( - transport=right, - plugins_dir=plugins_dir, - env_manager=env_manager, - ) - - try: - await runtime.start() - await core.wait_until_remote_initialized() - - assert runtime.loaded_plugins == ["plugin_one"] - assert ( - runtime.skipped_plugins["plugin_two"] == "compile lockfile failed" - ) - assert core.remote_metadata["skipped_plugins"]["plugin_two"] == ( - "compile lockfile failed" - ) - finally: - await runtime.stop() - await core.stop() - - @pytest.mark.asyncio - async def test_route_handler_invoke_missing_handler(self): - """_route_handler_invoke should raise for missing handler.""" - transport = MemoryTransport() - - with tempfile.TemporaryDirectory() as temp_dir: - runtime = SupervisorRuntime( - transport=transport, - plugins_dir=Path(temp_dir), - ) - - with pytest.raises(AstrBotError, match="handler not found"): - await runtime._route_handler_invoke( - "req-1", - {"handler_id": "missing.handler", "event": {}}, - CancelToken(), - ) - - @pytest.mark.asyncio - async def test_handle_worker_closed_removes_session(self): - """_handle_worker_closed should remove session and handlers.""" - left, right = make_transport_pair() - - with tempfile.TemporaryDirectory() as temp_dir: - runtime = SupervisorRuntime( - transport=right, - plugins_dir=Path(temp_dir), - env_manager=FakeEnvManager(), - ) - - # Add fake session - mock_session = MagicMock() - mock_session.handlers = [ - HandlerDescriptor( - id="test.handler", trigger=CommandTrigger(command="test") - ) - ] - - runtime.worker_sessions["test_plugin"] = mock_session - runtime.handler_to_worker["test.handler"] = mock_session - runtime._handler_sources["test.handler"] = "test_plugin" - runtime.loaded_plugins.append("test_plugin") - - runtime._handle_worker_closed("test_plugin") - - assert "test_plugin" not in runtime.worker_sessions - assert "test.handler" not in runtime.handler_to_worker - assert "test.handler" not in runtime._handler_sources - assert "test_plugin" not in runtime.loaded_plugins - - @pytest.mark.asyncio - async def test_handle_worker_closed_removes_active_requests(self): - """_handle_worker_closed should drop in-flight requests owned by the worker.""" - transport = MemoryTransport() - - with tempfile.TemporaryDirectory() as temp_dir: - runtime = SupervisorRuntime( - transport=transport, - plugins_dir=Path(temp_dir), - ) - - mock_session = MagicMock() - mock_session.handlers = [ - HandlerDescriptor( - id="test.handler", trigger=CommandTrigger(command="test") - ) - ] - - runtime.worker_sessions["test_plugin"] = mock_session - runtime.handler_to_worker["test.handler"] = mock_session - runtime._handler_sources["test.handler"] = "test_plugin" - runtime.loaded_plugins.append("test_plugin") - runtime.active_requests["req-1"] = mock_session - - runtime._handle_worker_closed("test_plugin") - - assert "req-1" not in runtime.active_requests - - @pytest.mark.asyncio - async def test_handle_worker_closed_unknown_plugin(self): - """_handle_worker_closed should handle unknown plugin.""" - transport = MemoryTransport() - - with tempfile.TemporaryDirectory() as temp_dir: - runtime = SupervisorRuntime( - transport=transport, - plugins_dir=Path(temp_dir), - ) - - # Should not raise for unknown plugin - runtime._handle_worker_closed("unknown_plugin") - - -class TestPluginWorkerRuntimeInit: - """Tests for PluginWorkerRuntime initialization.""" - - def test_init_with_valid_plugin(self): - """PluginWorkerRuntime should initialize with valid plugin.""" - with tempfile.TemporaryDirectory() as temp_dir: - plugin_dir = Path(temp_dir) - manifest_path = plugin_dir / "plugin.yaml" - requirements_path = plugin_dir / "requirements.txt" - - manifest_path.write_text( - yaml.dump( - { - "name": "test_plugin", - "runtime": {"python": "3.12"}, - "components": [], - } - ), - encoding="utf-8", - ) - requirements_path.write_text("", encoding="utf-8") - - transport = MemoryTransport() - - # This should work if the plugin is valid - runtime = PluginWorkerRuntime(plugin_dir=plugin_dir, transport=transport) - - assert runtime.plugin.name == "test_plugin" - assert runtime.peer is not None - assert runtime.dispatcher is not None - - -class TestPluginWorkerRuntimeMethods: - """Tests for PluginWorkerRuntime methods.""" - - @pytest.mark.asyncio - async def test_handle_invoke_wrong_capability(self): - """_handle_invoke should raise for wrong capability.""" - with tempfile.TemporaryDirectory() as temp_dir: - plugin_dir = Path(temp_dir) - manifest_path = plugin_dir / "plugin.yaml" - requirements_path = plugin_dir / "requirements.txt" - - manifest_path.write_text( - yaml.dump( - { - "name": "test_plugin", - "runtime": {"python": "3.12"}, - "components": [], - } - ), - encoding="utf-8", - ) - requirements_path.write_text("", encoding="utf-8") - - transport = MemoryTransport() - runtime = PluginWorkerRuntime(plugin_dir=plugin_dir, transport=transport) - - message = InvokeMessage( - id="invoke-1", - capability="wrong.capability", - input={}, - ) - token = CancelToken() - - with pytest.raises(AstrBotError, match="未找到能力"): - await runtime._handle_invoke(message, token) - - @pytest.mark.asyncio - async def test_handle_invoke_plugin_capability(self): - """_handle_invoke should dispatch plugin-provided capabilities.""" - with tempfile.TemporaryDirectory() as temp_dir: - plugin_dir = Path(temp_dir) - manifest_path = plugin_dir / "plugin.yaml" - requirements_path = plugin_dir / "requirements.txt" - module_dir = plugin_dir / "demo_plugin" - module_dir.mkdir() - (module_dir / "__init__.py").write_text("", encoding="utf-8") - (module_dir / "component.py").write_text( - """ -from astrbot_sdk import Star, provide_capability - - -class DemoComponent(Star): - @provide_capability( - "demo.echo", - description="Echo text", - input_schema={"type": "object", "properties": {"text": {"type": "string"}}}, - output_schema={"type": "object", "properties": {"echo": {"type": "string"}}}, - ) - async def echo(self, payload): - return {"echo": payload["text"]} -""".strip(), - encoding="utf-8", - ) - - manifest_path.write_text( - yaml.dump( - { - "name": "test_plugin", - "runtime": {"python": "3.12"}, - "components": [ - {"class": "demo_plugin.component:DemoComponent"} - ], - } - ), - encoding="utf-8", - ) - requirements_path.write_text("", encoding="utf-8") - - if str(plugin_dir) not in sys.path: - sys.path.insert(0, str(plugin_dir)) - - try: - transport = MemoryTransport() - runtime = PluginWorkerRuntime( - plugin_dir=plugin_dir, - transport=transport, - ) - message = InvokeMessage( - id="invoke-cap", - capability="demo.echo", - input={"text": "hello"}, - ) - - result = await runtime._handle_invoke(message, CancelToken()) - - assert result == {"echo": "hello"} - finally: - if str(plugin_dir) in sys.path: - sys.path.remove(str(plugin_dir)) - - @pytest.mark.asyncio - async def test_start_and_stop_run_compat_context_lifecycle_hooks(self): - """PluginWorkerRuntime should execute compat lifecycle hooks around peer startup/shutdown.""" - with tempfile.TemporaryDirectory() as temp_dir: - plugin_dir = Path(temp_dir) - manifest_path = plugin_dir / "plugin.yaml" - requirements_path = plugin_dir / "requirements.txt" - - manifest_path.write_text( - yaml.dump( - { - "name": "test_plugin", - "runtime": {"python": "3.12"}, - "components": [], - } - ), - encoding="utf-8", - ) - requirements_path.write_text("", encoding="utf-8") - - transport = MemoryTransport() - runtime = PluginWorkerRuntime(plugin_dir=plugin_dir, transport=transport) - - seen_hooks = [] - legacy_context = LegacyContext("test_plugin") - - class CompatHooks: - async def on_astrbot_loaded(self, context): - seen_hooks.append(("astrbot", context.plugin_id)) - - async def on_platform_loaded(self, context): - seen_hooks.append(("platform", context.plugin_id)) - - async def on_plugin_loaded(self, metadata): - seen_hooks.append(("loaded", metadata["name"])) - - async def on_plugin_unloaded(self, metadata): - seen_hooks.append(("unloaded", metadata["name"])) - - from astrbot_sdk.api.event.filter import ( - on_astrbot_loaded, - on_platform_loaded, - on_plugin_loaded, - on_plugin_unloaded, - ) - - CompatHooks.on_astrbot_loaded = on_astrbot_loaded()( - CompatHooks.on_astrbot_loaded - ) - CompatHooks.on_platform_loaded = on_platform_loaded()( - CompatHooks.on_platform_loaded - ) - CompatHooks.on_plugin_loaded = on_plugin_loaded()( - CompatHooks.on_plugin_loaded - ) - CompatHooks.on_plugin_unloaded = on_plugin_unloaded()( - CompatHooks.on_plugin_unloaded - ) - legacy_context._register_component(CompatHooks()) - legacy_context.bind_runtime_context(runtime._lifecycle_context) - - runtime.loaded_plugin.handlers.append( - LoadedHandler( - descriptor=HandlerDescriptor( - id="legacy.compat", - trigger=CommandTrigger(command="compat"), - ), - callable=AsyncMock(), - owner=MagicMock(), - legacy_context=legacy_context, - ) - ) - runtime.peer.start = AsyncMock() - runtime.peer.initialize = AsyncMock() - runtime.peer.stop = AsyncMock() - - await runtime.start() - await runtime.stop() - - assert seen_hooks == [ - ("astrbot", "test_plugin"), - ("platform", "test_plugin"), - ("loaded", "test_plugin"), - ("unloaded", "test_plugin"), - ] - - @pytest.mark.asyncio - async def test_start_uses_legacy_runtime_boundary_for_startup_hooks(self): - """PluginWorkerRuntime startup should delegate compat lifecycle work to the adapter boundary.""" - with tempfile.TemporaryDirectory() as temp_dir: - plugin_dir = Path(temp_dir) - manifest_path = plugin_dir / "plugin.yaml" - requirements_path = plugin_dir / "requirements.txt" - - manifest_data = { - "name": "test_plugin", - "runtime": {"python": "3.12"}, - "components": [], - } - manifest_path.write_text( - yaml.dump(manifest_data), - encoding="utf-8", - ) - requirements_path.write_text("", encoding="utf-8") - - runtime = PluginWorkerRuntime( - plugin_dir=plugin_dir, - transport=MemoryTransport(), - ) - runtime.peer.start = AsyncMock() - runtime.peer.initialize = AsyncMock() - runtime.peer.stop = AsyncMock() - runtime._legacy_worker_runtime = MagicMock() - runtime._legacy_worker_runtime.run_startup_hooks = AsyncMock() - - await runtime.start() - - startup_hooks = runtime._legacy_worker_runtime.run_startup_hooks - startup_hooks.assert_awaited_once() - _, kwargs = startup_hooks.await_args - assert kwargs["context"] is runtime._lifecycle_context - assert kwargs["metadata"] == manifest_data - - @pytest.mark.asyncio - async def test_stop_uses_legacy_runtime_boundary_for_shutdown_hooks(self): - """PluginWorkerRuntime shutdown should delegate compat unload hooks to the adapter boundary.""" - with tempfile.TemporaryDirectory() as temp_dir: - plugin_dir = Path(temp_dir) - manifest_path = plugin_dir / "plugin.yaml" - requirements_path = plugin_dir / "requirements.txt" - - manifest_data = { - "name": "test_plugin", - "runtime": {"python": "3.12"}, - "components": [], - } - manifest_path.write_text( - yaml.dump(manifest_data), - encoding="utf-8", - ) - requirements_path.write_text("", encoding="utf-8") - - runtime = PluginWorkerRuntime( - plugin_dir=plugin_dir, - transport=MemoryTransport(), - ) - runtime.peer.stop = AsyncMock() - runtime._legacy_worker_runtime = MagicMock() - runtime._legacy_worker_runtime.run_shutdown_hooks = AsyncMock() - - await runtime.stop() - - shutdown_hooks = runtime._legacy_worker_runtime.run_shutdown_hooks - shutdown_hooks.assert_awaited_once() - _, kwargs = shutdown_hooks.await_args - assert kwargs["context"] is runtime._lifecycle_context - assert kwargs["metadata"] == manifest_data - - @pytest.mark.asyncio - async def test_run_lifecycle_sync_hook(self): - """_run_lifecycle should call sync hooks.""" - with tempfile.TemporaryDirectory() as temp_dir: - plugin_dir = Path(temp_dir) - manifest_path = plugin_dir / "plugin.yaml" - requirements_path = plugin_dir / "requirements.txt" - - manifest_path.write_text( - yaml.dump( - { - "name": "test_plugin", - "runtime": {"python": "3.12"}, - "components": [], - } - ), - encoding="utf-8", - ) - requirements_path.write_text("", encoding="utf-8") - - transport = MemoryTransport() - runtime = PluginWorkerRuntime(plugin_dir=plugin_dir, transport=transport) - - # Add mock instance with sync hook - called = [] - - class MockInstance: - def on_start(self, ctx): - called.append("on_start") - - runtime.loaded_plugin.instances.append(MockInstance()) - - await runtime._run_lifecycle("on_start") - - assert "on_start" in called - - @pytest.mark.asyncio - async def test_run_lifecycle_async_hook(self): - """_run_lifecycle should call async hooks.""" - with tempfile.TemporaryDirectory() as temp_dir: - plugin_dir = Path(temp_dir) - manifest_path = plugin_dir / "plugin.yaml" - requirements_path = plugin_dir / "requirements.txt" - - manifest_path.write_text( - yaml.dump( - { - "name": "test_plugin", - "runtime": {"python": "3.12"}, - "components": [], - } - ), - encoding="utf-8", - ) - requirements_path.write_text("", encoding="utf-8") - - transport = MemoryTransport() - runtime = PluginWorkerRuntime(plugin_dir=plugin_dir, transport=transport) - - # Add mock instance with async hook - called = [] - - class MockInstance: - async def on_stop(self, ctx): - called.append("on_stop") - - runtime.loaded_plugin.instances.append(MockInstance()) - - await runtime._run_lifecycle("on_stop") - - assert "on_stop" in called - - @pytest.mark.asyncio - async def test_run_lifecycle_missing_method(self): - """_run_lifecycle should skip missing methods.""" - with tempfile.TemporaryDirectory() as temp_dir: - plugin_dir = Path(temp_dir) - manifest_path = plugin_dir / "plugin.yaml" - requirements_path = plugin_dir / "requirements.txt" - - manifest_path.write_text( - yaml.dump( - { - "name": "test_plugin", - "runtime": {"python": "3.12"}, - "components": [], - } - ), - encoding="utf-8", - ) - requirements_path.write_text("", encoding="utf-8") - - transport = MemoryTransport() - runtime = PluginWorkerRuntime(plugin_dir=plugin_dir, transport=transport) - - # Add mock instance without the method - class MockInstance: - pass - - runtime.loaded_plugin.instances.append(MockInstance()) - - # Should not raise - await runtime._run_lifecycle("on_start") - - @pytest.mark.asyncio - async def test_run_lifecycle_legacy_initialize_and_terminate_aliases(self): - """_run_lifecycle should map legacy initialize/terminate for old stars.""" - with tempfile.TemporaryDirectory() as temp_dir: - plugin_dir = Path(temp_dir) - manifest_path = plugin_dir / "plugin.yaml" - requirements_path = plugin_dir / "requirements.txt" - - manifest_path.write_text( - yaml.dump( - { - "name": "test_plugin", - "runtime": {"python": "3.12"}, - "components": [], - } - ), - encoding="utf-8", - ) - requirements_path.write_text("", encoding="utf-8") - - transport = MemoryTransport() - runtime = PluginWorkerRuntime(plugin_dir=plugin_dir, transport=transport) - - called = [] - - class MockLegacyInstance: - @classmethod - def __astrbot_is_new_star__(cls): - return False - - async def initialize(self): - called.append("initialize") - - async def terminate(self): - called.append("terminate") - - runtime.loaded_plugin.instances.append(MockLegacyInstance()) - - await runtime._run_lifecycle("on_start") - await runtime._run_lifecycle("on_stop") - - assert called == ["initialize", "terminate"] - - def test_bind_legacy_runtime_contexts_reuses_shared_context(self): - """legacy lifecycle helpers should see the worker runtime context.""" - with tempfile.TemporaryDirectory() as temp_dir: - plugin_dir = Path(temp_dir) - manifest_path = plugin_dir / "plugin.yaml" - requirements_path = plugin_dir / "requirements.txt" - - manifest_path.write_text( - yaml.dump( - { - "name": "test_plugin", - "runtime": {"python": "3.12"}, - "components": [], - } - ), - encoding="utf-8", - ) - requirements_path.write_text("", encoding="utf-8") - - runtime = PluginWorkerRuntime( - plugin_dir=plugin_dir, transport=MemoryTransport() - ) - legacy_context = LegacyContext("test_plugin") - runtime.loaded_plugin.handlers.append( - SimpleNamespace( - legacy_runtime=LegacyRuntimeAdapter(legacy_context=legacy_context) - ) - ) - runtime.loaded_plugin.capabilities.append( - SimpleNamespace( - legacy_runtime=LegacyRuntimeAdapter(legacy_context=legacy_context) - ) - ) - - runtime._bind_legacy_runtime_contexts(runtime._lifecycle_context) - - assert ( - legacy_context.require_runtime_context() is runtime._lifecycle_context - ) - - -class TestIntegrationWithTransportPair: - """Integration tests using transport pairs.""" - - @pytest.mark.asyncio - async def test_supervisor_responds_to_initialize(self): - """SupervisorRuntime should respond to initialize messages.""" - left, right = make_transport_pair() - core = await start_test_core_peer(left) - - with tempfile.TemporaryDirectory() as temp_dir: - runtime = SupervisorRuntime( - transport=right, - plugins_dir=Path(temp_dir), - env_manager=FakeEnvManager(), - ) - - try: - await runtime.start() - await core.wait_until_remote_initialized() - assert core.remote_peer is not None - assert core.remote_peer.name == "astrbot-supervisor" - assert core.remote_metadata["plugins"] == [] - assert core.remote_metadata["skipped_plugins"] == {} - - finally: - await runtime.stop() - await core.stop() - - @pytest.mark.asyncio - async def test_worker_session_lifecycle(self): - """WorkerSession should start and stop cleanly.""" - with tempfile.TemporaryDirectory() as temp_dir: - # Create a minimal plugin - plugin_dir = Path(temp_dir) / "plugins" / "test_plugin" - plugin_dir.mkdir(parents=True) - - manifest_path = plugin_dir / "plugin.yaml" - requirements_path = plugin_dir / "requirements.txt" - - manifest_path.write_text( - yaml.dump( - { - "name": "test_plugin", - "runtime": { - "python": f"{sys.version_info.major}.{sys.version_info.minor}" - }, - "components": [], - } - ), - encoding="utf-8", - ) - requirements_path.write_text("", encoding="utf-8") - - plugin = PluginSpec( - name="test_plugin", - plugin_dir=plugin_dir, - manifest_path=manifest_path, - requirements_path=requirements_path, - python_version=f"{sys.version_info.major}.{sys.version_info.minor}", - manifest_data={"name": "test_plugin"}, - ) - - left, right = make_transport_pair() - - session = WorkerSession( - plugin=plugin, - repo_root=Path(temp_dir), - env_manager=FakeEnvManager(), - capability_router=CapabilityRouter(), - ) - - # Note: Full start would require subprocess, skip for unit test - # Just verify the session can be created and stopped - await session.stop() diff --git a/tests_v4/test_compatibility_contract.py b/tests_v4/test_compatibility_contract.py deleted file mode 100644 index c2ba6d1d7..000000000 --- a/tests_v4/test_compatibility_contract.py +++ /dev/null @@ -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() diff --git a/tests_v4/test_external_plugin_smoke.py b/tests_v4/test_external_plugin_smoke.py deleted file mode 100644 index 82330f7a9..000000000 --- a/tests_v4/test_external_plugin_smoke.py +++ /dev/null @@ -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=`` -- ``ASTRBOT_EXTERNAL_PLUGIN_EVENT_TEXT=`` (可选,默认等于 command) -- ``ASTRBOT_EXTERNAL_PLUGIN_EXPECT_TEXT=`` (可选) -""" - -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"), - ) diff --git a/tests_v4/test_grouped_environment_smoke.py b/tests_v4/test_grouped_environment_smoke.py deleted file mode 100644 index 8593ed8ad..000000000 --- a/tests_v4/test_grouped_environment_smoke.py +++ /dev/null @@ -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() diff --git a/tests_v4/test_handler_dispatcher.py b/tests_v4/test_handler_dispatcher.py deleted file mode 100644 index 526962292..000000000 --- a/tests_v4/test_handler_dispatcher.py +++ /dev/null @@ -1,1228 +0,0 @@ -""" -Tests for runtime/handler_dispatcher.py - HandlerDispatcher implementation. -""" - -from __future__ import annotations - -import asyncio -from typing import Any -from unittest.mock import AsyncMock, MagicMock - -import pytest - -from astrbot.core.utils.session_waiter import ( - SessionController, - SessionWaiter, - session_waiter, -) -from astrbot_sdk._legacy_api import LegacyContext -from astrbot_sdk._legacy_runtime import LegacyRuntimeAdapter -from astrbot_sdk.api.event import AstrMessageEvent -from astrbot_sdk.api.event.filter import ( - CustomFilter, - after_message_sent, - on_decorating_result, -) -from astrbot_sdk.api.message import Comp, MessageChain -from astrbot_sdk.context import CancelToken, Context -from astrbot_sdk.events import MessageEvent, PlainTextResult -from astrbot_sdk.protocol.descriptors import ( - CommandTrigger, - HandlerDescriptor, -) -from astrbot_sdk.protocol.messages import InvokeMessage -from astrbot_sdk.runtime.handler_dispatcher import HandlerDispatcher -from astrbot_sdk.runtime.loader import LoadedHandler - - -class MockPeer: - """Mock peer for testing.""" - - def __init__(self): - self.sent_messages: list[dict[str, Any]] = [] - self.platform = self # platform.send 通过 self.send 调用 - # CapabilityProxy 需要的属性 - self.remote_capability_map: dict[str, Any] = {} - - async def send(self, session_id: str, text: str) -> None: - """模拟 platform.send 方法""" - self.sent_messages.append({"session_id": session_id, "text": text}) - - async def invoke( - self, name: str, payload: dict[str, Any], stream: bool = False - ) -> dict[str, Any]: - """模拟 peer.invoke 方法,用于 CapabilityProxy""" - if name == "platform.send": - await self.send( - payload.get("session", payload.get("session_id", "")), - payload.get("text", ""), - ) - return {} - if name == "platform.send_chain": - self.sent_messages.append( - { - "session_id": payload.get("session", ""), - "chain": payload.get("chain", []), - } - ) - return {} - return {} - - -def create_mock_handler( - handler_id: str = "test.handler", - command: str = "hello", -) -> LoadedHandler: - """Create a mock loaded handler.""" - descriptor = HandlerDescriptor( - id=handler_id, - trigger=CommandTrigger(command=command), - ) - - async def handler_func(event: MessageEvent, ctx: Context): - await event.reply("Hello!") - return None - - handler_func.__func__ = handler_func # Simulate bound method - - return LoadedHandler( - descriptor=descriptor, - callable=handler_func, - owner=MagicMock(), - legacy_context=None, - ) - - -def create_invoke_message( - message_id: str = "msg_001", - handler_id: str = "test.handler", - event_data: dict[str, Any] | None = None, - args: dict[str, Any] | None = None, -) -> InvokeMessage: - """Create a mock invoke message.""" - input_data = {"handler_id": handler_id, "event": event_data or {}} - if args: - input_data["args"] = args - return InvokeMessage( - id=message_id, - capability="handler.invoke", - input=input_data, - ) - - -def create_message_event() -> MessageEvent: - """Create a mock message event.""" - return MessageEvent( - session_id="session-1", - user_id="user-1", - platform="test", - text="hello world", - ) - - -class TestHandlerDispatcherInit: - """Tests for HandlerDispatcher initialization.""" - - def test_init(self): - """HandlerDispatcher should initialize with handlers.""" - peer = MockPeer() - handler = create_mock_handler() - - dispatcher = HandlerDispatcher( - plugin_id="test_plugin", - peer=peer, - handlers=[handler], - ) - - assert dispatcher._plugin_id == "test_plugin" - assert dispatcher._peer is peer - assert "test.handler" in dispatcher._handlers - assert dispatcher._active == {} - - def test_handlers_indexed_by_id(self): - """HandlerDispatcher should index handlers by id.""" - peer = MockPeer() - handlers = [ - create_mock_handler("handler.one", "cmd1"), - create_mock_handler("handler.two", "cmd2"), - ] - - dispatcher = HandlerDispatcher( - plugin_id="test_plugin", - peer=peer, - handlers=handlers, - ) - - assert "handler.one" in dispatcher._handlers - assert "handler.two" in dispatcher._handlers - - -class TestHandlerDispatcherInvoke: - """Tests for HandlerDispatcher.invoke method.""" - - @pytest.mark.asyncio - async def test_invoke_calls_handler(self): - """invoke should call the registered handler.""" - peer = MockPeer() - sent_messages = [] - - # 记录 platform.send 的调用 - original_send = peer.send - - async def track_send(session_id: str, text: str) -> None: - sent_messages.append({"session_id": session_id, "text": text}) - await original_send(session_id, text) - - peer.send = track_send - - handler_called = [] - - async def handler_func(e: MessageEvent, ctx: Context): - handler_called.append(e) - await e.reply("response") - - descriptor = HandlerDescriptor( - id="test.handler", - trigger=CommandTrigger(command="hello"), - ) - handler = LoadedHandler( - descriptor=descriptor, - callable=handler_func, - owner=MagicMock(), - legacy_context=None, - ) - - dispatcher = HandlerDispatcher( - plugin_id="test_plugin", - peer=peer, - handlers=[handler], - ) - - event = create_message_event() - # MessageEvent 使用 to_payload() 而不是 model_dump() - message = InvokeMessage( - id="msg_001", - capability="handler.invoke", - input={"handler_id": "test.handler", "event": event.to_payload()}, - ) - - cancel_token = CancelToken() - result = await dispatcher.invoke(message, cancel_token) - - assert result == {} - assert len(handler_called) == 1 - # 验证 reply 通过 platform.send 发送 - assert any("response" in m.get("text", "") for m in sent_messages) - - @pytest.mark.asyncio - async def test_invoke_missing_handler_raises(self): - """invoke should raise LookupError for missing handler.""" - peer = MockPeer() - dispatcher = HandlerDispatcher( - plugin_id="test_plugin", - peer=peer, - handlers=[], - ) - - message = InvokeMessage( - id="msg_001", - capability="handler.invoke", - input={"handler_id": "nonexistent.handler", "event": {}}, - ) - - cancel_token = CancelToken() - - with pytest.raises(LookupError, match="handler not found"): - await dispatcher.invoke(message, cancel_token) - - @pytest.mark.asyncio - async def test_invoke_with_legacy_args(self): - """invoke should pass legacy args to handler.""" - peer = MockPeer() - - received_args = [] - - async def handler_func(event: MessageEvent, ctx: Context, name: str): - received_args.append(name) - return None - - descriptor = HandlerDescriptor( - id="test.handler", - trigger=CommandTrigger(command="hello"), - ) - handler = LoadedHandler( - descriptor=descriptor, - callable=handler_func, - owner=MagicMock(), - legacy_context=None, - ) - - dispatcher = HandlerDispatcher( - plugin_id="test_plugin", - peer=peer, - handlers=[handler], - ) - - message = InvokeMessage( - id="msg_001", - capability="handler.invoke", - input={ - "handler_id": "test.handler", - "event": { - "type": "message", - "session_id": "s1", - "user_id": "u1", - "platform": "test", - }, - "args": {"name": "test_name"}, - }, - ) - - cancel_token = CancelToken() - await dispatcher.invoke(message, cancel_token) - - assert "test_name" in received_args - - @pytest.mark.asyncio - async def test_invoke_tracks_active_task(self): - """invoke should track active task.""" - peer = MockPeer() - - async def slow_handler(event: MessageEvent, ctx: Context): - await asyncio.sleep(0.1) - return None - - descriptor = HandlerDescriptor( - id="slow.handler", - trigger=CommandTrigger(command="slow"), - ) - handler = LoadedHandler( - descriptor=descriptor, - callable=slow_handler, - owner=MagicMock(), - legacy_context=None, - ) - - dispatcher = HandlerDispatcher( - plugin_id="test_plugin", - peer=peer, - handlers=[handler], - ) - - message = InvokeMessage( - id="msg_001", - capability="handler.invoke", - input={ - "handler_id": "slow.handler", - "event": { - "type": "message", - "session_id": "s1", - "user_id": "u1", - "platform": "test", - }, - }, - ) - - cancel_token = CancelToken() - - # Start invoke in background - task = asyncio.create_task(dispatcher.invoke(message, cancel_token)) - - # Give it time to start - await asyncio.sleep(0) - - # Should have active task during execution - # Note: might be empty if task completes quickly - - await task - - # After completion, should be cleared - assert "msg_001" not in dispatcher._active - - @pytest.mark.asyncio - async def test_invoke_wraps_legacy_astr_message_event(self): - """Annotated AstrMessageEvent handlers should receive the compat wrapper.""" - peer = MockPeer() - received_types = [] - replies = [] - - async def handler_func(event: AstrMessageEvent): - received_types.append(type(event)) - yield event.plain_result("legacy reply") - - async def track_send(session_id: str, text: str) -> None: - replies.append({"session_id": session_id, "text": text}) - - peer.send = track_send - - descriptor = HandlerDescriptor( - id="legacy.handler", - trigger=CommandTrigger(command="hello"), - ) - handler = LoadedHandler( - descriptor=descriptor, - callable=handler_func, - owner=MagicMock(), - legacy_context=None, - ) - - dispatcher = HandlerDispatcher( - plugin_id="test_plugin", - peer=peer, - handlers=[handler], - ) - - message = InvokeMessage( - id="msg_legacy", - capability="handler.invoke", - input={ - "handler_id": "legacy.handler", - "event": { - "text": "hello", - "session_id": "session-legacy", - "user_id": "user-1", - "platform": "test", - }, - }, - ) - - await dispatcher.invoke(message, CancelToken()) - - assert received_types == [AstrMessageEvent] - assert replies == [{"session_id": "session-legacy", "text": "legacy reply"}] - - @pytest.mark.asyncio - async def test_invoke_reply_falls_back_to_peer_send_for_sync_mock(self): - """invoke should fall back to peer.send when peer.invoke is a sync mock.""" - peer = MagicMock() - peer.remote_capability_map = {} - sent_messages = [] - - async def track_send(session_id: str, text: str) -> None: - sent_messages.append({"session_id": session_id, "text": text}) - - peer.send = track_send - - async def handler_func(event: MessageEvent, ctx: Context): - await event.reply("fallback") - - descriptor = HandlerDescriptor( - id="test.handler", - trigger=CommandTrigger(command="hello"), - ) - handler = LoadedHandler( - descriptor=descriptor, - callable=handler_func, - owner=MagicMock(), - legacy_context=None, - ) - - dispatcher = HandlerDispatcher( - plugin_id="test_plugin", - peer=peer, - handlers=[handler], - ) - - message = InvokeMessage( - id="msg_sync_mock", - capability="handler.invoke", - input={ - "handler_id": "test.handler", - "event": { - "text": "hello", - "session_id": "session-sync", - "user_id": "user-1", - "platform": "test", - }, - }, - ) - - await dispatcher.invoke(message, CancelToken()) - - assert sent_messages == [{"session_id": "session-sync", "text": "fallback"}] - - @pytest.mark.asyncio - async def test_invoke_routes_followup_message_to_session_waiter(self): - """compat session_waiter should capture the next message from the same session.""" - peer = MockPeer() - legacy_context = LegacyContext("test_plugin") - captured_replies = [] - - async def handler_func(event: AstrMessageEvent): - await event.send(MessageChain().message("请输入确认内容")) - - @session_waiter(timeout=0.2, record_history_chains=False) - async def waiter(controller: SessionController, ev: AstrMessageEvent): - captured_replies.append(ev.message_str) - await ev.send(MessageChain().message(f"收到:{ev.message_str}")) - controller.stop() - - await waiter(event) - - descriptor = HandlerDescriptor( - id="legacy.waiter", - trigger=CommandTrigger(command="ask"), - ) - handler = LoadedHandler( - descriptor=descriptor, - callable=handler_func, - owner=MagicMock(), - legacy_context=legacy_context, - ) - dispatcher = HandlerDispatcher( - plugin_id="test_plugin", - peer=peer, - handlers=[handler], - ) - - first_message = InvokeMessage( - id="msg_waiter_1", - capability="handler.invoke", - input={ - "handler_id": "legacy.waiter", - "event": { - "text": "ask", - "session_id": "session-waiter", - "user_id": "user-1", - "platform": "test", - }, - }, - ) - followup_message = InvokeMessage( - id="msg_waiter_2", - capability="handler.invoke", - input={ - "handler_id": "legacy.waiter", - "event": { - "text": "确认", - "session_id": "session-waiter", - "user_id": "user-1", - "platform": "test", - }, - }, - ) - - waiting_task = asyncio.create_task( - dispatcher.invoke(first_message, CancelToken()) - ) - await asyncio.sleep(0.05) - result = await dispatcher.invoke(followup_message, CancelToken()) - await waiting_task - - assert result == {} - assert captured_replies == ["确认"] - assert peer.sent_messages == [ - {"session_id": "session-waiter", "text": "请输入确认内容"}, - {"session_id": "session-waiter", "text": "收到:确认"}, - ] - - @pytest.mark.asyncio - async def test_session_waiter_trigger_routes_by_explicit_session_id(self): - """SessionWaiter.trigger() should forward a message to the active waiter by key.""" - peer = MockPeer() - legacy_context = LegacyContext("test_plugin") - captured_replies = [] - - async def handler_func(event: AstrMessageEvent): - @session_waiter(timeout=0.2) - async def waiter(controller: SessionController, ev: AstrMessageEvent): - captured_replies.append(ev.message_str) - controller.stop() - - waiting_task = asyncio.create_task(waiter(event)) - await asyncio.sleep(0) - await SessionWaiter.trigger( - event.unified_msg_origin, - AstrMessageEvent( - text="显式触发", - session_id=event.get_session_id(), - user_id=event.get_sender_id(), - platform=event.get_platform_name(), - context=event._context, - ), - ) - await waiting_task - - descriptor = HandlerDescriptor( - id="legacy.waiter.trigger", - trigger=CommandTrigger(command="ask"), - ) - handler = LoadedHandler( - descriptor=descriptor, - callable=handler_func, - owner=MagicMock(), - legacy_context=legacy_context, - ) - dispatcher = HandlerDispatcher( - plugin_id="test_plugin", - peer=peer, - handlers=[handler], - ) - - message = InvokeMessage( - id="msg_waiter_trigger", - capability="handler.invoke", - input={ - "handler_id": "legacy.waiter.trigger", - "event": { - "text": "ask", - "session_id": "session-trigger", - "user_id": "user-1", - "platform": "test", - }, - }, - ) - - await dispatcher.invoke(message, CancelToken()) - - assert captured_replies == ["显式触发"] - - -class TestHandlerDispatcherLegacyCompat: - """Tests for legacy compat hooks and filters during dispatch.""" - - @pytest.mark.asyncio - async def test_prebuilt_legacy_runtime_adapter_can_drive_filtering(self): - """Dispatcher should prefer the adapter boundary instead of reading raw legacy fields.""" - peer = MockPeer() - legacy_context = LegacyContext("test_plugin") - called = [] - - class RejectAll(CustomFilter): - def filter(self, event: AstrMessageEvent, cfg) -> bool: - return False - - async def handler_func(event: AstrMessageEvent): - called.append(event.message_str) - - descriptor = HandlerDescriptor( - id="legacy.adapter.filtered", - trigger=CommandTrigger(command="ask"), - ) - handler = LoadedHandler( - descriptor=descriptor, - callable=handler_func, - owner=MagicMock(), - legacy_context=None, - ) - handler.legacy_runtime = LegacyRuntimeAdapter( - legacy_context=legacy_context, - filters=[RejectAll()], - ) - dispatcher = HandlerDispatcher( - plugin_id="test_plugin", - peer=peer, - handlers=[handler], - ) - - message = InvokeMessage( - id="msg_filtered_adapter", - capability="handler.invoke", - input={ - "handler_id": "legacy.adapter.filtered", - "event": { - "text": "ask", - "session_id": "session-filtered", - "user_id": "user-1", - "platform": "test", - }, - }, - ) - - result = await dispatcher.invoke(message, CancelToken()) - - assert result == {} - assert called == [] - assert peer.sent_messages == [] - - @pytest.mark.asyncio - async def test_custom_filter_can_skip_legacy_handler_invocation(self): - """Legacy custom filters should prevent handler execution when they reject the event.""" - peer = MockPeer() - legacy_context = LegacyContext("test_plugin") - called = [] - - class RejectAll(CustomFilter): - def filter(self, event: AstrMessageEvent, cfg) -> bool: - return False - - async def handler_func(event: AstrMessageEvent): - called.append(event.message_str) - - descriptor = HandlerDescriptor( - id="legacy.filtered", - trigger=CommandTrigger(command="ask"), - ) - handler = LoadedHandler( - descriptor=descriptor, - callable=handler_func, - owner=MagicMock(), - legacy_context=legacy_context, - compat_filters=[RejectAll()], - ) - dispatcher = HandlerDispatcher( - plugin_id="test_plugin", - peer=peer, - handlers=[handler], - ) - - message = InvokeMessage( - id="msg_filtered", - capability="handler.invoke", - input={ - "handler_id": "legacy.filtered", - "event": { - "text": "ask", - "session_id": "session-filtered", - "user_id": "user-1", - "platform": "test", - }, - }, - ) - - result = await dispatcher.invoke(message, CancelToken()) - - assert result == {} - assert called == [] - assert peer.sent_messages == [] - - @pytest.mark.asyncio - async def test_decorating_and_after_send_hooks_run_for_legacy_results(self): - """Legacy decorating/send hooks should be applied around compat result sending.""" - peer = MockPeer() - legacy_context = LegacyContext("test_plugin") - observed_results = [] - - class CompatHooks: - @on_decorating_result() - async def decorate(self, event: AstrMessageEvent): - event.set_result("decorated result") - - @after_message_sent() - async def after_send(self, event: AstrMessageEvent): - result = event.get_result() - observed_results.append(result.get_plain_text() if result else "") - - legacy_context._register_component(CompatHooks()) - - async def handler_func(event: AstrMessageEvent): - return "raw result" - - descriptor = HandlerDescriptor( - id="legacy.decorated", - trigger=CommandTrigger(command="decorate"), - ) - handler = LoadedHandler( - descriptor=descriptor, - callable=handler_func, - owner=MagicMock(), - legacy_context=legacy_context, - ) - dispatcher = HandlerDispatcher( - plugin_id="test_plugin", - peer=peer, - handlers=[handler], - ) - - message = InvokeMessage( - id="msg_decorated", - capability="handler.invoke", - input={ - "handler_id": "legacy.decorated", - "event": { - "text": "decorate", - "session_id": "session-decorated", - "user_id": "user-1", - "platform": "test", - }, - }, - ) - - await dispatcher.invoke(message, CancelToken()) - - assert peer.sent_messages == [ - {"session_id": "session-decorated", "text": "decorated result"} - ] - assert observed_results == ["decorated result"] - - -class TestHandlerDispatcherCancel: - """Tests for HandlerDispatcher.cancel method.""" - - @pytest.mark.asyncio - async def test_cancel_stops_active_task(self): - """cancel should stop the active task.""" - peer = MockPeer() - - cancelled = [] - - async def slow_handler(event: MessageEvent, ctx: Context): - try: - await asyncio.sleep(10) # Long sleep - except asyncio.CancelledError: - cancelled.append(True) - raise - - descriptor = HandlerDescriptor( - id="slow.handler", - trigger=CommandTrigger(command="slow"), - ) - handler = LoadedHandler( - descriptor=descriptor, - callable=slow_handler, - owner=MagicMock(), - legacy_context=None, - ) - - dispatcher = HandlerDispatcher( - plugin_id="test_plugin", - peer=peer, - handlers=[handler], - ) - - message = InvokeMessage( - id="msg_001", - capability="handler.invoke", - input={ - "handler_id": "slow.handler", - "event": { - "type": "message", - "session_id": "s1", - "user_id": "u1", - "platform": "test", - }, - }, - ) - - cancel_token = CancelToken() - - # Start invoke - task = asyncio.create_task(dispatcher.invoke(message, cancel_token)) - - # Wait for task to be active - await asyncio.sleep(0.05) - - # Cancel - await dispatcher.cancel("msg_001") - - # Task should be cancelled - await asyncio.sleep(0.05) - - assert cancelled or task.cancelled() or task.done() - - @pytest.mark.asyncio - async def test_cancel_unknown_request_does_nothing(self): - """cancel should do nothing for unknown request.""" - peer = MockPeer() - dispatcher = HandlerDispatcher( - plugin_id="test_plugin", - peer=peer, - handlers=[], - ) - - # Should not raise - await dispatcher.cancel("unknown_request") - - -class TestHandlerDispatcherBuildArgs: - """Tests for HandlerDispatcher._build_args method.""" - - def test_build_args_event_parameter(self): - """_build_args should inject event parameter.""" - peer = MockPeer() - dispatcher = HandlerDispatcher( - plugin_id="test_plugin", - peer=peer, - handlers=[], - ) - - def handler(event: MessageEvent, ctx: Context): - pass - - event = create_message_event() - ctx = Context(peer=peer, plugin_id="test", cancel_token=CancelToken()) - - args = dispatcher._build_args(handler, event, ctx) - - assert args[0] is event - - def test_build_args_ctx_parameter(self): - """_build_args should inject ctx/context parameter.""" - peer = MockPeer() - dispatcher = HandlerDispatcher( - plugin_id="test_plugin", - peer=peer, - handlers=[], - ) - - def handler(event: MessageEvent, ctx: Context): - pass - - event = create_message_event() - ctx = Context(peer=peer, plugin_id="test", cancel_token=CancelToken()) - - args = dispatcher._build_args(handler, event, ctx) - - assert args[1] is ctx - - def test_build_args_legacy_args(self): - """_build_args should inject legacy args.""" - peer = MockPeer() - dispatcher = HandlerDispatcher( - plugin_id="test_plugin", - peer=peer, - handlers=[], - ) - - def handler(event: MessageEvent, ctx: Context, custom_arg: str): - pass - - event = create_message_event() - ctx = Context(peer=peer, plugin_id="test", cancel_token=CancelToken()) - - args = dispatcher._build_args(handler, event, ctx, {"custom_arg": "value"}) - - assert args[2] == "value" - - def test_build_args_skip_keyword_only(self): - """_build_args should skip keyword-only parameters.""" - peer = MockPeer() - dispatcher = HandlerDispatcher( - plugin_id="test_plugin", - peer=peer, - handlers=[], - ) - - def handler(event: MessageEvent, *, optional: str = "default"): - pass - - event = create_message_event() - ctx = Context(peer=peer, plugin_id="test", cancel_token=CancelToken()) - - args = dispatcher._build_args(handler, event, ctx) - - # Should only have event - assert len(args) == 1 - - -class TestHandlerDispatcherConsumeResult: - """Tests for HandlerDispatcher._consume_legacy_result method.""" - - @pytest.mark.asyncio - async def test_consume_plain_text_result(self): - """_consume_legacy_result should handle PlainTextResult.""" - peer = MockPeer() - dispatcher = HandlerDispatcher( - plugin_id="test_plugin", - peer=peer, - handlers=[], - ) - - replies = [] - event = create_message_event() - - # reply_handler 必须是异步的 - async def async_reply(text: str) -> None: - replies.append(text) - - event._reply_handler = async_reply - - result = PlainTextResult(text="plain text") - await dispatcher._consume_legacy_result(result, event) - - assert "plain text" in replies - - @pytest.mark.asyncio - async def test_consume_string(self): - """_consume_legacy_result should handle string.""" - peer = MockPeer() - dispatcher = HandlerDispatcher( - plugin_id="test_plugin", - peer=peer, - handlers=[], - ) - - replies = [] - event = create_message_event() - - # reply_handler 必须是异步的 - async def async_reply(text: str) -> None: - replies.append(text) - - event._reply_handler = async_reply - - await dispatcher._consume_legacy_result("string reply", event) - - assert "string reply" in replies - - @pytest.mark.asyncio - async def test_consume_dict_with_text(self): - """_consume_legacy_result should handle dict with text.""" - peer = MockPeer() - dispatcher = HandlerDispatcher( - plugin_id="test_plugin", - peer=peer, - handlers=[], - ) - - replies = [] - event = create_message_event() - - # reply_handler 必须是异步的 - async def async_reply(text: str) -> None: - replies.append(text) - - event._reply_handler = async_reply - - await dispatcher._consume_legacy_result({"text": "dict reply"}, event) - - assert "dict reply" in replies - - @pytest.mark.asyncio - async def test_consume_other_type_ignored(self): - """_consume_legacy_result should ignore other types.""" - peer = MockPeer() - dispatcher = HandlerDispatcher( - plugin_id="test_plugin", - peer=peer, - handlers=[], - ) - - event = create_message_event() - event._reply_handler = MagicMock() - - # Should not raise - await dispatcher._consume_legacy_result(123, event) - await dispatcher._consume_legacy_result(None, event) - - @pytest.mark.asyncio - async def test_consume_message_chain_uses_platform_send_chain(self): - """_consume_legacy_result should preserve rich chains when ctx is available.""" - peer = MockPeer() - dispatcher = HandlerDispatcher( - plugin_id="test_plugin", - peer=peer, - handlers=[], - ) - - event = create_message_event() - ctx = Context(peer=peer, plugin_id="test", cancel_token=CancelToken()) - chain = MessageChain( - [ - Comp.Plain(text="hello"), - Comp.Image(file="https://example.com/image.png"), - ] - ) - - await dispatcher._consume_legacy_result(chain, event, ctx) - - assert peer.sent_messages == [ - { - "session_id": "session-1", - "chain": [ - {"type": "Plain", "text": "hello"}, - {"type": "Image", "file": "https://example.com/image.png"}, - ], - } - ] - - -class TestHandlerDispatcherHandleError: - """Tests for HandlerDispatcher._handle_error method.""" - - @pytest.mark.asyncio - async def test_handle_error_with_on_error_method(self): - """_handle_error should call owner.on_error if available.""" - peer = MockPeer() - - errors_handled = [] - - class OwnerWithOnError: - async def on_error(self, exc: Exception, event: MessageEvent, ctx: Context): - errors_handled.append(exc) - - owner = OwnerWithOnError() - dispatcher = HandlerDispatcher( - plugin_id="test_plugin", - peer=peer, - handlers=[], - ) - - event = create_message_event() - ctx = Context(peer=peer, plugin_id="test", cancel_token=CancelToken()) - exc = ValueError("test error") - - await dispatcher._handle_error(owner, exc, event, ctx) - - assert exc in errors_handled - - @pytest.mark.asyncio - async def test_handle_error_without_on_error_method(self): - """_handle_error should use Star.on_error if owner has no on_error.""" - peer = MockPeer() - dispatcher = HandlerDispatcher( - plugin_id="test_plugin", - peer=peer, - handlers=[], - ) - - event = create_message_event() - ctx = Context(peer=peer, plugin_id="test", cancel_token=CancelToken()) - exc = ValueError("test error") - - # owner 是 MagicMock,on_error 方法返回 MagicMock 而不是协程 - # 但 _handle_error 会 await owner.on_error(...) - # 所以我们需要让 owner.on_error 返回一个协程 - owner = MagicMock() - owner.on_error = AsyncMock() - - # Should not raise - await dispatcher._handle_error(owner, exc, event, ctx) - - -class TestHandlerDispatcherRunHandler: - """Tests for HandlerDispatcher._run_handler method.""" - - @pytest.mark.asyncio - async def test_run_handler_sync_function(self): - """_run_handler should handle sync function.""" - peer = MockPeer() - - called = [] - - def sync_handler(event: MessageEvent, ctx: Context): - called.append(True) - - descriptor = HandlerDescriptor( - id="sync.handler", - trigger=CommandTrigger(command="sync"), - ) - handler = LoadedHandler( - descriptor=descriptor, - callable=sync_handler, - owner=MagicMock(), - legacy_context=None, - ) - - dispatcher = HandlerDispatcher( - plugin_id="test_plugin", - peer=peer, - handlers=[handler], - ) - - event = create_message_event() - ctx = Context(peer=peer, plugin_id="test", cancel_token=CancelToken()) - - await dispatcher._run_handler(handler, event, ctx) - - assert called - - @pytest.mark.asyncio - async def test_run_handler_async_function(self): - """_run_handler should handle async function.""" - peer = MockPeer() - - called = [] - - async def async_handler(event: MessageEvent, ctx: Context): - called.append(True) - - descriptor = HandlerDescriptor( - id="async.handler", - trigger=CommandTrigger(command="async"), - ) - handler = LoadedHandler( - descriptor=descriptor, - callable=async_handler, - owner=MagicMock(), - legacy_context=None, - ) - - dispatcher = HandlerDispatcher( - plugin_id="test_plugin", - peer=peer, - handlers=[handler], - ) - - event = create_message_event() - ctx = Context(peer=peer, plugin_id="test", cancel_token=CancelToken()) - - await dispatcher._run_handler(handler, event, ctx) - - assert called - - @pytest.mark.asyncio - async def test_run_handler_async_generator(self): - """_run_handler should handle async generator.""" - peer = MockPeer() - - replies = [] - - async def gen_handler(event: MessageEvent, ctx: Context): - yield "first" - yield "second" - - descriptor = HandlerDescriptor( - id="gen.handler", - trigger=CommandTrigger(command="gen"), - ) - handler = LoadedHandler( - descriptor=descriptor, - callable=gen_handler, - owner=MagicMock(), - legacy_context=None, - ) - - dispatcher = HandlerDispatcher( - plugin_id="test_plugin", - peer=peer, - handlers=[handler], - ) - - event = create_message_event() - - # reply_handler 必须是异步的 - async def async_reply(text: str) -> None: - replies.append(text) - - event._reply_handler = async_reply - ctx = Context(peer=peer, plugin_id="test", cancel_token=CancelToken()) - - await dispatcher._run_handler(handler, event, ctx) - - assert "first" in replies - assert "second" in replies - - @pytest.mark.asyncio - async def test_run_handler_with_exception(self): - """_run_handler should handle exceptions.""" - peer = MockPeer() - - async def failing_handler(event: MessageEvent, ctx: Context): - raise ValueError("handler error") - - descriptor = HandlerDescriptor( - id="failing.handler", - trigger=CommandTrigger(command="fail"), - ) - # owner.on_error 需要是异步的 - owner = MagicMock() - owner.on_error = AsyncMock() - handler = LoadedHandler( - descriptor=descriptor, - callable=failing_handler, - owner=owner, - legacy_context=None, - ) - - dispatcher = HandlerDispatcher( - plugin_id="test_plugin", - peer=peer, - handlers=[handler], - ) - - event = create_message_event() - ctx = Context(peer=peer, plugin_id="test", cancel_token=CancelToken()) - - with pytest.raises(ValueError, match="handler error"): - await dispatcher._run_handler(handler, event, ctx) diff --git a/tests_v4/test_legacy_adapter.py b/tests_v4/test_legacy_adapter.py deleted file mode 100644 index dfbcaee53..000000000 --- a/tests_v4/test_legacy_adapter.py +++ /dev/null @@ -1,253 +0,0 @@ -from __future__ import annotations - -import unittest - -from astrbot_sdk.protocol.descriptors import CommandTrigger, HandlerDescriptor -from astrbot_sdk.protocol.legacy_adapter import ( - LEGACY_CONTEXT_CAPABILITY, - LEGACY_HANDSHAKE_METADATA_KEY, - LegacyAdapter, -) -from astrbot_sdk.protocol.messages import ( - EventMessage, - InitializeMessage, - PeerInfo, - ResultMessage, -) - - -class LegacyAdapterTest(unittest.TestCase): - def test_call_handler_roundtrip_preserves_handler_name_in_stream_notifications( - self, - ) -> None: - adapter = LegacyAdapter() - - invoke = adapter.legacy_to_v4( - { - "jsonrpc": "2.0", - "id": "call-1", - "method": "call_handler", - "params": { - "handler_full_name": "commands.demo:MyPlugin.handle", - "event": {"text": "/hello"}, - "args": {}, - }, - } - ) - - self.assertEqual(invoke.capability, "handler.invoke") - self.assertEqual(invoke.input["handler_id"], "commands.demo:MyPlugin.handle") - - started = adapter.event_to_legacy_notification( - EventMessage(id="call-1", phase="started") - ) - delta = adapter.event_to_legacy_notification( - EventMessage(id="call-1", phase="delta", data={"text": "hi"}) - ) - completed = adapter.event_to_legacy_notification( - EventMessage(id="call-1", phase="completed", output={"text": "hi"}) - ) - response = adapter.result_to_legacy_response( - ResultMessage(id="call-1", success=True, output={"handled_by": "demo"}) - ) - - self.assertEqual(started["method"], "handler_stream_start") - self.assertEqual( - delta["params"]["handler_full_name"], "commands.demo:MyPlugin.handle" - ) - self.assertEqual(delta["params"]["data"], {"text": "hi"}) - self.assertEqual(completed["method"], "handler_stream_end") - self.assertEqual(response["result"], {"handled_by": "demo"}) - - def test_call_context_function_maps_to_internal_capability(self) -> None: - adapter = LegacyAdapter() - message = adapter.legacy_to_v4( - { - "jsonrpc": "2.0", - "id": "ctx-1", - "method": "call_context_function", - "params": { - "name": "ConversationManager.new_conversation", - "args": {"unified_msg_origin": "session-1"}, - }, - } - ) - - self.assertEqual(message.capability, LEGACY_CONTEXT_CAPABILITY) - self.assertEqual(message.input["name"], "ConversationManager.new_conversation") - self.assertEqual( - message.input["args"], - {"unified_msg_origin": "session-1"}, - ) - - legacy_request = adapter.invoke_to_legacy_request(message) - self.assertEqual(legacy_request["method"], "call_context_function") - self.assertEqual( - legacy_request["params"]["name"], - "ConversationManager.new_conversation", - ) - - def test_legacy_handler_stream_notifications_map_back_to_v4_events(self) -> None: - adapter = LegacyAdapter() - - started = adapter.legacy_to_v4( - { - "jsonrpc": "2.0", - "method": "handler_stream_start", - "params": { - "id": "call-2", - "handler_full_name": "commands.demo:MyPlugin.handle", - }, - } - ) - delta = adapter.legacy_to_v4( - { - "jsonrpc": "2.0", - "method": "handler_stream_update", - "params": { - "id": "call-2", - "handler_full_name": "commands.demo:MyPlugin.handle", - "data": {"text": "partial"}, - }, - } - ) - failed = adapter.legacy_to_v4( - { - "jsonrpc": "2.0", - "method": "handler_stream_end", - "params": { - "id": "call-2", - "handler_full_name": "commands.demo:MyPlugin.handle", - "error": { - "code": "cancelled", - "message": "调用被取消", - "hint": "", - "retryable": False, - }, - }, - } - ) - - self.assertEqual(started.phase, "started") - self.assertEqual(delta.phase, "delta") - self.assertEqual(delta.data, {"text": "partial"}) - self.assertEqual(failed.phase, "failed") - self.assertEqual(failed.error.code, "cancelled") - - def test_handshake_payload_maps_to_initialize_and_roundtrips(self) -> None: - adapter = LegacyAdapter(legacy_peer_name="legacy-plugin") - legacy_payload = { - "plugin_one.main": { - "name": "plugin_one", - "author": "tester", - "desc": "legacy", - "version": "0.1.0", - "repo": None, - "module_path": "plugin_one.main", - "root_dir_name": "plugin_one", - "reserved": False, - "activated": True, - "config": None, - "star_handler_full_names": [ - "commands.plugin_one:SampleCommand.handle_plugin_one" - ], - "display_name": "plugin_one", - "logo_path": None, - "handlers": [ - { - "event_type": 3, - "handler_full_name": "commands.plugin_one:SampleCommand.handle_plugin_one", - "handler_name": "handle_plugin_one", - "handler_module_path": "commands.plugin_one:SampleCommand", - "desc": "", - "extras_configs": {"priority": 7}, - } - ], - } - } - - initialize = adapter.legacy_to_v4( - { - "jsonrpc": "2.0", - "id": "handshake-1", - "result": legacy_payload, - } - ) - - self.assertIsInstance(initialize, InitializeMessage) - self.assertEqual(initialize.peer.name, "plugin_one") - self.assertEqual( - initialize.handlers[0].id, - "commands.plugin_one:SampleCommand.handle_plugin_one", - ) - self.assertEqual(initialize.handlers[0].priority, 7) - self.assertEqual( - initialize.metadata[LEGACY_HANDSHAKE_METADATA_KEY], - legacy_payload, - ) - - roundtrip = adapter.initialize_to_legacy_handshake_response( - initialize, - request_id="handshake-1", - ) - self.assertEqual(roundtrip["result"], legacy_payload) - - def test_handshake_error_becomes_initialize_failure(self) -> None: - adapter = LegacyAdapter() - adapter.legacy_to_v4( - { - "jsonrpc": "2.0", - "id": "handshake-2", - "method": "handshake", - "params": {}, - } - ) - - result = adapter.legacy_to_v4( - { - "jsonrpc": "2.0", - "id": "handshake-2", - "error": { - "code": -32000, - "message": "boom", - }, - } - ) - - self.assertIsInstance(result, ResultMessage) - self.assertEqual(result.kind, "initialize_result") - self.assertFalse(result.success) - self.assertEqual(result.error.code, "legacy_rpc_error") - - def test_initialize_can_synthesize_legacy_handshake_payload(self) -> None: - adapter = LegacyAdapter() - initialize = InitializeMessage( - id="msg_001", - protocol_version="1.0", - peer=PeerInfo(name="v4-plugin", role="plugin", version="1.2.0"), - handlers=[ - HandlerDescriptor( - id="commands.sample:MyPlugin.hello", - trigger=CommandTrigger(command="hello", description="hello"), - priority=3, - ) - ], - metadata={"plugin_id": "v4-plugin"}, - ) - - payload = adapter.initialize_to_legacy_handshake_response( - initialize, - request_id="handshake-3", - ) - star_payload = payload["result"]["v4-plugin.main"] - - self.assertEqual(star_payload["name"], "v4-plugin") - self.assertEqual( - star_payload["handlers"][0]["handler_full_name"], - "commands.sample:MyPlugin.hello", - ) - self.assertEqual(star_payload["handlers"][0]["extras_configs"]["priority"], 3) - - -if __name__ == "__main__": - unittest.main() diff --git a/tests_v4/test_legacy_context_metadata.py b/tests_v4/test_legacy_context_metadata.py deleted file mode 100644 index 513622644..000000000 --- a/tests_v4/test_legacy_context_metadata.py +++ /dev/null @@ -1,119 +0,0 @@ -"""Tests for LegacyContext metadata methods.""" - -from __future__ import annotations - -from unittest.mock import AsyncMock, MagicMock - -import pytest - -from astrbot_sdk._legacy_api import LegacyContext -from astrbot_sdk.context import Context as NewContext -from astrbot_sdk.clients.metadata import PluginMetadata - - -class TestLegacyContextMetadataMethods: - """Tests for LegacyContext.get_registered_star and get_all_stars.""" - - @pytest.fixture - def legacy_context(self): - """Create LegacyContext instance.""" - return LegacyContext("test_plugin") - - @pytest.fixture - def mock_runtime_context(self): - """Create mock runtime context with metadata client.""" - context = MagicMock(spec=NewContext) - context.metadata = MagicMock() - context.metadata.get_plugin = AsyncMock() - context.metadata.list_plugins = AsyncMock() - return context - - @pytest.mark.asyncio - async def test_get_registered_star_returns_plugin_metadata( - self, legacy_context, mock_runtime_context - ): - """get_registered_star should return plugin metadata.""" - expected = PluginMetadata( - name="target_plugin", - display_name="Target Plugin", - description="Test", - author="test", - version="1.0.0", - ) - mock_runtime_context.metadata.get_plugin.return_value = expected - legacy_context.bind_runtime_context(mock_runtime_context) - - result = await legacy_context.get_registered_star("target_plugin") - - mock_runtime_context.metadata.get_plugin.assert_called_once_with( - "target_plugin" - ) - assert result == expected - - @pytest.mark.asyncio - async def test_get_registered_star_returns_none_when_not_found( - self, legacy_context, mock_runtime_context - ): - """get_registered_star should return None when plugin not found.""" - mock_runtime_context.metadata.get_plugin.return_value = None - legacy_context.bind_runtime_context(mock_runtime_context) - - result = await legacy_context.get_registered_star("nonexistent") - - assert result is None - - @pytest.mark.asyncio - async def test_get_registered_star_raises_without_runtime_context( - self, legacy_context - ): - """get_registered_star should raise when runtime context not bound.""" - with pytest.raises(RuntimeError, match="尚未绑定运行时 Context"): - await legacy_context.get_registered_star("any_plugin") - - @pytest.mark.asyncio - async def test_get_all_stars_returns_list( - self, legacy_context, mock_runtime_context - ): - """get_all_stars should return list of plugin metadata.""" - expected = [ - PluginMetadata( - name="plugin1", - display_name="Plugin 1", - description="Test", - author="a1", - version="1.0", - ), - PluginMetadata( - name="plugin2", - display_name="Plugin 2", - description="Test", - author="a2", - version="2.0", - ), - ] - mock_runtime_context.metadata.list_plugins.return_value = expected - legacy_context.bind_runtime_context(mock_runtime_context) - - result = await legacy_context.get_all_stars() - - mock_runtime_context.metadata.list_plugins.assert_called_once() - assert result == expected - assert len(result) == 2 - - @pytest.mark.asyncio - async def test_get_all_stars_returns_empty_list( - self, legacy_context, mock_runtime_context - ): - """get_all_stars should return empty list when no plugins.""" - mock_runtime_context.metadata.list_plugins.return_value = [] - legacy_context.bind_runtime_context(mock_runtime_context) - - result = await legacy_context.get_all_stars() - - assert result == [] - - @pytest.mark.asyncio - async def test_get_all_stars_raises_without_runtime_context(self, legacy_context): - """get_all_stars should raise when runtime context not bound.""" - with pytest.raises(RuntimeError, match="尚未绑定运行时 Context"): - await legacy_context.get_all_stars() diff --git a/tests_v4/test_legacy_loader.py b/tests_v4/test_legacy_loader.py deleted file mode 100644 index 8fab4f35c..000000000 --- a/tests_v4/test_legacy_loader.py +++ /dev/null @@ -1,170 +0,0 @@ -"""Tests for the private legacy loader helpers.""" - -from __future__ import annotations - -import tempfile -import textwrap -from pathlib import Path - -import yaml - -from astrbot_sdk._legacy_loader import ( - build_legacy_manifest, - load_legacy_main_component_classes, - load_plugin_manifest_payload, - looks_like_legacy_plugin, - resolve_plugin_component_classes, -) - - -def _read_yaml(path: Path) -> dict: - return yaml.safe_load(path.read_text(encoding="utf-8")) or {} - - -def test_looks_like_legacy_plugin_requires_main_without_manifest(): - with tempfile.TemporaryDirectory() as temp_dir: - plugin_dir = Path(temp_dir) / "legacy_plugin" - plugin_dir.mkdir() - (plugin_dir / "main.py").write_text("print('x')", encoding="utf-8") - - assert looks_like_legacy_plugin(plugin_dir) is True - - (plugin_dir / "plugin.yaml").write_text("name: modern", encoding="utf-8") - assert looks_like_legacy_plugin(plugin_dir) is False - - -def test_build_legacy_manifest_uses_metadata_and_marks_legacy_main(): - with tempfile.TemporaryDirectory() as temp_dir: - plugin_dir = Path(temp_dir) / "legacy_plugin" - plugin_dir.mkdir() - metadata_path = plugin_dir / "metadata.yaml" - metadata_path.write_text( - yaml.dump({"name": "legacy_demo", "author": "tester", "version": "1.0.0"}), - encoding="utf-8", - ) - - manifest_path, manifest_data = build_legacy_manifest( - plugin_dir, - read_yaml=_read_yaml, - default_python_version="3.12", - manifest_flag_key="__legacy_main__", - ) - - assert manifest_path == metadata_path - assert manifest_data["name"] == "legacy_demo" - assert manifest_data["runtime"]["python"] == "3.12" - assert manifest_data["__legacy_main__"] is True - - -def test_load_legacy_main_component_classes_supports_relative_imports(): - with tempfile.TemporaryDirectory() as temp_dir: - plugin_dir = Path(temp_dir) / "legacy_plugin" - plugin_dir.mkdir() - (plugin_dir / "src").mkdir() - (plugin_dir / "src" / "helper.py").write_text( - 'VALUE = "legacy-ok"\n', - encoding="utf-8", - ) - (plugin_dir / "main.py").write_text( - textwrap.dedent( - """\ - from astrbot_sdk.api.star import Star - from .src.helper import VALUE - - - class LegacyPlugin(Star): - helper_value = VALUE - """ - ), - encoding="utf-8", - ) - - classes = load_legacy_main_component_classes( - plugin_name="legacy-plugin", - plugin_dir=plugin_dir, - ) - - assert [cls.__name__ for cls in classes] == ["LegacyPlugin"] - assert classes[0].helper_value == "legacy-ok" - - -def test_load_legacy_main_component_classes_preserves_definition_order(): - with tempfile.TemporaryDirectory() as temp_dir: - plugin_dir = Path(temp_dir) / "legacy_plugin" - plugin_dir.mkdir() - (plugin_dir / "main.py").write_text( - textwrap.dedent( - """\ - from astrbot_sdk.api.star import Star - - - class ZebraComponent(Star): - pass - - - class AlphaComponent(Star): - pass - """ - ), - encoding="utf-8", - ) - - classes = load_legacy_main_component_classes( - plugin_name="legacy-plugin", - plugin_dir=plugin_dir, - ) - - assert [cls.__name__ for cls in classes] == [ - "ZebraComponent", - "AlphaComponent", - ] - - -def test_load_plugin_manifest_payload_prefers_plugin_yaml_when_present(): - with tempfile.TemporaryDirectory() as temp_dir: - plugin_dir = Path(temp_dir) / "plugin" - plugin_dir.mkdir() - manifest_path = plugin_dir / "plugin.yaml" - manifest_path.write_text( - yaml.dump({"name": "modern_plugin", "runtime": {"python": "3.12"}}), - encoding="utf-8", - ) - - resolved_path, manifest_data = load_plugin_manifest_payload( - plugin_dir, - read_yaml=_read_yaml, - default_python_version="3.13", - manifest_flag_key="__legacy_main__", - ) - - assert resolved_path == manifest_path - assert manifest_data["name"] == "modern_plugin" - assert "__legacy_main__" not in manifest_data - - -def test_resolve_plugin_component_classes_falls_back_to_legacy_main(): - with tempfile.TemporaryDirectory() as temp_dir: - plugin_dir = Path(temp_dir) / "legacy_plugin" - plugin_dir.mkdir() - (plugin_dir / "main.py").write_text( - textwrap.dedent( - """\ - from astrbot_sdk.api.star import Star - - - class LegacyPlugin(Star): - pass - """ - ), - encoding="utf-8", - ) - - classes = resolve_plugin_component_classes( - plugin_name="legacy_plugin", - plugin_dir=plugin_dir, - manifest_data={"components": [], "__legacy_main__": True}, - manifest_flag_key="__legacy_main__", - import_string=lambda path, plugin_dir=None: None, - ) - - assert [cls.__name__ for cls in classes] == ["LegacyPlugin"] diff --git a/tests_v4/test_legacy_plugin_integration.py b/tests_v4/test_legacy_plugin_integration.py deleted file mode 100644 index 77e25ff37..000000000 --- a/tests_v4/test_legacy_plugin_integration.py +++ /dev/null @@ -1,541 +0,0 @@ -"""旧版插件兼容性集成测试。 - -测试目标:验证 test_plugin 目录中的旧版插件能够正确加载和运行。 -""" - -from __future__ import annotations - -import sys -import tempfile -import textwrap -from pathlib import Path - -import pytest -import yaml - -from astrbot_sdk.protocol.descriptors import CommandTrigger, MessageTrigger -from astrbot_sdk.runtime.loader import ( - load_plugin, - load_plugin_spec, -) - - -class TestLegacyPluginImports: - """测试旧版 API 导入路径是否可用。""" - - def test_import_command_component(self): - """测试导入 CommandComponent。""" - from astrbot_sdk.api.components.command import CommandComponent - - assert CommandComponent is not None - - def test_import_legacy_context(self): - """测试导入 Legacy Context。""" - from astrbot_sdk.api.star.context import Context - - assert Context is not None - - def test_import_filter_namespace(self): - """测试导入 filter 命名空间。""" - from astrbot_sdk.api.event import filter - - assert hasattr(filter, "command") - assert hasattr(filter, "regex") - assert hasattr(filter, "permission") - assert hasattr(filter, "event_message_type") - assert hasattr(filter, "platform_adapter_type") - - def test_import_astr_message_event(self): - """测试导入 AstrMessageEvent。""" - from astrbot_sdk.api.event import AstrMessageEvent - - assert AstrMessageEvent is not None - - def test_import_message_chain(self): - """测试导入 MessageChain。""" - from astrbot_sdk.api.message import MessageChain - - assert MessageChain is not None - - def test_import_message_components(self): - """测试导入消息组件。""" - from astrbot_sdk.api.message_components import ( - At, - AtAll, - Face, - Image, - Plain, - Reply, - ) - - assert Plain is not None - assert Image is not None - assert At is not None - assert AtAll is not None - assert Reply is not None - assert Face is not None - - -class TestMessageChainFeatures: - """测试 MessageChain 功能。""" - - def test_message_chain_builder(self): - """测试 MessageChain 构建器模式。""" - from astrbot_sdk.api.message import MessageChain - - chain = ( - MessageChain() - .message("Hello ") - .at("user", "12345") - .message("!") - .url_image("https://example.com/img.png") - ) - - assert len(chain.chain) == 4 - payload = chain.to_payload() - assert len(payload) == 4 - - def test_is_plain_text_only(self): - """测试纯文本检测。""" - from astrbot_sdk.api.message import MessageChain - - # 纯文本 - plain_chain = MessageChain().message("Hello").message(" World") - assert plain_chain.is_plain_text_only() is True - - # 包含非文本 - mixed_chain = MessageChain().message("Hello").at("user", "123") - assert mixed_chain.is_plain_text_only() is False - - def test_get_plain_text(self): - """测试获取纯文本。""" - from astrbot_sdk.api.message import MessageChain - - chain = MessageChain().message("Hello").message(" World") - assert chain.get_plain_text() == "Hello World" - - -class TestMessageComponents: - """测试消息组件。""" - - def test_plain_component(self): - """测试 Plain 组件。""" - from astrbot_sdk.api.message_components import Plain - - plain = Plain(text="Hello") - d = plain.to_dict() - - assert d["type"] == "Plain" - assert d["text"] == "Hello" - - def test_at_component_with_aliases(self): - """测试 At 组件支持旧版字段别名。""" - from astrbot_sdk.api.message_components import At - - # 新版字段 - at1 = At(user_id="123", user_name="test") - assert at1.user_id == "123" - assert at1.user_name == "test" - - # 旧版字段别名 (qq, name) - at2 = At.model_validate({"qq": "456", "name": "legacy_user"}) - assert at2.user_id == "456" - assert at2.user_name == "legacy_user" - - def test_image_from_url(self): - """测试 Image.fromURL 工厂方法。""" - from astrbot_sdk.api.message_components import Image - - img = Image.fromURL("https://example.com/test.png") - assert img.file == "https://example.com/test.png" - - -class TestFilterDecorators: - """测试 filter 装饰器。""" - - def test_command_decorator(self): - """测试 command 装饰器。""" - from astrbot_sdk.api.event import filter - - @filter.command("test", aliases=["t", "testing"]) - async def handler(event): - pass - - meta = getattr(handler, "__astrbot_handler_meta__", None) - assert meta is not None - assert isinstance(meta.trigger, CommandTrigger) - assert meta.trigger.command == "test" - assert "t" in meta.trigger.aliases - assert "testing" in meta.trigger.aliases - - def test_regex_decorator(self): - """测试 regex 装饰器。""" - from astrbot_sdk.api.event import filter - - @filter.regex(r"^ping.*") - async def handler(event): - pass - - meta = getattr(handler, "__astrbot_handler_meta__", None) - assert meta is not None - assert isinstance(meta.trigger, MessageTrigger) - assert meta.trigger.regex == r"^ping.*" - - def test_event_message_type_decorator(self): - """测试 event_message_type 装饰器。""" - from astrbot_sdk.api.event import filter - from astrbot_sdk.api.event.filter import EventMessageType - - @filter.event_message_type(EventMessageType.GROUP_MESSAGE) - @filter.command("group_cmd") - async def handler(event): - pass - - meta = getattr(handler, "__astrbot_handler_meta__", None) - assert meta is not None - assert isinstance(meta.trigger, CommandTrigger) - assert "group" in meta.trigger.message_types - - def test_platform_adapter_type_decorator(self): - """测试 platform_adapter_type 装饰器。""" - from astrbot_sdk.api.event import filter - - @filter.platform_adapter_type("aiocqhttp") - @filter.command("cqhttp_cmd") - async def handler(event): - pass - - meta = getattr(handler, "__astrbot_handler_meta__", None) - assert meta is not None - assert isinstance(meta.trigger, CommandTrigger) - assert "aiocqhttp" in meta.trigger.platforms - - -class TestLegacyContextFeatures: - """测试 LegacyContext 功能。""" - - def test_conversation_manager_exists(self): - """测试 conversation_manager 存在。""" - from astrbot_sdk._legacy_api import LegacyContext - - ctx = LegacyContext("test_plugin") - assert ctx.conversation_manager is not None - - def test_register_component(self): - """测试组件注册。""" - - class MockComponent: - def echo(self, text: str) -> str: - return f"echo: {text}" - - from astrbot_sdk._legacy_api import LegacyContext - - ctx = LegacyContext("test_plugin") - ctx._register_component(MockComponent()) - - assert "MockComponent" in ctx._registered_managers - assert "MockComponent.echo" in ctx._registered_functions - - -class TestLoadLegacyStylePlugin: - """测试加载旧版风格插件。""" - - def test_load_plugin_with_command_component(self): - """测试加载使用 CommandComponent 的插件。""" - with tempfile.TemporaryDirectory() as temp_dir: - plugin_dir = Path(temp_dir) / "test_plugin" - plugin_dir.mkdir() - manifest_path = plugin_dir / "plugin.yaml" - requirements_path = plugin_dir / "requirements.txt" - - # 创建 commands 目录 - commands_dir = plugin_dir / "commands" - commands_dir.mkdir() - (commands_dir / "__init__.py").write_text("", encoding="utf-8") - - # 创建使用旧版 API 的组件 - (commands_dir / "hello.py").write_text( - textwrap.dedent(""" - 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 HelloCommand(CommandComponent): - def __init__(self, context: Context): - self.context = context - - @filter.command("hello") - async def hello(self, event: AstrMessageEvent): - yield event.plain_result("Hello!") - """), - encoding="utf-8", - ) - - manifest_path.write_text( - yaml.dump( - { - "name": "test_plugin", - "runtime": {"python": "3.12"}, - "components": [ - { - "class": "commands.hello:HelloCommand", - "type": "command", - "name": "hello", - } - ], - } - ), - encoding="utf-8", - ) - requirements_path.write_text("", encoding="utf-8") - - spec = load_plugin_spec(plugin_dir) - - if str(plugin_dir) not in sys.path: - sys.path.insert(0, str(plugin_dir)) - - try: - loaded = load_plugin(spec) - - assert loaded.plugin.name == "test_plugin" - assert len(loaded.instances) == 1 - assert len(loaded.handlers) >= 1 - - # 验证 handler 触发器 - handler = loaded.handlers[0] - assert isinstance(handler.descriptor.trigger, CommandTrigger) - assert handler.descriptor.trigger.command == "hello" - - # 验证 LegacyContext 共享 - instance = loaded.instances[0] - assert instance.context is not None - assert instance.context.plugin_id == "test_plugin" - finally: - if str(plugin_dir) in sys.path: - sys.path.remove(str(plugin_dir)) - - def test_load_plugin_with_message_chain(self): - """测试加载使用 MessageChain 的插件。""" - with tempfile.TemporaryDirectory() as temp_dir: - plugin_dir = Path(temp_dir) / "chain_plugin" - plugin_dir.mkdir() - manifest_path = plugin_dir / "plugin.yaml" - requirements_path = plugin_dir / "requirements.txt" - - # 使用唯一的模块名避免与其他测试冲突 - handlers_dir = plugin_dir / "chain_handlers" - handlers_dir.mkdir() - (handlers_dir / "__init__.py").write_text("", encoding="utf-8") - - (handlers_dir / "chain_cmd.py").write_text( - textwrap.dedent(""" - from astrbot_sdk.api.components.command import CommandComponent - from astrbot_sdk.api.event import AstrMessageEvent, filter - from astrbot_sdk.api.star.context import Context - from astrbot_sdk.api.message import MessageChain - from astrbot_sdk.api.message_components import Plain, At - - class ChainCommand(CommandComponent): - def __init__(self, context: Context): - self.context = context - - @filter.command("chain") - async def chain_test(self, event: AstrMessageEvent): - chain = MessageChain().message("Hi ").at("user", "123") - payload = chain.to_payload() - yield event.plain_result(f"Chain: {len(payload)} components") - """), - encoding="utf-8", - ) - - manifest_path.write_text( - yaml.dump( - { - "name": "chain_plugin", - "runtime": {"python": "3.12"}, - "components": [ - { - "class": "chain_handlers.chain_cmd:ChainCommand", - "type": "command", - "name": "chain", - } - ], - } - ), - encoding="utf-8", - ) - requirements_path.write_text("", encoding="utf-8") - - spec = load_plugin_spec(plugin_dir) - - path_added = False - if str(plugin_dir) not in sys.path: - sys.path.insert(0, str(plugin_dir)) - path_added = True - - try: - loaded = load_plugin(spec) - - assert len(loaded.instances) == 1 - assert len(loaded.handlers) >= 1 - finally: - # 清理导入的模块 - modules_to_remove = [ - k - for k in list(sys.modules.keys()) - if k.startswith("chain_handlers") - ] - for mod in modules_to_remove: - del sys.modules[mod] - if path_added and str(plugin_dir) in sys.path: - sys.path.remove(str(plugin_dir)) - - def test_load_plugin_with_regex_handler(self): - """测试加载使用正则处理器的插件。""" - - with tempfile.TemporaryDirectory() as temp_dir: - plugin_dir = Path(temp_dir) / "regex_plugin" - plugin_dir.mkdir() - manifest_path = plugin_dir / "plugin.yaml" - requirements_path = plugin_dir / "requirements.txt" - - # 使用唯一的模块名避免与其他测试冲突 - regex_handlers_dir = plugin_dir / "regex_handlers" - regex_handlers_dir.mkdir() - (regex_handlers_dir / "__init__.py").write_text("", encoding="utf-8") - - (regex_handlers_dir / "matcher.py").write_text( - textwrap.dedent(""" - 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 RegexCommand(CommandComponent): - def __init__(self, context: Context): - self.context = context - - @filter.regex(r"^ping.*") - async def ping(self, event: AstrMessageEvent): - yield event.plain_result("Pong!") - """), - encoding="utf-8", - ) - - manifest_path.write_text( - yaml.dump( - { - "name": "regex_plugin", - "runtime": {"python": "3.12"}, - "components": [ - { - "class": "regex_handlers.matcher:RegexCommand", - "type": "command", - "name": "regex", - } - ], - } - ), - encoding="utf-8", - ) - requirements_path.write_text("", encoding="utf-8") - - spec = load_plugin_spec(plugin_dir) - - path_added = False - if str(plugin_dir) not in sys.path: - sys.path.insert(0, str(plugin_dir)) - path_added = True - - try: - loaded = load_plugin(spec) - - assert len(loaded.handlers) >= 1 - handler = loaded.handlers[0] - assert isinstance(handler.descriptor.trigger, MessageTrigger) - assert handler.descriptor.trigger.regex == r"^ping.*" - finally: - # 清理导入的模块 - modules_to_remove = [ - k for k in sys.modules if k.startswith("regex_handlers") - ] - for mod in modules_to_remove: - del sys.modules[mod] - if path_added and str(plugin_dir) in sys.path: - sys.path.remove(str(plugin_dir)) - - -class TestRealTestPlugin: - """测试真实的 test_plugin 目录。""" - - def test_load_test_plugin(self): - """测试加载项目中的 test_plugin。""" - project_root = Path(__file__).parent.parent - test_plugin_dir = project_root / "test_plugin" / "old" - - if not test_plugin_dir.exists(): - pytest.skip("test_plugin directory not found") - - spec = load_plugin_spec(test_plugin_dir) - - # 添加项目根目录到 sys.path - paths_to_add = [] - if str(test_plugin_dir) not in sys.path: - sys.path.insert(0, str(test_plugin_dir)) - paths_to_add.append(str(test_plugin_dir)) - - # 添加 src-new 到 sys.path 以便导入 astrbot_sdk - src_new = project_root / "src-new" - if str(src_new) not in sys.path: - sys.path.insert(0, str(src_new)) - paths_to_add.append(str(src_new)) - - try: - loaded = load_plugin(spec) - - # 验证插件加载成功 - assert loaded.plugin.name == "astrbot_plugin_helloworld" - assert len(loaded.instances) == 1 - assert len(loaded.handlers) >= 1 - assert [item.descriptor.name for item in loaded.capabilities] == [ - "compat.echo" - ] - - command_names = { - handler.descriptor.trigger.command - for handler in loaded.handlers - if isinstance(handler.descriptor.trigger, CommandTrigger) - } - assert { - "admin", - "admin_type", - "ai", - "chain", - "components", - "conversation", - "db", - "echo", - "hello", - "sendmsg", - "state", - } <= command_names - - regex_triggers = [ - handler.descriptor.trigger - for handler in loaded.handlers - if isinstance(handler.descriptor.trigger, MessageTrigger) - ] - assert any(trigger.regex == r"^ping.*" for trigger in regex_triggers) - - # 验证实例类型 - instance = loaded.instances[0] - assert hasattr(instance, "context") - assert instance.context.plugin_id == "astrbot_plugin_helloworld" - - finally: - for p in paths_to_add: - if p in sys.path: - sys.path.remove(p) - - -if __name__ == "__main__": - pytest.main([__file__, "-v"]) diff --git a/tests_v4/test_legacy_runtime.py b/tests_v4/test_legacy_runtime.py deleted file mode 100644 index b1d183c6e..000000000 --- a/tests_v4/test_legacy_runtime.py +++ /dev/null @@ -1,334 +0,0 @@ -"""Tests for the private legacy runtime boundary helpers.""" - -from __future__ import annotations - -from types import SimpleNamespace -from unittest.mock import MagicMock - -import pytest - -from astrbot_sdk._legacy_api import LegacyContext -from astrbot_sdk._legacy_runtime import ( - LegacyComponentConstruction, - LegacyRuntimeAdapter, - bind_loaded_legacy_runtime, - build_legacy_worker_runtime_bridge, - create_legacy_component_context, - finalize_legacy_component_instance, - is_new_star_component, - legacy_constructor_accepts_config, - plan_legacy_component_construction, - prepare_legacy_handler_runtime, - resolve_plugin_lifecycle_hook, - select_legacy_constructor_args, -) -from astrbot_sdk.api.event import AstrMessageEvent -from astrbot_sdk.api.event.filter import ( - CustomFilter, - after_message_sent, - on_decorating_result, - on_plugin_loaded, -) -from astrbot_sdk.context import Context -from astrbot_sdk.events import MessageEvent -from astrbot_sdk.protocol.descriptors import CommandTrigger, HandlerDescriptor -from astrbot_sdk.runtime.loader import LoadedHandler -from astrbot_sdk.star import Star - - -class _DummyPeer: - def __init__(self) -> None: - self.remote_capability_map: dict[str, object] = {} - - -class _RejectAll(CustomFilter): - def filter(self, event: AstrMessageEvent, cfg) -> bool: - return False - - -def _runtime_context() -> Context: - return Context(peer=_DummyPeer(), plugin_id="compat-plugin") - - -def _record_sender(bucket: list[str]): - async def sender(item) -> bool: - get_plain_text = getattr(item, "get_plain_text", None) - if callable(get_plain_text): - bucket.append(get_plain_text()) - else: - bucket.append(str(item)) - return True - - return sender - - -async def _ignore_sender(item) -> bool: - return False - - -def _loaded_handler(legacy_context: LegacyContext) -> LoadedHandler: - async def handler_func(event): - return event - - return LoadedHandler( - descriptor=HandlerDescriptor( - id="compat.handler", - trigger=CommandTrigger(command="compat"), - ), - callable=handler_func, - owner=MagicMock(), - legacy_context=None, - ) - - -@pytest.mark.asyncio -async def test_prepare_legacy_handler_runtime_binds_context_and_applies_filters(): - legacy_context = LegacyContext("compat-plugin") - loaded = _loaded_handler(legacy_context) - loaded.legacy_runtime = LegacyRuntimeAdapter( - legacy_context=legacy_context, - filters=[_RejectAll()], - ) - runtime_context = _runtime_context() - event = MessageEvent(text="compat", session_id="session-1", context=runtime_context) - - prepared = await prepare_legacy_handler_runtime( - loaded, - runtime_context=runtime_context, - event=event, - ) - - assert prepared.adapter is loaded.legacy_runtime - assert prepared.should_run is False - assert legacy_context.require_runtime_context() is runtime_context - - -def test_bind_loaded_legacy_runtime_binds_runtime_context(): - legacy_context = LegacyContext("compat-plugin") - adapter = LegacyRuntimeAdapter(legacy_context=legacy_context) - loaded = SimpleNamespace(legacy_runtime=adapter, legacy_context=legacy_context) - runtime_context = _runtime_context() - - bound = bind_loaded_legacy_runtime(loaded, runtime_context) - - assert bound is adapter - assert legacy_context.require_runtime_context() is runtime_context - - -def test_create_legacy_component_context_uses_factory_method_when_available(): - expected = object() - - class LegacyComponent: - @classmethod - def _astrbot_create_legacy_context(cls, plugin_name): - return expected - - assert create_legacy_component_context(LegacyComponent, "compat-plugin") is expected - - -def test_is_new_star_component_detects_legacy_marker(): - class NotAStar: - pass - - class LegacyCompat(Star): - @classmethod - def __astrbot_is_new_star__(cls) -> bool: - return False - - assert is_new_star_component("nope") is False - assert is_new_star_component(NotAStar) is False - assert is_new_star_component(LegacyCompat) is False - - -def test_legacy_constructor_helpers_follow_legacy_context_config_rules(): - class NeedsContextOnly: - def __init__(self, context): - self.context = context - - class NeedsContextAndConfig: - def __init__(self, context, config): - self.context = context - self.config = config - - legacy_context = object() - config = {"token": "secret"} - - assert legacy_constructor_accepts_config(NeedsContextOnly) is False - assert legacy_constructor_accepts_config(NeedsContextAndConfig) is True - assert select_legacy_constructor_args(NeedsContextOnly, legacy_context, config) == ( - legacy_context, - ) - assert select_legacy_constructor_args( - NeedsContextAndConfig, - legacy_context, - config, - ) == (legacy_context, config) - - -def test_plan_legacy_component_construction_reuses_shared_context_and_default_config(): - class NeedsContextAndConfig: - def __init__(self, context, config): - self.context = context - self.config = config - - shared_context = object() - created_configs: list[dict[str, str]] = [] - - def build_default_config(): - config = {"token": "default"} - created_configs.append(config) - return config - - planned = plan_legacy_component_construction( - NeedsContextAndConfig, - plugin_name="compat-plugin", - shared_legacy_context=shared_context, - plugin_config=None, - default_config_factory=build_default_config, - ) - - assert isinstance(planned, LegacyComponentConstruction) - assert planned.legacy_context is shared_context - assert planned.shared_legacy_context is shared_context - assert planned.component_config == {"token": "default"} - assert planned.constructor_args == (shared_context, {"token": "default"}) - assert created_configs == [{"token": "default"}] - - -def test_finalize_legacy_component_instance_binds_context_config_and_registers(): - legacy_context = LegacyContext("compat-plugin") - component_config = {"token": "secret"} - - class CompatComponent: - @on_plugin_loaded() - async def on_loaded(self, metadata): - return metadata - - instance = CompatComponent() - - finalize_legacy_component_instance( - instance, - legacy_context=legacy_context, - component_config=component_config, - ) - - assert instance.context is legacy_context - assert instance.config == component_config - assert "on_plugin_loaded" in legacy_context._compat_hooks - - -@pytest.mark.asyncio -async def test_legacy_worker_runtime_bridge_deduplicates_shared_context_hooks(): - legacy_context = LegacyContext("compat-plugin") - observed_metadata: list[str] = [] - - class CompatHooks: - @on_plugin_loaded() - async def on_loaded(self, metadata): - observed_metadata.append(str(metadata["name"])) - - legacy_context._register_compat_component(CompatHooks()) - loaded_items = [ - SimpleNamespace( - legacy_runtime=LegacyRuntimeAdapter(legacy_context=legacy_context), - legacy_context=legacy_context, - ), - SimpleNamespace( - legacy_runtime=LegacyRuntimeAdapter(legacy_context=legacy_context), - legacy_context=legacy_context, - ), - ] - bridge = build_legacy_worker_runtime_bridge(loaded_items) - - await bridge.run_startup_hooks( - context=_runtime_context(), - metadata={"name": "compat-plugin"}, - ) - - assert observed_metadata == ["compat-plugin"] - - -@pytest.mark.asyncio -async def test_legacy_runtime_dispatch_result_runs_after_send_only_for_sent_output(): - legacy_context = LegacyContext("compat-plugin") - observed_results: list[str] = [] - - class CompatHooks: - @on_plugin_loaded() - async def noop(self, metadata): - return metadata - - @on_decorating_result() - async def decorate(self, event: AstrMessageEvent): - event.set_result("decorated") - - @after_message_sent() - async def after_send(self, event: AstrMessageEvent): - result = event.get_result() - observed_results.append(result.get_plain_text() if result else "") - - legacy_context._register_compat_component(CompatHooks()) - adapter = LegacyRuntimeAdapter(legacy_context=legacy_context) - runtime_context = _runtime_context() - adapter.bind_runtime_context(runtime_context) - event = MessageEvent( - text="compat", - session_id="session-1", - user_id="user-1", - platform="test", - context=runtime_context, - ) - seen_items: list[str] = [] - - handled = await adapter.dispatch_result( - "raw", - event, - runtime_context, - sender=_record_sender(seen_items), - ) - - assert handled is True - assert seen_items == ["decorated"] - assert observed_results == ["decorated"] - - ignored = await adapter.dispatch_result( - "raw", - event, - runtime_context, - sender=_ignore_sender, - ) - - assert ignored is False - assert observed_results == ["decorated"] - - -def test_resolve_plugin_lifecycle_hook_prefers_legacy_initialize_alias(): - calls: list[str] = [] - - class LegacyComponent(Star): - @classmethod - def __astrbot_is_new_star__(cls) -> bool: - return False - - async def initialize(self, ctx): - calls.append(ctx.plugin_id) - - instance = LegacyComponent() - - hook = resolve_plugin_lifecycle_hook(instance, "on_start") - - assert hook is not None - assert getattr(hook, "__name__", "") == "initialize" - - -def test_resolve_plugin_lifecycle_hook_keeps_overridden_new_star_hook(): - class NewComponent(Star): - async def on_start(self, ctx): - return ctx - - instance = NewComponent() - - hook = resolve_plugin_lifecycle_hook(instance, "on_start") - - assert hook is not None - assert getattr(hook, "__name__", "") == "on_start" diff --git a/tests_v4/test_loader.py b/tests_v4/test_loader.py deleted file mode 100644 index e89231b12..000000000 --- a/tests_v4/test_loader.py +++ /dev/null @@ -1,1471 +0,0 @@ -""" -Tests for runtime/loader.py - Plugin loading utilities. -""" - -from __future__ import annotations - -import json -import sys -import tempfile -import textwrap -from pathlib import Path -from unittest.mock import MagicMock, patch - -import pytest -import yaml -from astrbot_sdk._legacy_api import LegacyContext -from astrbot_sdk._legacy_runtime import ( - LegacyRuntimeAdapter, -) -from astrbot_sdk._legacy_runtime import ( - create_legacy_component_context as _create_legacy_context, -) -from astrbot_sdk._legacy_runtime import ( - is_new_star_component as _is_new_star_component, -) -from astrbot_sdk.protocol.descriptors import CommandTrigger, HandlerDescriptor -from astrbot_sdk.runtime.environment_groups import ( - GROUP_STATE_FILE_NAME, - EnvironmentGroup, - GroupEnvironmentManager, -) -from astrbot_sdk.runtime.loader import ( - STATE_FILE_NAME, - LoadedHandler, - LoadedPlugin, - PluginDiscoveryResult, - PluginEnvironmentManager, - PluginSpec, - _iter_handler_names, - _venv_python_path, - discover_plugins, - import_string, - load_plugin, - load_plugin_spec, -) - -from astrbot_sdk.api.event.filter import CustomFilter, custom_filter - - -def write_test_plugin( - plugins_dir: Path, - name: str, - *, - python_version: str = "3.12", - requirements: str = "", -) -> PluginSpec: - plugin_dir = plugins_dir / name - plugin_dir.mkdir(parents=True, exist_ok=True) - (plugin_dir / "plugin.yaml").write_text( - yaml.dump( - { - "name": name, - "runtime": {"python": python_version}, - "components": [], - } - ), - encoding="utf-8", - ) - (plugin_dir / "requirements.txt").write_text(requirements, encoding="utf-8") - return load_plugin_spec(plugin_dir) - - -class TestVenvPythonPath: - """Tests for _venv_python_path function.""" - - def test_linux_path(self): - """_venv_python_path should return correct Linux path.""" - # 使用 PurePath 进行路径拼接测试,避免跨平台问题 - from pathlib import PurePosixPath - - # 测试逻辑:posix 系统返回 bin/python - with patch("os.name", "posix"): - path = _venv_python_path(PurePosixPath("/home/user/.venv")) - # 结果应该是字符串形式比较 - assert str(path) == "/home/user/.venv/bin/python" - - def test_windows_path(self): - """_venv_python_path should return correct Windows path.""" - from pathlib import PureWindowsPath - - with patch("os.name", "nt"): - path = _venv_python_path(PureWindowsPath("C:\\venv")) - assert str(path) == "C:\\venv\\Scripts\\python.exe" - - -class TestPluginSpec: - """Tests for PluginSpec dataclass.""" - - def test_init(self): - """PluginSpec should store all fields.""" - plugin_dir = Path("/tmp/plugin") - manifest_path = plugin_dir / "plugin.yaml" - requirements_path = plugin_dir / "requirements.txt" - - spec = PluginSpec( - name="test_plugin", - plugin_dir=plugin_dir, - manifest_path=manifest_path, - requirements_path=requirements_path, - python_version="3.12", - manifest_data={"name": "test_plugin"}, - ) - - assert spec.name == "test_plugin" - assert spec.plugin_dir == plugin_dir - assert spec.manifest_path == manifest_path - assert spec.requirements_path == requirements_path - assert spec.python_version == "3.12" - assert spec.manifest_data == {"name": "test_plugin"} - - -class TestPluginDiscoveryResult: - """Tests for PluginDiscoveryResult dataclass.""" - - def test_init(self): - """PluginDiscoveryResult should store plugins and skipped.""" - spec = PluginSpec( - name="test", - plugin_dir=Path("/tmp"), - manifest_path=Path("/tmp/plugin.yaml"), - requirements_path=Path("/tmp/requirements.txt"), - python_version="3.12", - manifest_data={}, - ) - - result = PluginDiscoveryResult( - plugins=[spec], - skipped_plugins={"bad_plugin": "missing requirements.txt"}, - ) - - assert len(result.plugins) == 1 - assert result.skipped_plugins == {"bad_plugin": "missing requirements.txt"} - - -class TestLoadedHandler: - """Tests for LoadedHandler dataclass.""" - - def test_init(self): - """LoadedHandler should store all fields.""" - descriptor = HandlerDescriptor( - id="test.handler", - trigger=CommandTrigger(command="hello"), - ) - - def handler_func(): - pass - - owner = MagicMock() - - loaded = LoadedHandler( - descriptor=descriptor, - callable=handler_func, - owner=owner, - legacy_context=None, - ) - - assert loaded.descriptor == descriptor - assert loaded.callable == handler_func - assert loaded.owner == owner - assert loaded.legacy_context is None - assert loaded.legacy_runtime is None - - def test_init_builds_legacy_runtime_adapter(self): - """LoadedHandler should prebuild the legacy runtime adapter for runtime use.""" - descriptor = HandlerDescriptor( - id="test.handler", - trigger=CommandTrigger(command="hello"), - ) - legacy_context = LegacyContext("test_plugin") - - def handler_func(): - pass - - loaded = LoadedHandler( - descriptor=descriptor, - callable=handler_func, - owner=MagicMock(), - legacy_context=legacy_context, - ) - - assert isinstance(loaded.legacy_runtime, LegacyRuntimeAdapter) - assert loaded.legacy_runtime.legacy_context is legacy_context - - def test_init_collects_compat_filters_through_adapter_boundary(self): - """LoadedHandler should preserve compat filters when prebuilding the adapter.""" - descriptor = HandlerDescriptor( - id="test.handler", - trigger=CommandTrigger(command="hello"), - ) - legacy_context = LegacyContext("test_plugin") - - class RejectAll(CustomFilter): - def filter(self, event, cfg) -> bool: - return False - - @custom_filter(RejectAll) - def handler_func(): - pass - - loaded = LoadedHandler( - descriptor=descriptor, - callable=handler_func, - owner=MagicMock(), - legacy_context=legacy_context, - ) - - assert len(loaded.compat_filters) == 1 - assert isinstance(loaded.compat_filters[0], RejectAll) - assert loaded.legacy_runtime is not None - assert len(loaded.legacy_runtime.filters) == 1 - assert isinstance(loaded.legacy_runtime.filters[0], RejectAll) - - -class TestLoadedPlugin: - """Tests for LoadedPlugin dataclass.""" - - def test_init(self): - """LoadedPlugin should store plugin and handlers.""" - spec = PluginSpec( - name="test", - plugin_dir=Path("/tmp"), - manifest_path=Path("/tmp/plugin.yaml"), - requirements_path=Path("/tmp/requirements.txt"), - python_version="3.12", - manifest_data={}, - ) - - loaded = LoadedPlugin(plugin=spec, handlers=[], instances=[]) - - assert loaded.plugin == spec - assert loaded.handlers == [] - assert loaded.capabilities == [] - assert loaded.instances == [] - - -class TestIsNewStarComponent: - """Tests for _is_new_star_component function.""" - - def test_non_class_returns_false(self): - """_is_new_star_component should return False for non-class.""" - assert _is_new_star_component("not a class") is False - assert _is_new_star_component(123) is False - - def test_non_star_subclass_returns_false(self): - """_is_new_star_component should return False for non-Star class.""" - - class NotAStar: - pass - - assert _is_new_star_component(NotAStar) is False - - def test_star_without_marker_returns_true(self): - """_is_new_star_component should return True for Star without marker.""" - from astrbot_sdk.star import Star - - class MyStar(Star): - pass - - assert _is_new_star_component(MyStar) is True - - def test_star_with_false_marker_returns_false(self): - """_is_new_star_component should return False if marker returns False.""" - from astrbot_sdk.star import Star - - class LegacyStar(Star): - @classmethod - def __astrbot_is_new_star__(cls): - return False - - assert _is_new_star_component(LegacyStar) is False - - -class TestCreateLegacyContext: - """Tests for _create_legacy_context function.""" - - def test_with_factory_method(self): - """_create_legacy_context should use factory method if available.""" - mock_context = MagicMock() - - class ComponentWithFactory: - @classmethod - def _astrbot_create_legacy_context(cls, plugin_name): - return mock_context - - result = _create_legacy_context(ComponentWithFactory, "test_plugin") - assert result == mock_context - - def test_without_factory_method(self): - """_create_legacy_context should create default context.""" - # Without factory, it imports LegacyContext - from astrbot_sdk.star import Star - - class PlainStar(Star): - pass - - result = _create_legacy_context(PlainStar, "test_plugin") - # Should return some context object - assert result is not None - - -class TestIterHandlerNames: - """Tests for _iter_handler_names function.""" - - def test_with_handlers_attribute(self): - """_iter_handler_names should use __handlers__ if available.""" - - # 创建一个真实的类来测试,而不是 MagicMock - class InstanceWithHandlers: - __handlers__ = ("handler1", "handler2") - - instance = InstanceWithHandlers() - names = _iter_handler_names(instance) - assert names == ["handler1", "handler2"] - - def test_without_handlers_attribute(self): - """_iter_handler_names should fall back to dir() if no __handlers__.""" - - # 创建一个没有 __handlers__ 的真实类 - class InstanceWithoutHandlers: - def method1(self): - pass - - def method2(self): - pass - - instance = InstanceWithoutHandlers() - names = _iter_handler_names(instance) - # 应该返回 dir(instance) 的结果 - assert "method1" in names - assert "method2" in names - - -class TestLoadPluginSpec: - """Tests for load_plugin_spec function.""" - - def test_loads_manifest(self): - """load_plugin_spec should load plugin.yaml.""" - with tempfile.TemporaryDirectory() as temp_dir: - plugin_dir = Path(temp_dir) - manifest_path = plugin_dir / "plugin.yaml" - requirements_path = plugin_dir / "requirements.txt" - - manifest_path.write_text( - yaml.dump( - { - "name": "test_plugin", - "runtime": {"python": "3.11"}, - } - ), - encoding="utf-8", - ) - requirements_path.write_text("", encoding="utf-8") - - spec = load_plugin_spec(plugin_dir) - - assert spec.name == "test_plugin" - assert spec.python_version == "3.11" - assert spec.plugin_dir.resolve() == plugin_dir.resolve() - - def test_defaults_python_version(self): - """load_plugin_spec should default python version to current.""" - with tempfile.TemporaryDirectory() as temp_dir: - plugin_dir = Path(temp_dir) - manifest_path = plugin_dir / "plugin.yaml" - requirements_path = plugin_dir / "requirements.txt" - - manifest_path.write_text( - yaml.dump({"name": "test_plugin"}), - encoding="utf-8", - ) - requirements_path.write_text("", encoding="utf-8") - - spec = load_plugin_spec(plugin_dir) - - expected = f"{sys.version_info.major}.{sys.version_info.minor}" - assert spec.python_version == expected - - def test_defaults_name_to_dir_name(self): - """load_plugin_spec should default name to directory name.""" - with tempfile.TemporaryDirectory() as temp_dir: - plugin_dir = Path(temp_dir) / "my_plugin" - plugin_dir.mkdir() - manifest_path = plugin_dir / "plugin.yaml" - requirements_path = plugin_dir / "requirements.txt" - - manifest_path.write_text("{}", encoding="utf-8") - requirements_path.write_text("", encoding="utf-8") - - spec = load_plugin_spec(plugin_dir) - - assert spec.name == "my_plugin" - - -class TestDiscoverPlugins: - """Tests for discover_plugins function.""" - - def test_empty_directory(self): - """discover_plugins should return empty for non-existent directory.""" - result = discover_plugins(Path("/nonexistent")) - assert result.plugins == [] - assert result.skipped_plugins == {} - - def test_skips_dot_directories(self): - """discover_plugins should skip directories starting with dot.""" - with tempfile.TemporaryDirectory() as temp_dir: - plugins_dir = Path(temp_dir) - - # Create .hidden directory - hidden_dir = plugins_dir / ".hidden" - hidden_dir.mkdir() - (hidden_dir / "plugin.yaml").write_text( - yaml.dump( - { - "name": "hidden", - "runtime": {"python": "3.12"}, - "components": [{"class": "test:Test"}], - } - ), - encoding="utf-8", - ) - (hidden_dir / "requirements.txt").write_text("", encoding="utf-8") - - result = discover_plugins(plugins_dir) - - assert result.plugins == [] - - def test_skips_missing_manifest(self): - """discover_plugins should skip directories without plugin.yaml.""" - with tempfile.TemporaryDirectory() as temp_dir: - plugins_dir = Path(temp_dir) - - plugin_dir = plugins_dir / "no_manifest" - plugin_dir.mkdir() - (plugin_dir / "requirements.txt").write_text("", encoding="utf-8") - - result = discover_plugins(plugins_dir) - - assert result.plugins == [] - - def test_skips_missing_requirements(self): - """discover_plugins should skip directories without requirements.txt.""" - with tempfile.TemporaryDirectory() as temp_dir: - plugins_dir = Path(temp_dir) - - plugin_dir = plugins_dir / "no_requirements" - plugin_dir.mkdir() - (plugin_dir / "plugin.yaml").write_text( - yaml.dump({"name": "test"}), - encoding="utf-8", - ) - - result = discover_plugins(plugins_dir) - - assert "no_requirements" in result.skipped_plugins - assert "requirements.txt" in result.skipped_plugins["no_requirements"] - - def test_validates_required_fields(self): - """discover_plugins should validate required manifest fields.""" - with tempfile.TemporaryDirectory() as temp_dir: - plugins_dir = Path(temp_dir) - - # Missing name - plugin_dir = plugins_dir / "missing_name" - plugin_dir.mkdir() - (plugin_dir / "plugin.yaml").write_text( - yaml.dump( - { - "runtime": {"python": "3.12"}, - "components": [{"class": "test:Test"}], - } - ), - encoding="utf-8", - ) - (plugin_dir / "requirements.txt").write_text("", encoding="utf-8") - - result = discover_plugins(plugins_dir) - - assert "missing_name" in result.skipped_plugins - assert "name" in result.skipped_plugins["missing_name"] - - def test_detects_duplicate_names(self): - """discover_plugins should detect duplicate plugin names.""" - with tempfile.TemporaryDirectory() as temp_dir: - plugins_dir = Path(temp_dir) - - for i, dirname in enumerate(["plugin1", "plugin2"]): - plugin_dir = plugins_dir / dirname - plugin_dir.mkdir() - (plugin_dir / "plugin.yaml").write_text( - yaml.dump( - { - "name": "duplicate_name", # Same name - "runtime": {"python": "3.12"}, - "components": [{"class": "test:Test"}], - } - ), - encoding="utf-8", - ) - (plugin_dir / "requirements.txt").write_text("", encoding="utf-8") - - result = discover_plugins(plugins_dir) - - # First one should succeed, second should be skipped - assert len(result.plugins) == 1 - assert "duplicate_name" in result.skipped_plugins - - def test_validates_components_list(self): - """discover_plugins should validate components is a list.""" - with tempfile.TemporaryDirectory() as temp_dir: - plugins_dir = Path(temp_dir) - - plugin_dir = plugins_dir / "bad_components" - plugin_dir.mkdir() - (plugin_dir / "plugin.yaml").write_text( - yaml.dump( - { - "name": "test", - "runtime": {"python": "3.12"}, - "components": "not_a_list", - } - ), - encoding="utf-8", - ) - (plugin_dir / "requirements.txt").write_text("", encoding="utf-8") - - result = discover_plugins(plugins_dir) - - assert "test" in result.skipped_plugins - assert "components" in result.skipped_plugins["test"] - - def test_allows_empty_components_list(self): - """discover_plugins should allow plugins without components.""" - with tempfile.TemporaryDirectory() as temp_dir: - plugins_dir = Path(temp_dir) - - plugin_dir = plugins_dir / "empty_components" - plugin_dir.mkdir() - (plugin_dir / "plugin.yaml").write_text( - yaml.dump( - { - "name": "empty_components", - "runtime": {"python": "3.12"}, - "components": [], - } - ), - encoding="utf-8", - ) - (plugin_dir / "requirements.txt").write_text("", encoding="utf-8") - - result = discover_plugins(plugins_dir) - - assert [plugin.name for plugin in result.plugins] == ["empty_components"] - assert result.skipped_plugins == {} - - def test_discovers_valid_plugin(self): - """discover_plugins should discover valid plugin.""" - with tempfile.TemporaryDirectory() as temp_dir: - plugins_dir = Path(temp_dir) - - plugin_dir = plugins_dir / "valid_plugin" - plugin_dir.mkdir() - (plugin_dir / "plugin.yaml").write_text( - yaml.dump( - { - "name": "valid_plugin", - "runtime": {"python": "3.12"}, - "components": [{"class": "module:Class"}], - } - ), - encoding="utf-8", - ) - (plugin_dir / "requirements.txt").write_text("", encoding="utf-8") - - result = discover_plugins(plugins_dir) - - assert len(result.plugins) == 1 - assert result.plugins[0].name == "valid_plugin" - - def test_discovers_legacy_main_plugin_without_manifest(self): - """discover_plugins should accept legacy plugins with main.py.""" - with tempfile.TemporaryDirectory() as temp_dir: - plugins_dir = Path(temp_dir) - plugin_dir = plugins_dir / "legacy_plugin" - plugin_dir.mkdir() - (plugin_dir / "main.py").write_text( - "from astrbot_sdk.api.star import Star\n\nclass LegacyPlugin(Star):\n pass\n", - encoding="utf-8", - ) - (plugin_dir / "metadata.yaml").write_text( - yaml.dump({"name": "legacy_plugin", "author": "tester"}), - encoding="utf-8", - ) - - result = discover_plugins(plugins_dir) - - assert [plugin.name for plugin in result.plugins] == ["legacy_plugin"] - assert result.skipped_plugins == {} - - -class TestPluginEnvironmentManager: - """Tests for PluginEnvironmentManager class.""" - - def test_init(self): - """PluginEnvironmentManager should initialize with repo root.""" - with tempfile.TemporaryDirectory() as temp_dir: - manager = PluginEnvironmentManager(Path(temp_dir)) - assert manager.repo_root == Path(temp_dir).resolve() - assert manager.cache_dir == Path(temp_dir).resolve() / ".uv-cache" - - def test_uv_binary_detection(self): - """PluginEnvironmentManager should detect uv binary.""" - with tempfile.TemporaryDirectory() as temp_dir: - with patch("shutil.which", return_value="/usr/bin/uv"): - manager = PluginEnvironmentManager(Path(temp_dir)) - assert manager.uv_binary == "/usr/bin/uv" - - def test_prepare_environment_without_uv_raises(self): - """prepare_environment should raise if uv not found.""" - with tempfile.TemporaryDirectory() as temp_dir: - # 创建 requirements.txt,否则 _fingerprint 会失败 - requirements_path = Path(temp_dir) / "requirements.txt" - requirements_path.write_text("", encoding="utf-8") - - # Mock shutil.which 在环境规划模块中返回 None,确保 uv_binary 为 None - with patch( - "astrbot_sdk.runtime.environment_groups.shutil.which", - return_value=None, - ): - manager = PluginEnvironmentManager(Path(temp_dir), uv_binary=None) - assert manager.uv_binary is None - - spec = PluginSpec( - name="test", - plugin_dir=Path(temp_dir), - manifest_path=Path(temp_dir) / "plugin.yaml", - requirements_path=requirements_path, - python_version="3.12", - manifest_data={}, - ) - - with pytest.raises(RuntimeError, match="uv"): - manager.prepare_environment(spec) - - def test_fingerprint(self): - """_fingerprint should create consistent fingerprint.""" - with tempfile.TemporaryDirectory() as temp_dir: - plugin_dir = Path(temp_dir) - requirements = plugin_dir / "requirements.txt" - requirements.write_text("astrbot-sdk\n", encoding="utf-8") - - spec = PluginSpec( - name="test", - plugin_dir=plugin_dir, - manifest_path=plugin_dir / "plugin.yaml", - requirements_path=requirements, - python_version="3.12", - manifest_data={}, - ) - - fingerprint = PluginEnvironmentManager._fingerprint(spec) - - assert "python_version" in fingerprint - assert "3.12" in fingerprint - assert "requirements" in fingerprint - - def test_load_state_missing_file(self): - """_load_state should return empty dict for missing file.""" - with tempfile.TemporaryDirectory() as temp_dir: - state = GroupEnvironmentManager._load_state(Path(temp_dir) / "missing.json") - assert state == {} - - def test_load_state_invalid_json(self): - """_load_state should return empty dict for invalid JSON.""" - with tempfile.TemporaryDirectory() as temp_dir: - state_path = Path(temp_dir) / "state.json" - state_path.write_text("not valid json", encoding="utf-8") - - state = GroupEnvironmentManager._load_state(state_path) - assert state == {} - - def test_write_state(self): - """group state should be written under the shared environment.""" - with tempfile.TemporaryDirectory() as temp_dir: - state_path = Path(temp_dir) / "state.json" - spec = PluginSpec( - name="test", - plugin_dir=Path(temp_dir), - manifest_path=Path(temp_dir) / "plugin.yaml", - requirements_path=Path(temp_dir) / "requirements.txt", - python_version="3.12", - manifest_data={}, - ) - group = EnvironmentGroup( - id="group-1", - python_version="3.12", - plugins=[spec], - source_path=Path(temp_dir) / ".astrbot" / "groups" / "group-1.in", - lockfile_path=Path(temp_dir) / ".astrbot" / "locks" / "group-1.txt", - metadata_path=Path(temp_dir) / ".astrbot" / "groups" / "group-1.json", - venv_path=Path(temp_dir) / ".astrbot" / "envs" / "group-1", - python_path=Path(temp_dir) - / ".astrbot" - / "envs" - / "group-1" - / "bin" - / "python", - environment_fingerprint="test_fingerprint", - ) - - GroupEnvironmentManager._write_state(state_path, group) - - state = json.loads(state_path.read_text(encoding="utf-8")) - - assert state["group_id"] == "group-1" - assert state["environment_fingerprint"] == "test_fingerprint" - assert state["plugins"] == ["test"] - - def test_plan_groups_same_python_with_empty_requirements(self): - """Plugins with the same Python and no requirements should share one group.""" - with tempfile.TemporaryDirectory() as temp_dir: - manager = PluginEnvironmentManager(Path(temp_dir), uv_binary="/usr/bin/uv") - plugins_dir = Path(temp_dir) / "plugins" - spec_one = write_test_plugin(plugins_dir, "plugin_one") - spec_two = write_test_plugin(plugins_dir, "plugin_two") - - plan = manager.plan([spec_one, spec_two]) - - assert len(plan.groups) == 1 - assert [plugin.name for plugin in plan.plugins] == [ - "plugin_one", - "plugin_two", - ] - assert ( - plan.plugin_to_group["plugin_one"].id - == plan.plugin_to_group["plugin_two"].id - ) - - def test_plan_splits_conflicting_requirements(self): - """Conflicting dependency pins should be split into dedicated groups.""" - with tempfile.TemporaryDirectory() as temp_dir: - manager = PluginEnvironmentManager(Path(temp_dir), uv_binary="/usr/bin/uv") - plugins_dir = Path(temp_dir) / "plugins" - spec_one = write_test_plugin( - plugins_dir, - "plugin_one", - requirements="demo==1.0.0\n", - ) - spec_two = write_test_plugin( - plugins_dir, - "plugin_two", - requirements="demo==2.0.0\n", - ) - - def fake_compile( - *, source_path: Path, output_path: Path, python_version: str - ): - content = source_path.read_text(encoding="utf-8") - if "demo==1.0.0" in content and "demo==2.0.0" in content: - raise RuntimeError( - "compile lockfile failed with exit code 1: conflict" - ) - output_path.write_text( - f"# python={python_version}\n{content}", - encoding="utf-8", - ) - - manager._planner._compile_lockfile = fake_compile - - plan = manager.plan([spec_one, spec_two]) - - assert len(plan.groups) == 2 - assert plan.skipped_plugins == {} - assert ( - plan.plugin_to_group["plugin_one"].id - != plan.plugin_to_group["plugin_two"].id - ) - - def test_three_plugins_share_and_isolate_group_envs_then_cleanup(self): - """Two plugins should share one env while a conflicting third gets its own.""" - with tempfile.TemporaryDirectory() as temp_dir: - manager = PluginEnvironmentManager(Path(temp_dir), uv_binary="/usr/bin/uv") - plugins_dir = Path(temp_dir) / "plugins" - spec_a = write_test_plugin( - plugins_dir, - "plugin_a", - requirements="alpha==1.0.0\n", - ) - spec_b = write_test_plugin( - plugins_dir, - "plugin_b", - requirements="beta==1.0.0\n", - ) - spec_c = write_test_plugin( - plugins_dir, - "plugin_c", - requirements="alpha==2.0.0\n", - ) - - def fake_compile( - *, source_path: Path, output_path: Path, python_version: str - ): - content = source_path.read_text(encoding="utf-8") - if "alpha==1.0.0" in content and "alpha==2.0.0" in content: - raise RuntimeError( - "compile lockfile failed with exit code 1: conflict" - ) - output_path.write_text( - f"# python={python_version}\n{content}", - encoding="utf-8", - ) - - def fake_prepare(group: EnvironmentGroup) -> Path: - group.python_path.parent.mkdir(parents=True, exist_ok=True) - group.python_path.write_text( - f"group={group.id}\n", - encoding="utf-8", - ) - return group.python_path - - manager._planner._compile_lockfile = fake_compile - manager._group_manager.prepare = fake_prepare - - plan = manager.plan([spec_a, spec_b, spec_c]) - shared_group = plan.plugin_to_group["plugin_a"] - isolated_group = plan.plugin_to_group["plugin_c"] - - assert len(plan.groups) == 2 - assert shared_group.id == plan.plugin_to_group["plugin_b"].id - assert shared_group.id != isolated_group.id - - path_a = manager.prepare_environment(spec_a) - path_b = manager.prepare_environment(spec_b) - path_c = manager.prepare_environment(spec_c) - - assert path_a == path_b - assert path_a != path_c - assert len({path_a, path_b, path_c}) == 2 - assert shared_group.venv_path.exists() - assert isolated_group.venv_path.exists() - - manager._planner.cleanup_artifacts([]) - - assert not shared_group.venv_path.exists() - assert not isolated_group.venv_path.exists() - assert spec_a.plugin_dir.exists() - assert spec_b.plugin_dir.exists() - assert spec_c.plugin_dir.exists() - - def test_plan_skips_only_plugin_with_invalid_lockfile(self): - """A plugin whose lockfile cannot be compiled should be skipped alone.""" - with tempfile.TemporaryDirectory() as temp_dir: - manager = PluginEnvironmentManager(Path(temp_dir), uv_binary="/usr/bin/uv") - plugins_dir = Path(temp_dir) / "plugins" - bad_spec = write_test_plugin( - plugins_dir, - "broken_plugin", - requirements="broken>=1\n", - ) - good_spec = write_test_plugin(plugins_dir, "good_plugin") - - def fake_compile( - *, source_path: Path, output_path: Path, python_version: str - ): - content = source_path.read_text(encoding="utf-8") - if "broken>=1" in content: - raise RuntimeError( - "compile lockfile failed with exit code 1: invalid requirement" - ) - output_path.write_text( - f"# python={python_version}\n{content}", - encoding="utf-8", - ) - - manager._planner._compile_lockfile = fake_compile - - plan = manager.plan([bad_spec, good_spec]) - - assert [plugin.name for plugin in plan.plugins] == ["good_plugin"] - assert "broken_plugin" in plan.skipped_plugins - assert "invalid requirement" in plan.skipped_plugins["broken_plugin"] - - def test_prepare_environment_reuses_python_path_for_same_group(self): - """prepare_environment should reuse the same interpreter for one group.""" - with tempfile.TemporaryDirectory() as temp_dir: - manager = PluginEnvironmentManager(Path(temp_dir), uv_binary="/usr/bin/uv") - plugins_dir = Path(temp_dir) / "plugins" - spec_one = write_test_plugin(plugins_dir, "plugin_one") - spec_two = write_test_plugin(plugins_dir, "plugin_two") - - plan = manager.plan([spec_one, spec_two]) - prepared_groups: list[str] = [] - - def fake_prepare(group: EnvironmentGroup) -> Path: - prepared_groups.append(group.id) - return group.python_path - - manager._group_manager.prepare = fake_prepare - - path_one = manager.prepare_environment(spec_one) - path_two = manager.prepare_environment(spec_two) - - assert path_one == path_two - assert prepared_groups == [plan.groups[0].id, plan.groups[0].id] - - def test_cleanup_artifacts_keeps_plugin_local_venv(self): - """Shared artifact cleanup should not delete plugin-local legacy venvs.""" - with tempfile.TemporaryDirectory() as temp_dir: - manager = PluginEnvironmentManager(Path(temp_dir), uv_binary="/usr/bin/uv") - plugins_dir = Path(temp_dir) / "plugins" - spec = write_test_plugin(plugins_dir, "plugin_one") - plan = manager.plan([spec]) - - stale_root = Path(temp_dir) / ".astrbot" - stale_group_dir = stale_root / "groups" - stale_lock_dir = stale_root / "locks" - stale_env_dir = stale_root / "envs" / "stalegroup" - stale_group_dir.mkdir(parents=True, exist_ok=True) - stale_lock_dir.mkdir(parents=True, exist_ok=True) - stale_env_dir.mkdir(parents=True, exist_ok=True) - (stale_group_dir / "stalegroup.in").write_text("", encoding="utf-8") - (stale_group_dir / "stalegroup.json").write_text("{}", encoding="utf-8") - (stale_lock_dir / "stalegroup.txt").write_text("", encoding="utf-8") - - legacy_venv = spec.plugin_dir / ".venv" - legacy_venv.mkdir(parents=True, exist_ok=True) - - manager._planner.cleanup_artifacts(plan.groups) - - assert legacy_venv.exists() - assert plan.groups[0].source_path.exists() - assert plan.groups[0].lockfile_path.exists() - assert plan.groups[0].metadata_path.exists() - assert not (stale_group_dir / "stalegroup.in").exists() - assert not (stale_group_dir / "stalegroup.json").exists() - assert not (stale_lock_dir / "stalegroup.txt").exists() - assert not stale_env_dir.exists() - - -class TestImportString: - """Tests for import_string function.""" - - def test_imports_module_attribute(self): - """import_string should import module and get attribute.""" - result = import_string("os:path") - assert result is not None - - def test_raises_for_missing_module(self): - """import_string should raise for missing module.""" - with pytest.raises(ImportError): - import_string("nonexistent_module:attr") - - def test_raises_for_missing_attribute(self): - """import_string should raise for missing attribute.""" - with pytest.raises(AttributeError): - import_string("os:nonexistent_attr") - - def test_raises_for_invalid_format(self): - """import_string should raise for invalid format.""" - with pytest.raises(ValueError): - import_string("no_colon") - - def test_plugin_dir_isolates_same_top_level_package(self): - """import_string should evict conflicting cached top-level plugin packages.""" - with tempfile.TemporaryDirectory() as temp_dir: - root = Path(temp_dir) - first_plugin = root / "plugin_one" - second_plugin = root / "plugin_two" - - for plugin_dir, marker in ( - (first_plugin, "first"), - (second_plugin, "second"), - ): - commands_dir = plugin_dir / "commands" - commands_dir.mkdir(parents=True) - (commands_dir / "__init__.py").write_text("", encoding="utf-8") - (commands_dir / "sample.py").write_text( - f"VALUE = {marker!r}\n", - encoding="utf-8", - ) - - try: - first_module = import_string( - "commands.sample:VALUE", - plugin_dir=first_plugin, - ) - second_module = import_string( - "commands.sample:VALUE", - plugin_dir=second_plugin, - ) - finally: - for module_name in list(sys.modules): - if module_name == "commands" or module_name.startswith("commands."): - sys.modules.pop(module_name, None) - for plugin_dir in (first_plugin, second_plugin): - plugin_path = str(plugin_dir) - if plugin_path in sys.path: - sys.path.remove(plugin_path) - - assert first_module == "first" - assert second_module == "second" - - -class TestLoadPlugin: - """Tests for load_plugin function.""" - - def test_loads_component_and_handlers(self): - """load_plugin should load component class and find handlers.""" - with tempfile.TemporaryDirectory() as temp_dir: - plugin_dir = Path(temp_dir) - manifest_path = plugin_dir / "plugin.yaml" - requirements_path = plugin_dir / "requirements.txt" - - # Create module - module_dir = plugin_dir / "mymodule" - module_dir.mkdir() - (module_dir / "__init__.py").write_text("", encoding="utf-8") - (module_dir / "component.py").write_text( - textwrap.dedent(""" - from astrbot_sdk import Star, on_command - - class MyComponent(Star): - @on_command("hello") - async def hello_handler(self): - pass - """), - encoding="utf-8", - ) - - manifest_path.write_text( - yaml.dump( - { - "name": "test_plugin", - "runtime": {"python": "3.12"}, - "components": [{"class": "mymodule.component:MyComponent"}], - } - ), - encoding="utf-8", - ) - requirements_path.write_text("", encoding="utf-8") - - spec = load_plugin_spec(plugin_dir) - - # Add plugin dir to sys.path for import - if str(plugin_dir) not in sys.path: - sys.path.insert(0, str(plugin_dir)) - - try: - loaded = load_plugin(spec) - - assert loaded.plugin.name == "test_plugin" - assert len(loaded.instances) == 1 - assert len(loaded.handlers) >= 1 - finally: - if str(plugin_dir) in sys.path: - sys.path.remove(str(plugin_dir)) - for module_name in list(sys.modules): - if module_name == "mymodule" or module_name.startswith("mymodule."): - sys.modules.pop(module_name, None) - - def test_preserves_legacy_handler_declaration_order(self): - """load_plugin should keep legacy handler order instead of sorting by dir().""" - with tempfile.TemporaryDirectory() as temp_dir: - plugin_dir = Path(temp_dir) - manifest_path = plugin_dir / "plugin.yaml" - requirements_path = plugin_dir / "requirements.txt" - - commands_dir = plugin_dir / "commands" - commands_dir.mkdir() - (commands_dir / "__init__.py").write_text("", encoding="utf-8") - (commands_dir / "component.py").write_text( - textwrap.dedent( - """\ - from astrbot_sdk.api.components.command import CommandComponent - from astrbot_sdk.api.event import AstrMessageEvent, filter - from astrbot_sdk.api.event.filter import EventMessageType - from astrbot_sdk.api.star.context import Context - - - class OrderedCommand(CommandComponent): - def __init__(self, context: Context): - self.context = context - - @filter.command("hello") - async def hello(self, event: AstrMessageEvent): - yield event.plain_result("hello") - - @filter.regex(r"^ping.*") - async def ping(self, event: AstrMessageEvent): - yield event.plain_result("ping") - - @filter.event_message_type(EventMessageType.GROUP_MESSAGE) - async def group_only(self, event: AstrMessageEvent): - yield event.plain_result("group") - """ - ), - encoding="utf-8", - ) - - manifest_path.write_text( - yaml.dump( - { - "name": "ordered_plugin", - "runtime": {"python": "3.12"}, - "components": [{"class": "commands.component:OrderedCommand"}], - } - ), - encoding="utf-8", - ) - requirements_path.write_text("", encoding="utf-8") - - spec = load_plugin_spec(plugin_dir) - try: - loaded = load_plugin(spec) - - trigger_order = [ - getattr(handler.descriptor.trigger, "command", None) - or getattr(handler.descriptor.trigger, "regex", None) - or ",".join( - getattr(handler.descriptor.trigger, "message_types", ()) - ) - for handler in loaded.handlers - ] - - assert trigger_order[:3] == ["hello", r"^ping.*", "group"] - finally: - plugin_path = str(plugin_dir) - if plugin_path in sys.path: - sys.path.remove(plugin_path) - for module_name in list(sys.modules): - if module_name == "commands" or module_name.startswith("commands."): - sys.modules.pop(module_name, None) - - def test_loads_component_capabilities(self): - """load_plugin should discover plugin-provided capabilities separately from handlers.""" - with tempfile.TemporaryDirectory() as temp_dir: - plugin_dir = Path(temp_dir) - manifest_path = plugin_dir / "plugin.yaml" - requirements_path = plugin_dir / "requirements.txt" - - module_dir = plugin_dir / "capmodule" - module_dir.mkdir() - (module_dir / "__init__.py").write_text("", encoding="utf-8") - (module_dir / "component.py").write_text( - textwrap.dedent( - """\ - from astrbot_sdk import Star, provide_capability - - - class MyComponent(Star): - @provide_capability( - "demo.echo", - description="Echo text", - input_schema={ - "type": "object", - "properties": {"text": {"type": "string"}}, - }, - output_schema={ - "type": "object", - "properties": {"echo": {"type": "string"}}, - }, - ) - async def echo(self, payload): - return {"echo": payload["text"]} - """ - ), - encoding="utf-8", - ) - - manifest_path.write_text( - yaml.dump( - { - "name": "cap_plugin", - "runtime": {"python": "3.12"}, - "components": [{"class": "capmodule.component:MyComponent"}], - } - ), - encoding="utf-8", - ) - requirements_path.write_text("", encoding="utf-8") - - spec = load_plugin_spec(plugin_dir) - - if str(plugin_dir) not in sys.path: - sys.path.insert(0, str(plugin_dir)) - - try: - loaded = load_plugin(spec) - assert [item.descriptor.name for item in loaded.capabilities] == [ - "demo.echo" - ] - assert len(loaded.handlers) == 0 - finally: - if str(plugin_dir) in sys.path: - sys.path.remove(str(plugin_dir)) - - def test_ignores_non_handler_descriptors_without_triggering_properties(self): - """load_plugin should not access unrelated properties during handler discovery.""" - with tempfile.TemporaryDirectory() as temp_dir: - plugin_dir = Path(temp_dir) - manifest_path = plugin_dir / "plugin.yaml" - requirements_path = plugin_dir / "requirements.txt" - - module_dir = plugin_dir / "mymodule" - module_dir.mkdir() - (module_dir / "__init__.py").write_text("", encoding="utf-8") - (module_dir / "component.py").write_text( - textwrap.dedent( - """\ - from astrbot_sdk import Star, on_command - - - class MyComponent(Star): - @property - def explode(self): - raise RuntimeError("property should not be touched") - - @on_command("hello") - async def hello_handler(self): - pass - """ - ), - encoding="utf-8", - ) - - manifest_path.write_text( - yaml.dump( - { - "name": "safe_loader_plugin", - "runtime": {"python": "3.12"}, - "components": [{"class": "mymodule.component:MyComponent"}], - } - ), - encoding="utf-8", - ) - requirements_path.write_text("", encoding="utf-8") - - spec = load_plugin_spec(plugin_dir) - - if str(plugin_dir) not in sys.path: - sys.path.insert(0, str(plugin_dir)) - - try: - loaded = load_plugin(spec) - assert len(loaded.instances) == 1 - assert [handler.descriptor.id for handler in loaded.handlers] - finally: - if str(plugin_dir) in sys.path: - sys.path.remove(str(plugin_dir)) - - @pytest.mark.asyncio - async def test_load_plugin_shares_legacy_context_between_components(self): - """Legacy components in one plugin should share the same LegacyContext.""" - with tempfile.TemporaryDirectory() as temp_dir: - plugin_dir = Path(temp_dir) / "test_plugin" - plugin_dir.mkdir() - manifest_path = plugin_dir / "plugin.yaml" - requirements_path = plugin_dir / "requirements.txt" - module_dir = plugin_dir / "legacy_pkg" - module_dir.mkdir() - (module_dir / "__init__.py").write_text("", encoding="utf-8") - (module_dir / "components.py").write_text( - textwrap.dedent( - """\ - from astrbot_sdk.api.components.command import CommandComponent - from astrbot_sdk.api.star.context import Context - - - class FirstComponent(CommandComponent): - def __init__(self, context: Context): - self.context = context - context._register_component(self) - - def echo(self, text: str) -> str: - return f"first:{text}" - - - class SecondComponent(CommandComponent): - def __init__(self, context: Context): - self.context = context - """ - ), - encoding="utf-8", - ) - - manifest_path.write_text( - yaml.dump( - { - "name": "test_plugin", - "runtime": {"python": "3.12"}, - "components": [ - {"class": "legacy_pkg.components:FirstComponent"}, - {"class": "legacy_pkg.components:SecondComponent"}, - ], - } - ), - encoding="utf-8", - ) - requirements_path.write_text("", encoding="utf-8") - - spec = load_plugin_spec(plugin_dir) - - if str(plugin_dir) not in sys.path: - sys.path.insert(0, str(plugin_dir)) - - try: - loaded = load_plugin(spec) - - assert len(loaded.instances) == 2 - assert loaded.instances[0].context is loaded.instances[1].context - result = await loaded.instances[1].context.call_context_function( - "FirstComponent.echo", - {"text": "hi"}, - ) - assert result == {"data": "first:hi"} - finally: - if str(plugin_dir) in sys.path: - sys.path.remove(str(plugin_dir)) - - def test_load_plugin_supports_legacy_main_and_config_schema(self): - """load_plugin should auto-discover main.py legacy stars and inject config.""" - with tempfile.TemporaryDirectory() as temp_dir: - plugin_dir = Path(temp_dir) / "legacy_plugin" - plugin_dir.mkdir() - (plugin_dir / "main.py").write_text( - textwrap.dedent( - """\ - from astrbot_sdk.api.event import AstrMessageEvent, filter - from astrbot_sdk.api.star import Context, Star - - - class LegacyPlugin(Star): - def __init__(self, context: Context, config): - super().__init__(context, config) - - @filter.command("hello") - async def hello(self, event: AstrMessageEvent): - yield event.plain_result(self.config["token"]) - """ - ), - encoding="utf-8", - ) - (plugin_dir / "metadata.yaml").write_text( - yaml.dump({"name": "legacy_plugin", "version": "1.0.0"}), - encoding="utf-8", - ) - (plugin_dir / "_conf_schema.json").write_text( - json.dumps( - { - "token": { - "type": "string", - "default": "demo-token", - }, - "nested": { - "type": "object", - "items": { - "enabled": { - "type": "bool", - "default": True, - } - }, - }, - } - ), - encoding="utf-8", - ) - - spec = load_plugin_spec(plugin_dir) - loaded = load_plugin(spec) - - assert len(loaded.instances) == 1 - instance = loaded.instances[0] - assert instance.context.plugin_id == "legacy_plugin" - assert instance.config["token"] == "demo-token" - assert instance.config["nested"] == {"enabled": True} - - config_path = plugin_dir / "data" / "config" / "legacy_plugin_config.json" - assert config_path.exists() - - instance.config["token"] = "changed" - instance.config.save_config() - persisted = json.loads(config_path.read_text(encoding="utf-8")) - assert persisted["token"] == "changed" - - def test_load_plugin_supports_legacy_astrbot_imports_relative_modules_and_groups( - self, - ): - """load_plugin should support real legacy package imports and command groups.""" - with tempfile.TemporaryDirectory() as temp_dir: - plugin_dir = Path(temp_dir) / "legacy_plugin" - plugin_dir.mkdir() - (plugin_dir / "src").mkdir() - (plugin_dir / "src" / "helper.py").write_text( - 'HELP_TEXT = "legacy-ok"\n', - encoding="utf-8", - ) - (plugin_dir / "main.py").write_text( - textwrap.dedent( - """\ - from astrbot.api.event import MessageChain, AstrMessageEvent, filter - from astrbot.api.star import Context, Star, StarTools, register - from astrbot.api import AstrBotConfig, logger - - from .src.helper import HELP_TEXT - - - @register("legacy_alias_demo", "tester", "demo", "1.0.0") - class LegacyPlugin(Star): - def __init__(self, context: Context, config: AstrBotConfig): - super().__init__(context, config) - self.data_dir = str(StarTools.get_data_dir()) - logger.info(HELP_TEXT) - - @filter.command("hello") - async def hello(self, event: AstrMessageEvent): - yield event.plain_result(HELP_TEXT) - - @filter.command_group("ccl") - def ccl(self): - pass - - @ccl.command("子命令") - async def sub(self, event: AstrMessageEvent): - yield MessageChain().message("sub") - """ - ), - encoding="utf-8", - ) - (plugin_dir / "metadata.yaml").write_text( - yaml.dump({"name": "legacy_alias_demo", "version": "1.0.0"}), - encoding="utf-8", - ) - - spec = load_plugin_spec(plugin_dir) - loaded = load_plugin(spec) - - assert len(loaded.instances) == 1 - instance = loaded.instances[0] - assert Path(instance.data_dir).resolve() == (plugin_dir / "data").resolve() - - commands = [ - handler.descriptor.trigger.command - for handler in loaded.handlers - if isinstance(handler.descriptor.trigger, CommandTrigger) - ] - assert commands == ["hello", "ccl 子命令"] - - -class TestStateFileConstant: - """Tests for STATE_FILE_NAME constant.""" - - def test_value(self): - """STATE_FILE_NAME should be correct.""" - assert STATE_FILE_NAME == ".astrbot-worker-state.json" - - -class TestGroupStateFileConstant: - """Tests for the shared environment state file constant.""" - - def test_value(self): - """GROUP_STATE_FILE_NAME should be correct.""" - assert GROUP_STATE_FILE_NAME == ".group-venv-state.json" diff --git a/tests_v4/test_new_plugin_integration.py b/tests_v4/test_new_plugin_integration.py deleted file mode 100644 index 567ab279c..000000000 --- a/tests_v4/test_new_plugin_integration.py +++ /dev/null @@ -1,117 +0,0 @@ -"""真实 v4 示例插件集成测试。""" - -from __future__ import annotations - -import sys -from pathlib import Path - -import pytest - -from astrbot_sdk.protocol.descriptors import ( - CommandTrigger, - EventTrigger, - MessageTrigger, - ScheduleTrigger, -) -from astrbot_sdk.runtime.loader import load_plugin, load_plugin_spec - - -class TestRealNewTestPlugin: - """验证仓库中的真实 v4 示例插件目录。""" - - def test_load_new_plugin(self): - project_root = Path(__file__).resolve().parent.parent - test_plugin_dir = project_root / "test_plugin" / "new" - - if not test_plugin_dir.exists(): - pytest.skip("test_plugin/new directory not found") - - spec = load_plugin_spec(test_plugin_dir) - - paths_to_add = [] - if str(test_plugin_dir) not in sys.path: - sys.path.insert(0, str(test_plugin_dir)) - paths_to_add.append(str(test_plugin_dir)) - - src_new = project_root / "src-new" - if str(src_new) not in sys.path: - sys.path.insert(0, str(src_new)) - paths_to_add.append(str(src_new)) - - try: - loaded = load_plugin(spec) - - assert loaded.plugin.name == "astrbot_plugin_v4demo" - assert len(loaded.instances) == 1 - - command_triggers = [ - handler.descriptor.trigger - for handler in loaded.handlers - if isinstance(handler.descriptor.trigger, CommandTrigger) - ] - message_triggers = [ - handler.descriptor.trigger - for handler in loaded.handlers - if isinstance(handler.descriptor.trigger, MessageTrigger) - ] - event_triggers = [ - handler.descriptor.trigger - for handler in loaded.handlers - if isinstance(handler.descriptor.trigger, EventTrigger) - ] - schedule_triggers = [ - handler.descriptor.trigger - for handler in loaded.handlers - if isinstance(handler.descriptor.trigger, ScheduleTrigger) - ] - handler_map = { - handler.descriptor.id: handler.descriptor for handler in loaded.handlers - } - - assert {trigger.command for trigger in command_triggers} == { - "announce", - "hello", - "platforms", - "raw", - "remember", - "secure", - } - hello_trigger = next( - trigger for trigger in command_triggers if trigger.command == "hello" - ) - assert "hi" in hello_trigger.aliases - secure_descriptor = next( - descriptor - for descriptor in handler_map.values() - if getattr(descriptor.trigger, "command", None) == "secure" - ) - assert secure_descriptor.permissions.require_admin is True - - assert len(message_triggers) == 1 - assert message_triggers[0].regex == r"^ping$" - assert message_triggers[0].keywords == ["ping"] - assert message_triggers[0].platforms == ["test"] - - assert len(event_triggers) == 1 - assert event_triggers[0].event_type == "group_join" - - assert len(schedule_triggers) == 1 - assert schedule_triggers[0].interval_seconds == 60 - - capability_names = [item.descriptor.name for item in loaded.capabilities] - assert capability_names == ["demo.echo", "demo.stream"] - stream_descriptor = next( - item.descriptor - for item in loaded.capabilities - if item.descriptor.name == "demo.stream" - ) - assert stream_descriptor.supports_stream is True - assert stream_descriptor.cancelable is True - finally: - for path in paths_to_add: - if path in sys.path: - sys.path.remove(path) - - for module_name in list(sys.modules): - if module_name == "commands" or module_name.startswith("commands."): - sys.modules.pop(module_name, None) diff --git a/tests_v4/test_protocol_legacy_adapter.py b/tests_v4/test_protocol_legacy_adapter.py deleted file mode 100644 index 031ac89cd..000000000 --- a/tests_v4/test_protocol_legacy_adapter.py +++ /dev/null @@ -1,783 +0,0 @@ -""" -Tests for protocol/legacy_adapter.py - Legacy protocol adapter. -""" - -from __future__ import annotations - -import pytest - -from astrbot_sdk.protocol.descriptors import ( - EventTrigger, - HandlerDescriptor, - Permissions, -) -from astrbot_sdk.protocol.legacy_adapter import ( - LEGACY_ADAPTER_MESSAGE_EVENT, - LEGACY_CONTEXT_CAPABILITY, - LEGACY_HANDSHAKE_METADATA_KEY, - LEGACY_JSONRPC_VERSION, - LEGACY_PLUGIN_KEYS_METADATA_KEY, - LegacyAdapter, - LegacyErrorData, - LegacyErrorResponse, - LegacyRequest, - LegacySuccessResponse, - 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, -) -from astrbot_sdk.protocol.messages import ( - CancelMessage, - ErrorPayload, - EventMessage, - InitializeMessage, - InvokeMessage, - PeerInfo, - ResultMessage, -) - - -class TestLegacyRequest: - """Tests for LegacyRequest model.""" - - def test_default_values(self): - """LegacyRequest should have default values.""" - req = LegacyRequest(method="test_method") - assert req.jsonrpc == LEGACY_JSONRPC_VERSION - assert req.id is None - assert req.method == "test_method" - assert req.params == {} - - def test_with_all_fields(self): - """LegacyRequest should accept all fields.""" - req = LegacyRequest( - id="req_001", - method="handshake", - params={"key": "value"}, - ) - assert req.id == "req_001" - assert req.method == "handshake" - assert req.params["key"] == "value" - - -class TestLegacySuccessResponse: - """Tests for LegacySuccessResponse model.""" - - def test_with_result(self): - """LegacySuccessResponse should accept result.""" - resp = LegacySuccessResponse(id="req_001", result={"status": "ok"}) - assert resp.jsonrpc == LEGACY_JSONRPC_VERSION - assert resp.id == "req_001" - assert resp.result["status"] == "ok" - - -class TestLegacyErrorResponse: - """Tests for LegacyErrorResponse model.""" - - def test_with_error(self): - """LegacyErrorResponse should accept error.""" - error = LegacyErrorData(code=-32000, message="Server error") - resp = LegacyErrorResponse(id="req_001", error=error) - assert resp.id == "req_001" - assert resp.error.code == -32000 - assert resp.error.message == "Server error" - - -class TestLegacyErrorData: - """Tests for LegacyErrorData model.""" - - def test_default_code(self): - """LegacyErrorData should have default code.""" - error = LegacyErrorData(message="Error") - assert error.code == -32000 - assert error.message == "Error" - assert error.data is None - - def test_with_data(self): - """LegacyErrorData should accept data.""" - error = LegacyErrorData( - code=-32600, - message="Invalid Request", - data={"details": "Missing field"}, - ) - assert error.code == -32600 - assert error.data["details"] == "Missing field" - - -class TestParseLegacyMessage: - """Tests for parse_legacy_message function.""" - - def test_parse_request(self): - """parse_legacy_message should parse LegacyRequest.""" - payload = {"jsonrpc": "2.0", "id": "1", "method": "test", "params": {}} - msg = parse_legacy_message(payload) - assert isinstance(msg, LegacyRequest) - assert msg.method == "test" - - def test_parse_request_from_json(self): - """parse_legacy_message should parse request from JSON string.""" - json_str = '{"jsonrpc": "2.0", "id": "1", "method": "handshake"}' - msg = parse_legacy_message(json_str) - assert isinstance(msg, LegacyRequest) - assert msg.method == "handshake" - - def test_parse_request_from_bytes(self): - """parse_legacy_message should parse request from bytes.""" - json_bytes = b'{"jsonrpc": "2.0", "method": "call_handler"}' - msg = parse_legacy_message(json_bytes) - assert isinstance(msg, LegacyRequest) - assert msg.method == "call_handler" - - def test_parse_success_response(self): - """parse_legacy_message should parse LegacySuccessResponse.""" - payload = {"jsonrpc": "2.0", "id": "1", "result": {"status": "ok"}} - msg = parse_legacy_message(payload) - assert isinstance(msg, LegacySuccessResponse) - assert msg.result["status"] == "ok" - - def test_parse_error_response(self): - """parse_legacy_message should parse LegacyErrorResponse.""" - payload = { - "jsonrpc": "2.0", - "id": "1", - "error": {"code": -32000, "message": "Error"}, - } - msg = parse_legacy_message(payload) - assert isinstance(msg, LegacyErrorResponse) - assert msg.error.message == "Error" - - def test_parse_unknown_raises(self): - """parse_legacy_message should raise for unknown type.""" - with pytest.raises(ValueError) as exc_info: - parse_legacy_message({"jsonrpc": "2.0", "unknown": "field"}) - assert "未知" in str(exc_info.value) - - def test_parse_non_mapping_raises(self): - """parse_legacy_message should reject non-object payloads.""" - with pytest.raises(ValueError, match="JSON object"): - parse_legacy_message(["not", "an", "object"]) - - def test_pass_through_legacy_message(self): - """parse_legacy_message should pass through already-parsed messages.""" - req = LegacyRequest(method="test") - result = parse_legacy_message(req) - assert result is req - - -class TestLegacyAdapterInit: - """Tests for LegacyAdapter initialization.""" - - def test_default_values(self): - """LegacyAdapter should have default values.""" - adapter = LegacyAdapter() - assert adapter.protocol_version == "1.0" - assert adapter.legacy_peer_name == "legacy-peer" - assert adapter.legacy_peer_role == "plugin" - assert adapter.legacy_peer_version is None - - def test_custom_values(self): - """LegacyAdapter should accept custom values.""" - adapter = LegacyAdapter( - protocol_version="2.0", - legacy_peer_name="custom-peer", - legacy_peer_role="core", - legacy_peer_version="1.5.0", - ) - assert adapter.protocol_version == "2.0" - assert adapter.legacy_peer_name == "custom-peer" - assert adapter.legacy_peer_role == "core" - assert adapter.legacy_peer_version == "1.5.0" - - -class TestLegacyAdapterTrackHandler: - """Tests for LegacyAdapter.track_handler method.""" - - def test_track_handler(self): - """track_handler should store handler name by request ID.""" - adapter = LegacyAdapter() - adapter.track_handler("req_001", "module.handler") - assert adapter._handler_names_by_request_id["req_001"] == "module.handler" - - def test_track_handler_empty_id(self): - """track_handler should not store for empty request ID.""" - adapter = LegacyAdapter() - adapter.track_handler("", "module.handler") - assert "" not in adapter._handler_names_by_request_id - - -class TestLegacyAdapterHandshake: - """Tests for LegacyAdapter handshake handling.""" - - def test_legacy_request_to_handshake(self): - """legacy_request_to_message should convert handshake request.""" - adapter = LegacyAdapter() - req = LegacyRequest(id="req_001", method="handshake", params={}) - msg = adapter.legacy_request_to_message(req) - - assert isinstance(msg, InitializeMessage) - assert msg.protocol_version == "1.0" - assert msg.peer.name == "legacy-peer" - assert msg.peer.role == "plugin" - assert msg.metadata.get("legacy_handshake") is True - - def test_build_legacy_handshake_request(self): - """build_legacy_handshake_request should create handshake request.""" - adapter = LegacyAdapter() - result = adapter.build_legacy_handshake_request("req_001") - - assert result["jsonrpc"] == LEGACY_JSONRPC_VERSION - assert result["id"] == "req_001" - assert result["method"] == "handshake" - - -class TestLegacyAdapterCallHandler: - """Tests for LegacyAdapter call_handler handling.""" - - def test_legacy_request_to_call_handler(self): - """legacy_request_to_message should convert call_handler request.""" - adapter = LegacyAdapter() - req = LegacyRequest( - id="req_001", - method="call_handler", - params={ - "handler_full_name": "module.handler", - "event": {"type": "message"}, - "args": {"key": "value"}, - }, - ) - msg = adapter.legacy_request_to_message(req) - - assert isinstance(msg, InvokeMessage) - assert msg.capability == "handler.invoke" - assert msg.input["handler_id"] == "module.handler" - assert msg.input["event"]["type"] == "message" - - def test_call_handler_tracks_handler(self): - """call_handler should track handler name.""" - adapter = LegacyAdapter() - req = LegacyRequest( - id="req_001", - method="call_handler", - params={"handler_full_name": "test.handler"}, - ) - adapter.legacy_request_to_message(req) - assert adapter._handler_names_by_request_id["req_001"] == "test.handler" - - -class TestLegacyAdapterContextFunction: - """Tests for LegacyAdapter call_context_function handling.""" - - def test_legacy_request_to_context_function(self): - """legacy_request_to_message should convert call_context_function.""" - adapter = LegacyAdapter() - req = LegacyRequest( - id="req_001", - method="call_context_function", - params={"name": "get_user", "args": {"user_id": 123}}, - ) - msg = adapter.legacy_request_to_message(req) - - assert isinstance(msg, InvokeMessage) - assert msg.capability == LEGACY_CONTEXT_CAPABILITY - assert msg.input["name"] == "get_user" - assert msg.input["args"]["user_id"] == 123 - - -class TestLegacyAdapterStreamMethods: - """Tests for LegacyAdapter stream handling.""" - - def test_handler_stream_start(self): - """legacy_request_to_message should convert handler_stream_start.""" - adapter = LegacyAdapter() - req = LegacyRequest( - method="handler_stream_start", - params={"id": "stream_001", "handler_full_name": "module.handler"}, - ) - msg = adapter.legacy_request_to_message(req) - - assert isinstance(msg, EventMessage) - assert msg.phase == "started" - - def test_handler_stream_update(self): - """legacy_request_to_message should convert handler_stream_update.""" - adapter = LegacyAdapter() - req = LegacyRequest( - method="handler_stream_update", - params={ - "id": "stream_001", - "handler_full_name": "module.handler", - "data": {"text": "chunk"}, - }, - ) - msg = adapter.legacy_request_to_message(req) - - assert isinstance(msg, EventMessage) - assert msg.phase == "delta" - assert msg.data["text"] == "chunk" - - def test_handler_stream_end_completed(self): - """legacy_request_to_message should convert handler_stream_end (completed).""" - adapter = LegacyAdapter() - req = LegacyRequest( - method="handler_stream_end", - params={"id": "stream_001", "handler_full_name": "module.handler"}, - ) - msg = adapter.legacy_request_to_message(req) - - assert isinstance(msg, EventMessage) - assert msg.phase == "completed" - # completed phase 需要有 output 字段 - assert msg.output is not None - - def test_handler_stream_end_failed(self): - """legacy_request_to_message should convert handler_stream_end (failed).""" - adapter = LegacyAdapter() - req = LegacyRequest( - method="handler_stream_end", - params={ - "id": "stream_001", - "handler_full_name": "module.handler", - "error": {"message": "Something went wrong"}, - }, - ) - msg = adapter.legacy_request_to_message(req) - - assert isinstance(msg, EventMessage) - assert msg.phase == "failed" - assert msg.error is not None - assert msg.error.message == "Something went wrong" - - -class TestLegacyAdapterCancel: - """Tests for LegacyAdapter cancel handling.""" - - def test_legacy_request_to_cancel(self): - """legacy_request_to_message should convert cancel request.""" - adapter = LegacyAdapter() - req = LegacyRequest( - id="req_001", - method="cancel", - params={"reason": "user_cancelled"}, - ) - msg = adapter.legacy_request_to_message(req) - - assert isinstance(msg, CancelMessage) - assert msg.reason == "user_cancelled" - - -class TestLegacyAdapterGenericMethod: - """Tests for LegacyAdapter generic method handling.""" - - def test_unknown_method_becomes_invoke(self): - """Unknown methods should become InvokeMessage with capability=method.""" - adapter = LegacyAdapter() - req = LegacyRequest( - id="req_001", - method="custom.capability", - params={"key": "value"}, - ) - msg = adapter.legacy_request_to_message(req) - - assert isinstance(msg, InvokeMessage) - assert msg.capability == "custom.capability" - assert msg.input["key"] == "value" - - -class TestLegacyAdapterResponseHandling: - """Tests for LegacyAdapter response handling.""" - - def test_success_response_to_result(self): - """legacy_response_to_message should convert success response.""" - adapter = LegacyAdapter() - resp = LegacySuccessResponse(id="req_001", result={"status": "ok"}) - msg = adapter.legacy_response_to_message(resp) - - assert isinstance(msg, ResultMessage) - assert msg.success is True - assert msg.output["status"] == "ok" - - def test_error_response_to_result(self): - """legacy_error_to_result should convert error response.""" - adapter = LegacyAdapter() - error = LegacyErrorData(code=-32000, message="Server error") - resp = LegacyErrorResponse(id="req_001", error=error) - msg = adapter.legacy_error_to_result(resp) - - assert isinstance(msg, ResultMessage) - assert msg.success is False - assert msg.error.code == "legacy_rpc_error" - assert msg.error.message == "Server error" - - def test_handshake_response_to_initialize(self): - """legacy_response_to_message should detect handshake response.""" - adapter = LegacyAdapter() - resp = LegacySuccessResponse( - id="req_001", - result={ - "module.path": { - "name": "test-plugin", - "version": "1.0.0", - "handlers": [], - } - }, - ) - msg = adapter.legacy_response_to_message(resp) - - assert isinstance(msg, InitializeMessage) - assert msg.peer.name == "test-plugin" - assert msg.peer.version == "1.0.0" - - -class TestLegacyAdapterV4ToLegacy: - """Tests for LegacyAdapter V4 to legacy conversion.""" - - def test_initialize_to_legacy_handshake_response(self): - """initialize_to_legacy_handshake_response should convert InitializeMessage.""" - adapter = LegacyAdapter() - init_msg = InitializeMessage( - id="msg_001", - protocol_version="1.0", - peer=PeerInfo(name="test", role="plugin", version="1.0.0"), - handlers=[], - metadata={ - "plugin_id": "test-plugin", - "display_name": "Test Plugin", - }, - ) - result = adapter.initialize_to_legacy_handshake_response(init_msg) - - assert result["jsonrpc"] == LEGACY_JSONRPC_VERSION - assert result["id"] == "msg_001" - assert "result" in result - - def test_initialize_with_legacy_payload(self): - """initialize_to_legacy_handshake_response should preserve legacy payload.""" - adapter = LegacyAdapter() - legacy_payload = { - "module.path": { - "name": "test-plugin", - "version": "1.0.0", - "handlers": [], - } - } - init_msg = InitializeMessage( - id="msg_001", - protocol_version="1.0", - peer=PeerInfo(name="test", role="plugin", version="1.0.0"), - handlers=[], - metadata={LEGACY_HANDSHAKE_METADATA_KEY: legacy_payload}, - ) - result = adapter.initialize_to_legacy_handshake_response(init_msg) - - assert result["result"] == legacy_payload - - def test_invoke_to_legacy_request_handler(self): - """invoke_to_legacy_request should convert handler.invoke.""" - adapter = LegacyAdapter() - invoke_msg = InvokeMessage( - id="msg_001", - capability="handler.invoke", - input={ - "handler_id": "module.handler", - "event": {"type": "message"}, - "args": {}, - }, - ) - result = adapter.invoke_to_legacy_request(invoke_msg) - - assert result["method"] == "call_handler" - assert result["params"]["handler_full_name"] == "module.handler" - - def test_invoke_to_legacy_request_context_function(self): - """invoke_to_legacy_request should convert context function.""" - adapter = LegacyAdapter() - invoke_msg = InvokeMessage( - id="msg_001", - capability=LEGACY_CONTEXT_CAPABILITY, - input={"name": "get_user", "args": {}}, - ) - result = adapter.invoke_to_legacy_request(invoke_msg) - - assert result["method"] == "call_context_function" - assert result["params"]["name"] == "get_user" - - def test_invoke_to_legacy_request_generic(self): - """invoke_to_legacy_request should convert generic capability.""" - adapter = LegacyAdapter() - invoke_msg = InvokeMessage( - id="msg_001", - capability="custom.capability", - input={"key": "value"}, - ) - result = adapter.invoke_to_legacy_request(invoke_msg) - - assert result["method"] == "custom.capability" - assert result["params"]["key"] == "value" - - def test_result_to_legacy_response_success(self): - """result_to_legacy_response should convert success result.""" - adapter = LegacyAdapter() - result_msg = ResultMessage( - id="msg_001", - success=True, - output={"status": "ok"}, - ) - result = adapter.result_to_legacy_response(result_msg) - - assert "result" in result - assert result["result"]["status"] == "ok" - - def test_result_to_legacy_response_error(self): - """result_to_legacy_response should convert error result.""" - adapter = LegacyAdapter() - result_msg = ResultMessage( - id="msg_001", - success=False, - error=ErrorPayload(code="error", message="Failed"), - ) - result = adapter.result_to_legacy_response(result_msg) - - assert "error" in result - assert result["error"]["message"] == "Failed" - - def test_event_to_legacy_notification_started(self): - """event_to_legacy_notification should convert started event.""" - adapter = LegacyAdapter() - adapter.track_handler("msg_001", "module.handler") - event_msg = EventMessage(id="msg_001", phase="started") - result = adapter.event_to_legacy_notification(event_msg) - - assert result["method"] == "handler_stream_start" - assert result["params"]["handler_full_name"] == "module.handler" - - def test_event_to_legacy_notification_delta(self): - """event_to_legacy_notification should convert delta event.""" - adapter = LegacyAdapter() - event_msg = EventMessage( - id="msg_001", - phase="delta", - data={"text": "chunk"}, - ) - result = adapter.event_to_legacy_notification(event_msg) - - assert result["method"] == "handler_stream_update" - assert result["params"]["data"]["text"] == "chunk" - - def test_event_to_legacy_notification_completed(self): - """event_to_legacy_notification should convert completed event.""" - adapter = LegacyAdapter() - # completed phase 需要 output 字段 - event_msg = EventMessage( - id="msg_001", phase="completed", output={"result": "done"} - ) - result = adapter.event_to_legacy_notification(event_msg) - - assert result["method"] == "handler_stream_end" - - def test_event_to_legacy_notification_failed(self): - """event_to_legacy_notification should convert failed event.""" - adapter = LegacyAdapter() - event_msg = EventMessage( - id="msg_001", - phase="failed", - error=ErrorPayload(code="error", message="Failed"), - ) - result = adapter.event_to_legacy_notification(event_msg) - - assert result["method"] == "handler_stream_end" - assert result["params"]["error"]["message"] == "Failed" - - def test_cancel_to_legacy_request(self): - """cancel_to_legacy_request should convert cancel message.""" - adapter = LegacyAdapter() - cancel_msg = CancelMessage(id="msg_001", reason="user_request") - result = adapter.cancel_to_legacy_request(cancel_msg) - - assert result["method"] == "cancel" - assert result["params"]["reason"] == "user_request" - - -class TestLegacyAdapterHandlerDescriptors: - """Tests for LegacyAdapter handler descriptor conversion.""" - - def test_legacy_handlers_to_descriptors(self): - """_legacy_handlers_to_descriptors should convert handlers.""" - adapter = LegacyAdapter() - payload = { - "module.path": { - "handlers": [ - { - "handler_full_name": "module.handler", - "event_type": "3", - "extras_configs": { - "priority": 10, - "require_admin": True, - "level": 5, - }, - } - ] - } - } - handlers = adapter._legacy_handlers_to_descriptors(payload) - - assert len(handlers) == 1 - assert handlers[0].id == "module.handler" - assert handlers[0].priority == 10 - assert handlers[0].permissions.require_admin is True - assert handlers[0].permissions.level == 5 - - def test_descriptor_to_legacy_handler(self): - """_descriptor_to_legacy_handler should convert HandlerDescriptor.""" - descriptor = HandlerDescriptor( - id="module.handler", - trigger=EventTrigger(event_type="3"), - priority=10, - permissions=Permissions(require_admin=True, level=5), - ) - result = LegacyAdapter._descriptor_to_legacy_handler(descriptor) - - assert result["handler_full_name"] == "module.handler" - assert result["event_type"] == 3 - assert result["extras_configs"]["priority"] == 10 - assert result["extras_configs"]["require_admin"] is True - - -class TestLegacyAdapterHelpers: - """Tests for LegacyAdapter helper methods.""" - - def test_request_id_with_value(self): - """_request_id should return string value.""" - result = LegacyAdapter._request_id("req_001", "fallback") - assert result == "req_001" - - def test_request_id_with_none(self): - """_request_id should return fallback for None.""" - result = LegacyAdapter._request_id(None, "fallback") - assert result == "fallback" - - def test_request_id_with_empty_string(self): - """_request_id should return fallback for empty string.""" - result = LegacyAdapter._request_id("", "fallback") - assert result == "fallback" - - def test_as_dict_with_dict(self): - """_as_dict should pass through dict.""" - result = LegacyAdapter._as_dict({"key": "value"}, field_name="data") - assert result == {"key": "value"} - - def test_as_dict_with_none(self): - """_as_dict should return empty dict for None.""" - result = LegacyAdapter._as_dict(None, field_name="data") - assert result == {} - - def test_as_dict_with_other(self): - """_as_dict should wrap other values.""" - result = LegacyAdapter._as_dict("value", field_name="data") - assert result == {"data": "value"} - - def test_looks_like_handshake_payload_valid(self): - """_looks_like_handshake_payload should detect valid payload.""" - payload = {"module.path": {"handlers": []}} - assert LegacyAdapter._looks_like_handshake_payload(payload) is True - - def test_looks_like_handshake_payload_invalid(self): - """_looks_like_handshake_payload should reject invalid payload.""" - assert LegacyAdapter._looks_like_handshake_payload({}) is False - assert LegacyAdapter._looks_like_handshake_payload({"key": "value"}) is False - assert LegacyAdapter._looks_like_handshake_payload(None) is False - - -class TestLegacyConvenienceFunctions: - """Tests for module-level convenience functions.""" - - def test_legacy_message_to_v4(self): - """legacy_message_to_v4 should convert legacy message.""" - payload = {"jsonrpc": "2.0", "method": "handshake"} - msg = legacy_message_to_v4(payload) - assert isinstance(msg, InitializeMessage) - - def test_legacy_request_to_invoke(self): - """legacy_request_to_invoke should convert to InvokeMessage.""" - payload = { - "jsonrpc": "2.0", - "id": "1", - "method": "custom.capability", - "params": {}, - } - msg = legacy_request_to_invoke(payload) - assert isinstance(msg, InvokeMessage) - assert msg.capability == "custom.capability" - - def test_legacy_request_to_invoke_non_invoke_raises(self): - """legacy_request_to_invoke should raise for non-invoke messages.""" - payload = {"jsonrpc": "2.0", "method": "handshake"} - with pytest.raises(ValueError, match="不能直接映射为 invoke"): - legacy_request_to_invoke(payload) - - def test_legacy_response_to_message(self): - """legacy_response_to_message should convert response.""" - payload = {"jsonrpc": "2.0", "id": "1", "result": {"status": "ok"}} - msg = legacy_response_to_message(payload) - assert isinstance(msg, ResultMessage) - - def test_initialize_to_legacy_handshake_response(self): - """initialize_to_legacy_handshake_response should convert.""" - msg = InitializeMessage( - id="msg_001", - protocol_version="1.0", - peer=PeerInfo(name="test", role="plugin"), - handlers=[], - ) - result = initialize_to_legacy_handshake_response(msg) - assert result["jsonrpc"] == LEGACY_JSONRPC_VERSION - - def test_invoke_to_legacy_request(self): - """invoke_to_legacy_request should convert.""" - msg = InvokeMessage(id="msg_001", capability="test.cap", input={}) - result = invoke_to_legacy_request(msg) - assert result["method"] == "test.cap" - - def test_result_to_legacy_response(self): - """result_to_legacy_response should convert.""" - msg = ResultMessage(id="msg_001", success=True, output={"ok": True}) - result = result_to_legacy_response(msg) - assert result["result"]["ok"] is True - - def test_event_to_legacy_notification(self): - """event_to_legacy_notification should convert.""" - msg = EventMessage(id="msg_001", phase="started") - result = event_to_legacy_notification(msg, handler_full_name="test.handler") - assert result["method"] == "handler_stream_start" - - def test_cancel_to_legacy_request(self): - """cancel_to_legacy_request should convert.""" - msg = CancelMessage(id="msg_001", reason="test") - result = cancel_to_legacy_request(msg) - assert result["method"] == "cancel" - - -class TestLegacyConstants: - """Tests for legacy adapter constants.""" - - def test_jsonrpc_version(self): - """LEGACY_JSONRPC_VERSION should be 2.0.""" - assert LEGACY_JSONRPC_VERSION == "2.0" - - def test_context_capability(self): - """LEGACY_CONTEXT_CAPABILITY should be internal capability.""" - assert LEGACY_CONTEXT_CAPABILITY == "internal.legacy.call_context_function" - - def test_message_event(self): - """LEGACY_ADAPTER_MESSAGE_EVENT should be 3.""" - assert LEGACY_ADAPTER_MESSAGE_EVENT == 3 - - def test_metadata_keys(self): - """Metadata keys should be defined.""" - assert LEGACY_HANDSHAKE_METADATA_KEY == "legacy_handshake_payload" - assert LEGACY_PLUGIN_KEYS_METADATA_KEY == "legacy_plugin_keys" diff --git a/tests_v4/test_protocol_package.py b/tests_v4/test_protocol_package.py deleted file mode 100644 index b5352de30..000000000 --- a/tests_v4/test_protocol_package.py +++ /dev/null @@ -1,73 +0,0 @@ -"""Tests for protocol package exports.""" - -from __future__ import annotations - -import astrbot_sdk.protocol as protocol_module - -from astrbot_sdk.protocol import ( - CapabilityDescriptor, - CommandTrigger, - ErrorPayload, - EventMessage, - HandlerDescriptor, - InitializeMessage, - MessageTrigger, - PeerInfo, - ProtocolMessage, - ResultMessage, - ScheduleTrigger, - parse_message, -) -from astrbot_sdk.protocol.descriptors import BUILTIN_CAPABILITY_SCHEMAS -from astrbot_sdk.protocol.legacy_adapter import ( - LegacyAdapter, - LegacyRequest, - parse_legacy_message, -) - - -class TestProtocolPackageExports: - """Ensure protocol package exposes the intended public surface.""" - - def test_core_exports_are_importable(self): - """Core protocol models and parsers should be importable from package root.""" - handler = HandlerDescriptor( - id="demo.handler", - trigger=CommandTrigger(command="hello"), - ) - message = InitializeMessage( - id="msg-1", - protocol_version="1.0", - peer=PeerInfo(name="plugin", role="plugin"), - handlers=[handler], - ) - parsed: ProtocolMessage = parse_message(message) - - assert isinstance(parsed, InitializeMessage) - assert isinstance(ErrorPayload(code="x", message="y"), ErrorPayload) - assert isinstance( - CapabilityDescriptor( - name="llm.chat", - description="chat", - input_schema=BUILTIN_CAPABILITY_SCHEMAS["llm.chat"]["input"], - output_schema=BUILTIN_CAPABILITY_SCHEMAS["llm.chat"]["output"], - ), - CapabilityDescriptor, - ) - assert isinstance(MessageTrigger(keywords=["hello"]), MessageTrigger) - assert isinstance(ScheduleTrigger(interval_seconds=60), ScheduleTrigger) - assert isinstance(EventMessage(id="evt-1", phase="started"), EventMessage) - assert isinstance(ResultMessage(id="res-1", success=True), ResultMessage) - - def test_protocol_root_does_not_reexport_legacy_helpers(self): - """protocol root should stay focused on native v4 models.""" - assert not hasattr(protocol_module, "LegacyAdapter") - assert not hasattr(protocol_module, "LegacyRequest") - assert not hasattr(protocol_module, "parse_legacy_message") - - def test_legacy_exports_are_available_from_submodule(self): - """Legacy adapter helpers remain available from the explicit submodule.""" - legacy = parse_legacy_message({"jsonrpc": "2.0", "method": "handshake"}) - - assert isinstance(legacy, LegacyRequest) - assert isinstance(LegacyAdapter(), LegacyAdapter) diff --git a/tests_v4/test_runtime.py b/tests_v4/test_runtime.py deleted file mode 100644 index de1127fd1..000000000 --- a/tests_v4/test_runtime.py +++ /dev/null @@ -1,414 +0,0 @@ -from __future__ import annotations - -import asyncio -import tempfile -import unittest -from pathlib import Path - -from astrbot_sdk.protocol.messages import InitializeOutput, PeerInfo -from astrbot_sdk.runtime.bootstrap import SupervisorRuntime -from astrbot_sdk.runtime.peer import Peer - -from tests_v4.helpers import ( - FakeEnvManager, - copy_sample_plugin, - make_transport_pair, -) - - -class RuntimeIntegrationTest(unittest.IsolatedAsyncioTestCase): - async def asyncSetUp(self) -> None: - self.left, self.right = make_transport_pair() - self.core = Peer( - transport=self.left, - peer_info=PeerInfo(name="outer-core", role="core", version="v4"), - ) - self.core.set_initialize_handler( - lambda _message: asyncio.sleep( - 0, - result=InitializeOutput( - peer=PeerInfo(name="outer-core", role="core", version="v4"), - capabilities=[], - metadata={}, - ), - ) - ) - await self.core.start() - - async def asyncTearDown(self) -> None: - await self.core.stop() - - def _find_handler_id(self, command_name: str) -> str: - return next( - handler.id - for handler in self.core.remote_handlers - if getattr(handler.trigger, "command", None) == command_name - ) - - async def test_supervisor_runs_v4_plugin_over_stdio_worker(self) -> None: - with tempfile.TemporaryDirectory() as temp_dir: - plugins_root = Path(temp_dir) / "plugins" - plugin_root = plugins_root / "v4_plugin" - copy_sample_plugin("new", plugin_root) - - runtime = SupervisorRuntime( - transport=self.right, - plugins_dir=plugins_root, - env_manager=FakeEnvManager(), - ) - try: - await runtime.start() - await self.core.wait_until_remote_initialized() - handler_id = self._find_handler_id("hello") - - await self.core.invoke( - "handler.invoke", - { - "handler_id": handler_id, - "event": { - "text": "hello", - "session_id": "session-1", - "user_id": "user-1", - "platform": "test", - }, - }, - request_id="call-v4", - ) - texts = [ - item.get("text") for item in runtime.capability_router.sent_messages - ] - self.assertEqual(texts, ["Echo: hello", "Echo: stream"]) - finally: - await runtime.stop() - - async def test_supervisor_runs_v4_plugin_client_commands(self) -> None: - with tempfile.TemporaryDirectory() as temp_dir: - plugins_root = Path(temp_dir) / "plugins" - plugin_root = plugins_root / "v4_plugin" - copy_sample_plugin("new", plugin_root) - - runtime = SupervisorRuntime( - transport=self.right, - plugins_dir=plugins_root, - env_manager=FakeEnvManager(), - ) - try: - await runtime.start() - await self.core.wait_until_remote_initialized() - - for request_id, command_name in ( - ("call-v4-raw", "raw"), - ("call-v4-remember", "remember"), - ("call-v4-platforms", "platforms"), - ): - await self.core.invoke( - "handler.invoke", - { - "handler_id": self._find_handler_id(command_name), - "event": { - "text": command_name, - "session_id": "session-v4-clients", - "user_id": "user-1", - "platform": "test", - }, - }, - request_id=request_id, - ) - - texts = [ - item.get("text") - for item in runtime.capability_router.sent_messages - if "text" in item - ] - self.assertTrue( - any(text.startswith("raw=Echo: raw|finish=stop|") for text in texts) - ) - self.assertTrue( - any( - text.startswith( - "remembered=user-1|searched=1|session=session-v4-clients|keys=1" - ) - for text in texts - ) - ) - image_message = next( - item - for item in runtime.capability_router.sent_messages - if item.get("image_url") == "https://example.com/demo.png" - ) - self.assertEqual(image_message["session"], "session-v4-clients") - self.assertEqual( - image_message["target"]["conversation_id"], - "session-v4-clients", - ) - self.assertTrue( - any( - item.get("text", "").startswith( - "members=2 first=session-v4-clients:member-1" - ) - for item in runtime.capability_router.sent_messages - ) - ) - finally: - await runtime.stop() - - async def test_supervisor_exposes_real_v4_plugin_capability(self) -> None: - with tempfile.TemporaryDirectory() as temp_dir: - plugins_root = Path(temp_dir) / "plugins" - plugin_root = plugins_root / "v4_plugin" - copy_sample_plugin("new", plugin_root) - - runtime = SupervisorRuntime( - transport=self.right, - plugins_dir=plugins_root, - env_manager=FakeEnvManager(), - ) - try: - await runtime.start() - await self.core.wait_until_remote_initialized() - - capability_names = { - descriptor.name - for descriptor in self.core.remote_provided_capabilities - } - self.assertIn("demo.echo", capability_names) - self.assertIn("demo.stream", capability_names) - - result = await self.core.invoke( - "demo.echo", - {"text": "capability"}, - request_id="call-v4-capability", - ) - self.assertEqual( - result, - { - "echo": "capability", - "plugin_id": "astrbot_plugin_v4demo", - }, - ) - finally: - await runtime.stop() - - async def test_supervisor_runs_v4_plugin_chain_send(self) -> None: - with tempfile.TemporaryDirectory() as temp_dir: - plugins_root = Path(temp_dir) / "plugins" - plugin_root = plugins_root / "v4_plugin" - copy_sample_plugin("new", plugin_root) - - runtime = SupervisorRuntime( - transport=self.right, - plugins_dir=plugins_root, - env_manager=FakeEnvManager(), - ) - try: - await runtime.start() - await self.core.wait_until_remote_initialized() - handler_id = self._find_handler_id("announce") - - await self.core.invoke( - "handler.invoke", - { - "handler_id": handler_id, - "event": { - "text": "announce", - "session_id": "session-chain", - "user_id": "user-1", - "platform": "test", - }, - }, - request_id="call-v4-chain", - ) - chain_message = runtime.capability_router.sent_messages[-1] - self.assertEqual(chain_message["session"], "session-chain") - self.assertEqual( - chain_message["target"]["conversation_id"], - "session-chain", - ) - self.assertEqual(chain_message["chain"][0]["text"], "Demo ") - self.assertEqual( - chain_message["chain"][1]["file"], - "https://example.com/demo.png", - ) - finally: - await runtime.stop() - - async def test_supervisor_exposes_real_v4_stream_capability(self) -> None: - with tempfile.TemporaryDirectory() as temp_dir: - plugins_root = Path(temp_dir) / "plugins" - plugin_root = plugins_root / "v4_plugin" - copy_sample_plugin("new", plugin_root) - - runtime = SupervisorRuntime( - transport=self.right, - plugins_dir=plugins_root, - env_manager=FakeEnvManager(), - ) - try: - await runtime.start() - await self.core.wait_until_remote_initialized() - - stream = await self.core.invoke_stream( - "demo.stream", - {"text": "abc"}, - request_id="call-v4-stream-capability", - ) - chunks = [event.data["text"] async for event in stream] - self.assertEqual(chunks, ["a", "b", "c"]) - finally: - await runtime.stop() - - async def test_supervisor_runs_compat_plugin(self) -> None: - with tempfile.TemporaryDirectory() as temp_dir: - plugins_root = Path(temp_dir) / "plugins" - plugin_root = plugins_root / "compat_plugin" - copy_sample_plugin("old", plugin_root) - - runtime = SupervisorRuntime( - transport=self.right, - plugins_dir=plugins_root, - env_manager=FakeEnvManager(), - ) - try: - await runtime.start() - await self.core.wait_until_remote_initialized() - handler_id = self._find_handler_id("hello") - - await self.core.invoke( - "handler.invoke", - { - "handler_id": handler_id, - "event": { - "text": "/hello", - "session_id": "session-compat", - "user_id": "user-1", - "platform": "test", - }, - }, - request_id="call-compat", - ) - texts = [ - item.get("text") for item in runtime.capability_router.sent_messages - ] - self.assertEqual(len(texts), 1) - self.assertIn("Created conversation ID", texts[0]) - finally: - await runtime.stop() - - async def test_supervisor_runs_compat_plugin_extended_api_commands(self) -> None: - with tempfile.TemporaryDirectory() as temp_dir: - plugins_root = Path(temp_dir) / "plugins" - plugin_root = plugins_root / "compat_plugin" - copy_sample_plugin("old", plugin_root) - - runtime = SupervisorRuntime( - transport=self.right, - plugins_dir=plugins_root, - env_manager=FakeEnvManager(), - ) - try: - await runtime.start() - await self.core.wait_until_remote_initialized() - - for request_id, command_name in ( - ("call-compat-ai", "ai"), - ("call-compat-conversation", "conversation"), - ("call-compat-sendmsg", "sendmsg"), - ("call-compat-chain", "chain"), - ("call-compat-components", "components"), - ): - await self.core.invoke( - "handler.invoke", - { - "handler_id": self._find_handler_id(command_name), - "event": { - "text": command_name, - "session_id": "session-compat-extended", - "user_id": "user-1", - "platform": "test", - }, - }, - request_id=request_id, - ) - - texts = [ - item.get("text") - for item in runtime.capability_router.sent_messages - if "text" in item - ] - self.assertTrue( - any( - text.startswith( - "LLM:Echo: legacy hello|AGENT:Echo: legacy hello" - ) - for text in texts - ) - ) - self.assertTrue( - any( - text.startswith("conversation=") and "|helper=COMPAT" in text - for text in texts - ) - ) - self.assertTrue(any(text == "send_message invoked" for text in texts)) - chain_messages = [ - item - for item in runtime.capability_router.sent_messages - if "chain" in item - ] - self.assertTrue( - any( - any( - component.get("type") == "At" - and component.get("user_id") == "all" - for component in item["chain"] - ) - for item in chain_messages - ) - ) - self.assertTrue( - any( - any( - component.get("type") == "Node" - for component in item["chain"] - ) - for item in chain_messages - ) - ) - finally: - await runtime.stop() - - async def test_supervisor_exposes_compat_plugin_capability(self) -> None: - with tempfile.TemporaryDirectory() as temp_dir: - plugins_root = Path(temp_dir) / "plugins" - plugin_root = plugins_root / "compat_plugin" - copy_sample_plugin("old", plugin_root) - - runtime = SupervisorRuntime( - transport=self.right, - plugins_dir=plugins_root, - env_manager=FakeEnvManager(), - ) - try: - await runtime.start() - await self.core.wait_until_remote_initialized() - - capability_names = { - descriptor.name - for descriptor in self.core.remote_provided_capabilities - } - self.assertIn("compat.echo", capability_names) - - result = await self.core.invoke( - "compat.echo", - {"text": "legacy-capability"}, - request_id="call-compat-capability", - ) - self.assertEqual( - result, - { - "echo": "legacy-capability", - "plugin_id": "astrbot_plugin_helloworld", - }, - ) - finally: - await runtime.stop() diff --git a/tests_v4/test_runtime_contracts.py b/tests_v4/test_runtime_contracts.py deleted file mode 100644 index 603550c76..000000000 --- a/tests_v4/test_runtime_contracts.py +++ /dev/null @@ -1,72 +0,0 @@ -"""Contract guards for the current public runtime/compat surface.""" - -from __future__ import annotations - -from importlib import import_module - -from astrbot_sdk.api.event import filter as compat_filter_namespace -from astrbot_sdk.protocol.descriptors import BUILTIN_CAPABILITY_SCHEMAS -from astrbot_sdk.runtime.capability_router import CapabilityRouter - -EXPECTED_PUBLIC_BUILTIN_CAPABILITIES = { - "llm.chat": False, - "llm.chat_raw": False, - "llm.stream_chat": True, - "memory.search": False, - "memory.save": False, - "memory.get": False, - "memory.delete": False, - "db.get": False, - "db.set": False, - "db.delete": False, - "db.list": False, - "db.get_many": False, - "db.set_many": False, - "db.watch": True, - "platform.send": False, - "platform.send_image": False, - "platform.send_chain": False, - "platform.get_members": False, -} -EXPECTED_CANCELABLE_CAPABILITIES = {"llm.stream_chat", "db.watch"} -EXPECTED_PUBLIC_COMPAT_HOOKS = { - "after_message_sent", - "on_astrbot_loaded", - "on_platform_loaded", - "on_decorating_result", - "on_llm_request", - "on_llm_response", - "on_waiting_llm_request", - "on_using_llm_tool", - "on_llm_tool_respond", - "on_plugin_error", - "on_plugin_loaded", - "on_plugin_unloaded", -} - - -def test_builtin_capability_schema_registry_matches_public_contract(): - """协议层公开的内建 capability 集合必须保持稳定。""" - assert set(BUILTIN_CAPABILITY_SCHEMAS) == set(EXPECTED_PUBLIC_BUILTIN_CAPABILITIES) - - -def test_capability_router_descriptors_match_public_contract(): - """Runtime 层内建 capability 的名字、stream 和 cancel 语义必须对齐契约。""" - descriptors = {item.name: item for item in CapabilityRouter().descriptors()} - - assert set(descriptors) == set(EXPECTED_PUBLIC_BUILTIN_CAPABILITIES) - assert { - name: descriptor.supports_stream for name, descriptor in descriptors.items() - } == EXPECTED_PUBLIC_BUILTIN_CAPABILITIES - assert { - name for name, descriptor in descriptors.items() if descriptor.cancelable - } == EXPECTED_CANCELABLE_CAPABILITIES - - -def test_public_compat_hook_factories_remain_available(): - """兼容 hook 名称必须同时保留模块级和 namespace 级入口。""" - compat_filter_module = import_module("astrbot_sdk.api.event.filter") - - for name in EXPECTED_PUBLIC_COMPAT_HOOKS: - assert callable(getattr(compat_filter_module, name)) - assert callable(getattr(compat_filter_namespace, name)) diff --git a/tests_v4/test_runtime_integration.py b/tests_v4/test_runtime_integration.py deleted file mode 100644 index 86192229f..000000000 --- a/tests_v4/test_runtime_integration.py +++ /dev/null @@ -1,1217 +0,0 @@ -""" -Integration tests for runtime module - covers subprocess lifecycle, -concurrency, and real-world scenarios. -""" - -from __future__ import annotations - -import asyncio -import os -import shutil -import sys -import tempfile -from pathlib import Path -from unittest.mock import MagicMock, patch - -import pytest -import yaml - -from astrbot_sdk.context import CancelToken, Context -from astrbot_sdk.errors import AstrBotError -from astrbot_sdk.events import MessageEvent -from astrbot_sdk.protocol.descriptors import ( - CapabilityDescriptor, - CommandTrigger, - EventTrigger, - HandlerDescriptor, - MessageTrigger, - ScheduleTrigger, -) -from astrbot_sdk.protocol.messages import ( - InitializeOutput, - InvokeMessage, - PeerInfo, -) -from astrbot_sdk.runtime.bootstrap import ( - SupervisorRuntime, - WorkerSession, -) -from astrbot_sdk.runtime.capability_router import CapabilityRouter -from astrbot_sdk.runtime.handler_dispatcher import HandlerDispatcher -from astrbot_sdk.runtime.loader import ( - LoadedHandler, - PluginEnvironmentManager, - PluginSpec, -) -from astrbot_sdk.runtime.peer import Peer - -from tests_v4.helpers import FakeEnvManager, MemoryTransport, make_transport_pair - - -def write_runtime_env_plugin( - plugins_dir: Path, - name: str, - *, - requirements: str = "", -) -> Path: - plugin_dir = plugins_dir / name - plugin_dir.mkdir(parents=True, exist_ok=True) - (plugin_dir / "plugin.yaml").write_text( - yaml.dump( - { - "name": name, - "runtime": { - "python": f"{sys.version_info.major}.{sys.version_info.minor}" - }, - "components": [], - } - ), - encoding="utf-8", - ) - (plugin_dir / "requirements.txt").write_text(requirements, encoding="utf-8") - return plugin_dir - - -async def start_test_core_peer(transport: MemoryTransport) -> Peer: - """Provide an initialize responder so transport-pair startup tests do not deadlock.""" - core = Peer( - transport=transport, - peer_info=PeerInfo(name="test-core", role="core", version="v4"), - ) - core.set_initialize_handler( - lambda _message: asyncio.sleep( - 0, - result=InitializeOutput( - peer=PeerInfo(name="test-core", role="core", version="v4"), - capabilities=[], - metadata={}, - ), - ) - ) - await core.start() - return core - - -class TestWorkerSessionSubprocessLifecycle: - """Tests for WorkerSession subprocess management.""" - - @pytest.mark.asyncio - async def test_worker_session_crash_during_init(self): - """WorkerSession 应该在 worker 子进程初始化阶段崩溃时正确清理。""" - # 创建一个会立即崩溃的插件 - with tempfile.TemporaryDirectory() as temp_dir: - plugins_dir = Path(temp_dir) / "plugins" - plugin_dir = plugins_dir / "crash_plugin" - plugin_dir.mkdir(parents=True) - - manifest_path = plugin_dir / "plugin.yaml" - requirements_path = plugin_dir / "requirements.txt" - - manifest_path.write_text( - yaml.dump( - { - "name": "crash_plugin", - "runtime": { - "python": f"{sys.version_info.major}.{sys.version_info.minor}" - }, - "components": [{"class": "nonexistent:Module"}], # 不存在的模块 - } - ), - encoding="utf-8", - ) - requirements_path.write_text("", encoding="utf-8") - - spec = PluginSpec( - name="crash_plugin", - plugin_dir=plugin_dir, - manifest_path=manifest_path, - requirements_path=requirements_path, - python_version=f"{sys.version_info.major}.{sys.version_info.minor}", - manifest_data={"name": "crash_plugin"}, - ) - - left, right = make_transport_pair() - core = await start_test_core_peer(left) - - router = CapabilityRouter() - session = WorkerSession( - plugin=spec, - repo_root=Path(temp_dir), - env_manager=FakeEnvManager(), - capability_router=router, - ) - - # 启动应该失败(子进程会崩溃) - with pytest.raises(RuntimeError, match="初始化阶段退出|worker 进程"): - await session.start() - - # 确保清理完成 - assert session.peer is None or session.peer._closed.is_set() - - await core.stop() - - @pytest.mark.asyncio - async def test_worker_session_handles_cancel_during_init(self): - """WorkerSession 应该正确处理初始化期间的取消操作。""" - with tempfile.TemporaryDirectory() as temp_dir: - plugin_dir = Path(temp_dir) / "test_plugin" - plugin_dir.mkdir(parents=True) - - manifest_path = plugin_dir / "plugin.yaml" - requirements_path = plugin_dir / "requirements.txt" - - manifest_path.write_text( - yaml.dump( - { - "name": "test_plugin", - "runtime": { - "python": f"{sys.version_info.major}.{sys.version_info.minor}" - }, - "components": [], - } - ), - encoding="utf-8", - ) - requirements_path.write_text("", encoding="utf-8") - - spec = PluginSpec( - name="test_plugin", - plugin_dir=plugin_dir, - manifest_path=manifest_path, - requirements_path=requirements_path, - python_version=f"{sys.version_info.major}.{sys.version_info.minor}", - manifest_data={"name": "test_plugin"}, - ) - - # 使用 mock 让 start 在中途被取消 - session = WorkerSession( - plugin=spec, - repo_root=Path(temp_dir), - env_manager=FakeEnvManager(), - capability_router=CapabilityRouter(), - ) - - # 模拟取消 - with patch.object( - Peer, - "start", - side_effect=asyncio.CancelledError, - ): - with pytest.raises(asyncio.CancelledError): - await session.start() - - # 确保清理完成 - await session.stop() - - -class TestConcurrentPeerOperations: - """Tests for concurrent invoke operations on Peer.""" - - @pytest.mark.asyncio - async def test_concurrent_invokes(self): - """Peer 应该正确处理多个并发调用。""" - left, right = make_transport_pair() - - router = CapabilityRouter() - core = Peer( - transport=left, - peer_info=PeerInfo(name="core", role="core", version="v4"), - ) - core.set_initialize_handler( - lambda _message: asyncio.sleep( - 0, - result=InitializeOutput( - peer=PeerInfo(name="core", role="core", version="v4"), - capabilities=router.descriptors(), - metadata={}, - ), - ) - ) - - call_count = [] - - async def tracking_handler(message, token): - call_count.append(message.id) - await asyncio.sleep(0.1) # 模拟处理时间 - return router.execute( - message.capability, - message.input, - stream=message.stream, - cancel_token=token, - request_id=message.id, - ) - - core.set_invoke_handler(tracking_handler) - - plugin = Peer( - transport=right, - peer_info=PeerInfo(name="plugin", role="plugin", version="v4"), - ) - - await core.start() - await plugin.start() - await plugin.initialize([]) - - # 并发发起 5 个调用 - tasks = [ - plugin.invoke("llm.chat", {"prompt": f"hello{i}"}, request_id=f"req-{i}") - for i in range(5) - ] - - results = await asyncio.gather(*tasks) - - # 所有调用都应成功 - assert len(results) == 5 - assert len(call_count) == 5 - # 每个请求 ID 都应该被记录 - for i in range(5): - assert f"req-{i}" in call_count - - await plugin.stop() - await core.stop() - - @pytest.mark.asyncio - async def test_concurrent_invoke_and_cancel(self): - """Peer 应该正确处理并发的调用和取消操作。""" - left, right = make_transport_pair() - - descriptor = CapabilityDescriptor( - name="slow.cap", - description="slow capability", - input_schema={"type": "object", "properties": {}, "required": []}, - output_schema={"type": "object", "properties": {}, "required": []}, - supports_stream=True, - cancelable=True, - ) - - started = asyncio.Event() - cancelled = [] - - async def slow_handler( - request_id: str, _payload: dict[str, object], token: CancelToken - ): - started.set() - try: - # 持续运行直到被取消 - while True: - token.raise_if_cancelled() - await asyncio.sleep(0.01) - yield {"tick": True} - except asyncio.CancelledError: - cancelled.append(request_id) - raise - - router = CapabilityRouter() - router.register(descriptor, stream_handler=slow_handler) - - core = Peer( - transport=left, - peer_info=PeerInfo(name="core", role="core", version="v4"), - ) - core.set_initialize_handler( - lambda _message: asyncio.sleep( - 0, - result=InitializeOutput( - peer=PeerInfo(name="core", role="core", version="v4"), - capabilities=[descriptor], - metadata={}, - ), - ) - ) - core.set_invoke_handler( - lambda message, token: router.execute( - message.capability, - message.input, - stream=message.stream, - cancel_token=token, - request_id=message.id, - ) - ) - - plugin = Peer( - transport=right, - peer_info=PeerInfo(name="plugin", role="plugin", version="v4"), - ) - - await core.start() - await plugin.start() - await plugin.initialize([]) - - # 启动流式调用 - stream = await plugin.invoke_stream("slow.cap", {}, request_id="slow-1") - - # 等待处理开始 - await started.wait() - - # 在迭代过程中取消 - cancel_task = asyncio.create_task(plugin.cancel("slow-1")) - - # 尝试迭代应该抛出错误 - with pytest.raises(AstrBotError) as raised: - async for _ in stream: - pass - assert raised.value.code == "cancelled" - - await cancel_task - await plugin.stop() - await core.stop() - - -class TestStreamCancelDuringIteration: - """Tests for cancelling stream invocations during iteration.""" - - @pytest.mark.asyncio - async def test_cancel_mid_stream(self): - """流式调用在迭代中途被取消应该正确终止。""" - left, right = make_transport_pair() - - chunks_produced = [] - - async def stream_handler(_request_id: str, _payload: dict[str, object], token): - for i in range(100): - token.raise_if_cancelled() - chunks_produced.append(i) - yield {"chunk": i} - await asyncio.sleep(0.01) - - router = CapabilityRouter() - router.register( - CapabilityDescriptor( - name="test.stream", - description="test", - supports_stream=True, - cancelable=True, - ), - stream_handler=stream_handler, - ) - - core = Peer( - transport=left, - peer_info=PeerInfo(name="core", role="core", version="v4"), - ) - core.set_initialize_handler( - lambda _message: asyncio.sleep( - 0, - result=InitializeOutput( - peer=PeerInfo(name="core", role="core", version="v4"), - capabilities=router.descriptors(), - metadata={}, - ), - ) - ) - core.set_invoke_handler( - lambda message, token: router.execute( - message.capability, - message.input, - stream=message.stream, - cancel_token=token, - request_id=message.id, - ) - ) - - plugin = Peer( - transport=right, - peer_info=PeerInfo(name="plugin", role="plugin", version="v4"), - ) - - await core.start() - await plugin.start() - await plugin.initialize([]) - - stream = await plugin.invoke_stream("test.stream", {}, request_id="stream-1") - - received = [] - - async def consume(): - async for chunk in stream: - received.append(chunk) - if len(received) == 3: - # 收到 3 个 chunk 后取消 - await plugin.cancel("stream-1") - - with pytest.raises(AstrBotError) as raised: - await consume() - assert raised.value.code == "cancelled" - - # 应该只收到了前几个 chunk - assert len(received) <= 5 - # 不应该产生了 100 个 chunk - assert len(chunks_produced) < 50 - - await plugin.stop() - await core.stop() - - @pytest.mark.asyncio - async def test_cancel_before_stream_starts(self): - """流式调用在开始迭代前被取消。""" - left, right = make_transport_pair() - - started = [] - - async def stream_handler(_request_id: str, _payload: dict[str, object], token): - started.append(True) - yield {"chunk": 1} - - router = CapabilityRouter() - router.register( - CapabilityDescriptor( - name="test.stream", - description="test", - supports_stream=True, - cancelable=True, - ), - stream_handler=stream_handler, - ) - - core = Peer( - transport=left, - peer_info=PeerInfo(name="core", role="core", version="v4"), - ) - core.set_initialize_handler( - lambda _message: asyncio.sleep( - 0, - result=InitializeOutput( - peer=PeerInfo(name="core", role="core", version="v4"), - capabilities=router.descriptors(), - metadata={}, - ), - ) - ) - core.set_invoke_handler( - lambda message, token: router.execute( - message.capability, - message.input, - stream=message.stream, - cancel_token=token, - request_id=message.id, - ) - ) - - plugin = Peer( - transport=right, - peer_info=PeerInfo(name="plugin", role="plugin", version="v4"), - ) - - await core.start() - await plugin.start() - await plugin.initialize([]) - - stream = await plugin.invoke_stream( - "test.stream", {}, request_id="stream-early" - ) - - # 在迭代前取消 - await plugin.cancel("stream-early") - - # 迭代应该抛出错误 - with pytest.raises(AstrBotError) as raised: - async for _ in stream: - pass - assert raised.value.code == "cancelled" - - await plugin.stop() - await core.stop() - - -class TestMultipleTriggerTypes: - """Tests for different trigger types in HandlerDispatcher.""" - - def create_dispatcher_with_trigger(self, trigger) -> HandlerDispatcher: - """创建带有指定触发器的调度器。""" - peer = MagicMock() - peer.sent_messages = [] - - async def mock_send(session_id: str, text: str) -> None: - peer.sent_messages.append({"session_id": session_id, "text": text}) - - peer.send = mock_send - - async def handler_func(event: MessageEvent, ctx: Context): - await event.reply("response") - return None - - descriptor = HandlerDescriptor( - id="test.handler", - trigger=trigger, - ) - handler = LoadedHandler( - descriptor=descriptor, - callable=handler_func, - owner=MagicMock(), - legacy_context=None, - ) - - return HandlerDispatcher( - plugin_id="test_plugin", - peer=peer, - handlers=[handler], - ) - - @pytest.mark.asyncio - async def test_command_trigger(self): - """CommandTrigger 应该正确处理命令触发。""" - trigger = CommandTrigger( - command="hello", - aliases=["hi", "hey"], - description="A greeting command", - ) - dispatcher = self.create_dispatcher_with_trigger(trigger) - - # 验证 descriptor 中的触发器信息 - handler = dispatcher._handlers["test.handler"] - assert handler.descriptor.trigger.command == "hello" - assert "hi" in handler.descriptor.trigger.aliases - - @pytest.mark.asyncio - async def test_message_trigger_regex(self): - """MessageTrigger 应该正确处理正则匹配。""" - trigger = MessageTrigger( - regex=r"ping\s+(\d+)", - platforms=["test"], - ) - dispatcher = self.create_dispatcher_with_trigger(trigger) - - handler = dispatcher._handlers["test.handler"] - assert handler.descriptor.trigger.regex == r"ping\s+(\d+)" - assert "test" in handler.descriptor.trigger.platforms - - @pytest.mark.asyncio - async def test_message_trigger_keywords(self): - """MessageTrigger 应该正确处理关键词匹配。""" - trigger = MessageTrigger( - keywords=["ping", "pong"], - ) - dispatcher = self.create_dispatcher_with_trigger(trigger) - - handler = dispatcher._handlers["test.handler"] - assert "ping" in handler.descriptor.trigger.keywords - assert "pong" in handler.descriptor.trigger.keywords - - @pytest.mark.asyncio - async def test_event_trigger(self): - """EventTrigger 应该正确处理事件类型触发。""" - trigger = EventTrigger( - event_type="message.received", - ) - dispatcher = self.create_dispatcher_with_trigger(trigger) - - handler = dispatcher._handlers["test.handler"] - assert handler.descriptor.trigger.event_type == "message.received" - - @pytest.mark.asyncio - async def test_schedule_trigger(self): - """ScheduleTrigger 应该正确处理定时触发。""" - trigger = ScheduleTrigger( - schedule="0 */5 * * * *", # 每5分钟 - ) - dispatcher = self.create_dispatcher_with_trigger(trigger) - - handler = dispatcher._handlers["test.handler"] - assert handler.descriptor.trigger.schedule == "0 */5 * * * *" - - @pytest.mark.asyncio - async def test_invoke_with_message_trigger_event(self): - """调度器应该正确处理 MessageTrigger 类型的事件。""" - trigger = MessageTrigger(regex=r"test\s+(.+)") - dispatcher = self.create_dispatcher_with_trigger(trigger) - - event = MessageEvent( - session_id="session-1", - user_id="user-1", - platform="test", - text="test message", - ) - - message = InvokeMessage( - id="msg_001", - capability="handler.invoke", - input={"handler_id": "test.handler", "event": event.to_payload()}, - ) - - cancel_token = CancelToken() - result = await dispatcher.invoke(message, cancel_token) - - assert result == {} - - @pytest.mark.asyncio - async def test_invoke_with_event_trigger_event(self): - """调度器应该正确处理 EventTrigger 类型的事件。""" - trigger = EventTrigger(event_type="custom.event") - dispatcher = self.create_dispatcher_with_trigger(trigger) - - # EventTrigger 通常用于非消息事件 - event_data = { - "type": "event", - "event_type": "custom.event", - "session_id": "session-1", - "user_id": "user-1", - "platform": "test", - } - - message = InvokeMessage( - id="msg_001", - capability="handler.invoke", - input={"handler_id": "test.handler", "event": event_data}, - ) - - cancel_token = CancelToken() - result = await dispatcher.invoke(message, cancel_token) - - assert result == {} - - -class TestEnvironmentCacheReuse: - """Tests for PluginEnvironmentManager caching behavior.""" - - def test_prepare_environment_reuses_existing_plan(self): - """prepare_environment should reuse an existing plan for the same plugin.""" - with tempfile.TemporaryDirectory() as temp_dir: - plugin_dir = Path(temp_dir) / "test_plugin" - plugin_dir.mkdir() - - requirements_path = plugin_dir / "requirements.txt" - requirements_path.write_text("", encoding="utf-8") - - spec = PluginSpec( - name="test_plugin", - plugin_dir=plugin_dir, - manifest_path=plugin_dir / "plugin.yaml", - requirements_path=requirements_path, - python_version="3.12", - manifest_data={}, - ) - - manager = PluginEnvironmentManager(Path(temp_dir), uv_binary="/usr/bin/uv") - plan_calls: list[list[str]] = [] - original_plan = manager.plan - - def tracked_plan(plugins: list[PluginSpec]): - plan_calls.append([plugin.name for plugin in plugins]) - return original_plan(plugins) - - manager.plan = tracked_plan - manager._group_manager.prepare = lambda group: group.python_path - - manager.plan([spec]) - first_path = manager.prepare_environment(spec) - second_path = manager.prepare_environment(spec) - - assert first_path == second_path - assert plan_calls == [["test_plugin"]] - - def test_fingerprint_changes_triggers_rebuild(self): - """当指纹变化时应该触发环境重建。""" - with tempfile.TemporaryDirectory() as temp_dir: - plugin_dir = Path(temp_dir) / "test_plugin" - plugin_dir.mkdir() - - requirements_path = plugin_dir / "requirements.txt" - requirements_path.write_text("astrbot-sdk==1.0.0\n", encoding="utf-8") - - spec = PluginSpec( - name="test_plugin", - plugin_dir=plugin_dir, - manifest_path=plugin_dir / "plugin.yaml", - requirements_path=requirements_path, - python_version="3.12", - manifest_data={}, - ) - - # 计算初始指纹 - fingerprint1 = PluginEnvironmentManager._fingerprint(spec) - - # 修改 requirements.txt - requirements_path.write_text("astrbot-sdk==2.0.0\n", encoding="utf-8") - - # 重新加载 spec - spec2 = PluginSpec( - name="test_plugin", - plugin_dir=plugin_dir, - manifest_path=plugin_dir / "plugin.yaml", - requirements_path=requirements_path, - python_version="3.12", - manifest_data={}, - ) - - fingerprint2 = PluginEnvironmentManager._fingerprint(spec2) - - # 指纹应该不同 - assert fingerprint1 != fingerprint2 - - def test_python_version_change_triggers_rebuild(self): - """Python 版本变化时应该触发环境重建。""" - with tempfile.TemporaryDirectory() as temp_dir: - requirements_path = Path(temp_dir) / "requirements.txt" - requirements_path.write_text("", encoding="utf-8") - - spec1 = PluginSpec( - name="test", - plugin_dir=Path(temp_dir), - manifest_path=Path(temp_dir) / "plugin.yaml", - requirements_path=requirements_path, - python_version="3.11", - manifest_data={}, - ) - - spec2 = PluginSpec( - name="test", - plugin_dir=Path(temp_dir), - manifest_path=Path(temp_dir) / "plugin.yaml", - requirements_path=requirements_path, - python_version="3.12", - manifest_data={}, - ) - - fingerprint1 = PluginEnvironmentManager._fingerprint(spec1) - fingerprint2 = PluginEnvironmentManager._fingerprint(spec2) - - # 不同 Python 版本应该产生不同指纹 - assert fingerprint1 != fingerprint2 - - def test_matches_python_version(self): - """_matches_python_version 应该正确检查 Python 版本。""" - with tempfile.TemporaryDirectory() as temp_dir: - venv_dir = Path(temp_dir) / ".venv" - venv_dir.mkdir() - - # 创建 pyvenv.cfg - pyvenv_cfg = venv_dir / "pyvenv.cfg" - pyvenv_cfg.write_text( - "home = /usr/bin\n" - "include-system-site-packages = false\n" - "version = 3.12.0\n", - encoding="utf-8", - ) - - manager = PluginEnvironmentManager(Path(temp_dir)) - - # 匹配的版本 - assert manager._matches_python_version(venv_dir, "3.12") is True - - # 不匹配的版本 - assert manager._matches_python_version(venv_dir, "3.11") is False - - # 不存在的 venv - assert ( - manager._matches_python_version(Path("/nonexistent"), "3.12") is False - ) - - -class TestSupervisorRuntimePluginLoading: - """Tests for SupervisorRuntime plugin loading scenarios.""" - - @pytest.mark.asyncio - async def test_load_multiple_plugins(self): - """SupervisorRuntime 应该正确加载多个插件。""" - with tempfile.TemporaryDirectory() as temp_dir: - plugins_dir = Path(temp_dir) / "plugins" - - # 创建多个插件 - for i in range(3): - plugin_dir = plugins_dir / f"plugin_{i}" - plugin_dir.mkdir(parents=True) - - manifest_path = plugin_dir / "plugin.yaml" - requirements_path = plugin_dir / "requirements.txt" - - manifest_path.write_text( - yaml.dump( - { - "name": f"plugin_{i}", - "runtime": { - "python": f"{sys.version_info.major}.{sys.version_info.minor}" - }, - "components": [], - } - ), - encoding="utf-8", - ) - requirements_path.write_text("", encoding="utf-8") - - left, right = make_transport_pair() - core = await start_test_core_peer(left) - - runtime = SupervisorRuntime( - transport=right, - plugins_dir=plugins_dir, - env_manager=FakeEnvManager(), - ) - - try: - await runtime.start() - await core.wait_until_remote_initialized() - - # 应该加载了所有插件 - assert len(runtime.loaded_plugins) == 3 - assert "plugin_0" in runtime.loaded_plugins - assert "plugin_1" in runtime.loaded_plugins - assert "plugin_2" in runtime.loaded_plugins - - finally: - await runtime.stop() - await core.stop() - - @pytest.mark.asyncio - async def test_loads_three_plugins_with_shared_and_isolated_group_envs(self): - """SupervisorRuntime should reuse one env for two plugins and isolate the third.""" - with tempfile.TemporaryDirectory() as temp_dir: - plugins_dir = Path(temp_dir) / "plugins" - write_runtime_env_plugin( - plugins_dir, - "plugin_a", - requirements="alpha==1.0.0\n", - ) - write_runtime_env_plugin( - plugins_dir, - "plugin_b", - requirements="beta==1.0.0\n", - ) - write_runtime_env_plugin( - plugins_dir, - "plugin_c", - requirements="alpha==2.0.0\n", - ) - - manager = PluginEnvironmentManager(Path(temp_dir), uv_binary="/usr/bin/uv") - prepared_groups: list[str] = [] - - def fake_compile( - *, source_path: Path, output_path: Path, python_version: str - ): - content = source_path.read_text(encoding="utf-8") - if "alpha==1.0.0" in content and "alpha==2.0.0" in content: - raise RuntimeError( - "compile lockfile failed with exit code 1: conflict" - ) - output_path.write_text( - f"# python={python_version}\n{content}", - encoding="utf-8", - ) - - def fake_prepare(group) -> Path: - prepared_groups.append(group.id) - 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) - else: - os.symlink(target, group.python_path) - return group.python_path - - manager._planner._compile_lockfile = fake_compile - manager._group_manager.prepare = fake_prepare - - left, right = make_transport_pair() - core = await start_test_core_peer(left) - runtime = SupervisorRuntime( - transport=right, - plugins_dir=plugins_dir, - env_manager=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 manager._plan_result is not None - assert len(manager._plan_result.groups) == 2 - - shared_group = manager._plan_result.plugin_to_group["plugin_a"] - isolated_group = manager._plan_result.plugin_to_group["plugin_c"] - - assert ( - shared_group.id - == 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 - assert prepared_groups.count(shared_group.id) == 1 - assert prepared_groups.count(isolated_group.id) == 1 - - shared_venv_path = shared_group.venv_path - isolated_venv_path = isolated_group.venv_path - assert shared_venv_path.exists() - assert isolated_venv_path.exists() - finally: - await runtime.stop() - await core.stop() - 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() - - @pytest.mark.asyncio - async def test_skip_invalid_plugins(self): - """SupervisorRuntime 应该跳过无效插件并记录原因。""" - with tempfile.TemporaryDirectory() as temp_dir: - plugins_dir = Path(temp_dir) / "plugins" - - # 有效插件 - valid_dir = plugins_dir / "valid_plugin" - valid_dir.mkdir(parents=True) - (valid_dir / "plugin.yaml").write_text( - yaml.dump( - { - "name": "valid_plugin", - "runtime": { - "python": f"{sys.version_info.major}.{sys.version_info.minor}" - }, - "components": [], - } - ), - encoding="utf-8", - ) - (valid_dir / "requirements.txt").write_text("", encoding="utf-8") - - # 无效插件(缺少 requirements.txt) - invalid_dir = plugins_dir / "invalid_plugin" - invalid_dir.mkdir(parents=True) - (invalid_dir / "plugin.yaml").write_text( - yaml.dump({"name": "invalid_plugin"}), - encoding="utf-8", - ) - - left, right = make_transport_pair() - core = await start_test_core_peer(left) - - runtime = SupervisorRuntime( - transport=right, - plugins_dir=plugins_dir, - env_manager=FakeEnvManager(), - ) - - try: - await runtime.start() - await core.wait_until_remote_initialized() - - # 应该只加载有效插件 - assert len(runtime.loaded_plugins) == 1 - assert "valid_plugin" in runtime.loaded_plugins - - # 应该记录跳过的插件 - assert "invalid_plugin" in runtime.skipped_plugins - - finally: - await runtime.stop() - await core.stop() - - @pytest.mark.asyncio - async def test_skip_plugin_when_on_start_fails_before_initialize(self): - """on_start 失败的插件不应向上游暴露 handlers 或 capabilities。""" - with tempfile.TemporaryDirectory() as temp_dir: - plugins_dir = Path(temp_dir) / "plugins" - plugin_dir = plugins_dir / "broken_plugin" - commands_dir = plugin_dir / "commands" - commands_dir.mkdir(parents=True) - - (plugin_dir / "plugin.yaml").write_text( - yaml.dump( - { - "name": "broken_plugin", - "runtime": { - "python": f"{sys.version_info.major}.{sys.version_info.minor}" - }, - "components": [ - {"class": "commands.broken:BrokenPlugin", "type": "command"} - ], - } - ), - encoding="utf-8", - ) - (plugin_dir / "requirements.txt").write_text("", encoding="utf-8") - (commands_dir / "__init__.py").write_text("", encoding="utf-8") - (commands_dir / "broken.py").write_text( - "from astrbot_sdk import Star, on_command, provide_capability\n\n" - "class BrokenPlugin(Star):\n" - " async def on_start(self, ctx):\n" - ' raise RuntimeError("boom during startup")\n\n' - ' @on_command("broken")\n' - " async def broken(self, event):\n" - ' await event.reply("should not load")\n\n' - ' @provide_capability("broken_plugin.echo", description="broken")\n' - " async def echo(self, payload):\n" - ' return {"echo": "broken"}\n', - encoding="utf-8", - ) - - left, right = make_transport_pair() - core = await start_test_core_peer(left) - - runtime = SupervisorRuntime( - transport=right, - plugins_dir=plugins_dir, - env_manager=FakeEnvManager(), - ) - - try: - await runtime.start() - await core.wait_until_remote_initialized() - - assert "broken_plugin" not in runtime.loaded_plugins - assert "broken_plugin" in runtime.skipped_plugins - assert "broken_plugin" not in core.remote_metadata["plugins"] - assert all( - "broken" not in handler.id for handler in core.remote_handlers - ) - assert all( - descriptor.name != "broken_plugin.echo" - for descriptor in core.remote_provided_capabilities - ) - finally: - await runtime.stop() - await core.stop() - - -class TestTimeoutHandling: - """Tests for timeout handling in Peer operations.""" - - @pytest.mark.asyncio - async def test_wait_until_remote_initialized_timeout(self): - """wait_until_remote_initialized 应该在超时后抛出错误。""" - left, right = make_transport_pair() - - # 只启动一侧,不提供初始化响应 - plugin = Peer( - transport=right, - peer_info=PeerInfo(name="plugin", role="plugin", version="v4"), - ) - - await left.start() - await plugin.start() - - # 不发送初始化响应,应该超时 - with pytest.raises(TimeoutError): - await plugin.wait_until_remote_initialized(timeout=0.1) - - await plugin.stop() - await left.stop() - - @pytest.mark.asyncio - async def test_invoke_timeout_on_no_response(self): - """invoke 应该在无响应时正确处理超时。""" - left, right = make_transport_pair() - - core = Peer( - transport=left, - peer_info=PeerInfo(name="core", role="core", version="v4"), - ) - - plugin = Peer( - transport=right, - peer_info=PeerInfo(name="plugin", role="plugin", version="v4"), - ) - - # 只设置初始化处理器,不设置 invoke 处理器 - core.set_initialize_handler( - lambda _message: asyncio.sleep( - 0, - result=InitializeOutput( - peer=PeerInfo(name="core", role="core", version="v4"), - capabilities=[], - metadata={}, - ), - ) - ) - - await core.start() - await plugin.start() - await plugin.initialize([]) - - # 尝试调用,但远程没有响应 - # 由于我们使用 MemoryTransport,调用会直接分发但无人处理 - # 这应该最终超时或抛出错误 - # 注意:实际实现可能不同,这里测试基本流程 - - await plugin.stop() - await core.stop() - - -class TestPeerRemoteHandlers: - """Tests for Peer remote handler tracking.""" - - @pytest.mark.asyncio - async def test_remote_handlers_populated_after_init(self): - """初始化后 remote_handlers 应该被填充。""" - left, right = make_transport_pair() - - router = CapabilityRouter() - core = Peer( - transport=left, - peer_info=PeerInfo(name="core", role="core", version="v4"), - ) - core.set_initialize_handler( - lambda _message: asyncio.sleep( - 0, - result=InitializeOutput( - peer=PeerInfo(name="core", role="core", version="v4"), - capabilities=router.descriptors(), - metadata={}, - ), - ) - ) - - plugin = Peer( - transport=right, - peer_info=PeerInfo(name="plugin", role="plugin", version="v4"), - ) - - await core.start() - await plugin.start() - await plugin.initialize([]) - - # 初始化后,应该有远程能力信息 - assert plugin.remote_peer is not None - assert plugin.remote_peer.name == "core" - - await plugin.stop() - await core.stop() - - @pytest.mark.asyncio - async def test_remote_metadata_preserved(self): - """初始化时的 metadata 应该被正确保存。""" - left, right = make_transport_pair() - - core = Peer( - transport=left, - peer_info=PeerInfo(name="core", role="core", version="v4"), - ) - core.set_initialize_handler( - lambda _message: asyncio.sleep( - 0, - result=InitializeOutput( - peer=PeerInfo(name="core", role="core", version="v4"), - capabilities=[], - metadata={ - "plugins": ["test_plugin"], - "version": "1.0.0", - }, - ), - ) - ) - - plugin = Peer( - transport=right, - peer_info=PeerInfo(name="plugin", role="plugin", version="v4"), - ) - - await core.start() - await plugin.start() - await plugin.initialize([]) - - assert plugin.remote_metadata.get("plugins") == ["test_plugin"] - assert plugin.remote_metadata.get("version") == "1.0.0" - - await plugin.stop() - await core.stop() diff --git a/tests_v4/test_script_migrations.py b/tests_v4/test_script_migrations.py deleted file mode 100644 index df2f840b1..000000000 --- a/tests_v4/test_script_migrations.py +++ /dev/null @@ -1,305 +0,0 @@ -from __future__ import annotations - -import asyncio -import contextlib -import sys -import tempfile -import textwrap -import time -import unittest -from pathlib import Path - -from astrbot_sdk.protocol.messages import InitializeOutput, PeerInfo -from astrbot_sdk.runtime.bootstrap import PluginWorkerRuntime, SupervisorRuntime -from astrbot_sdk.runtime.capability_router import CapabilityRouter -from astrbot_sdk.runtime.peer import Peer -from astrbot_sdk.runtime.transport import ( - WebSocketClientTransport, - WebSocketServerTransport, -) - -from tests_v4.helpers import FakeEnvManager, make_transport_pair - - -def write_websocket_plugin(plugin_root: Path) -> None: - (plugin_root / "commands").mkdir(parents=True, exist_ok=True) - (plugin_root / "commands" / "__init__.py").write_text("", encoding="utf-8") - (plugin_root / "requirements.txt").write_text("", encoding="utf-8") - (plugin_root / "plugin.yaml").write_text( - textwrap.dedent( - f"""\ - _schema_version: 2 - name: websocket_plugin - display_name: WebSocket Plugin - desc: websocket test - author: tester - version: 0.1.0 - runtime: - python: "{sys.version_info.major}.{sys.version_info.minor}" - components: - - class: commands.sample:MyPlugin - type: command - name: hello - description: hello - """ - ), - encoding="utf-8", - ) - (plugin_root / "commands" / "sample.py").write_text( - textwrap.dedent( - """\ - from astrbot_sdk import Context, MessageEvent, Star, on_command - - - class MyPlugin(Star): - @on_command("hello") - async def hello(self, event: MessageEvent, ctx: Context): - await event.reply(f"ws:{event.text}") - """ - ), - encoding="utf-8", - ) - - -def write_benchmark_plugin(plugins_dir: Path, index: int) -> None: - plugin_name = f"plugin_{index:03d}" - command_name = f"bench_{index:03d}" - 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("", encoding="utf-8") - (plugin_dir / "plugin.yaml").write_text( - textwrap.dedent( - f"""\ - _schema_version: 2 - name: {plugin_name} - display_name: {plugin_name} - desc: benchmark plugin {index} - author: tester - version: 0.1.0 - runtime: - python: "{sys.version_info.major}.{sys.version_info.minor}" - components: - - class: commands.plugin_{index:03d}:BenchmarkCommand{index:03d} - type: command - name: {command_name} - description: {command_name} - """ - ), - encoding="utf-8", - ) - (commands_dir / f"plugin_{index:03d}.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{index:03d}(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", - ) - - -class StartClientMigrationTest(unittest.IsolatedAsyncioTestCase): - async def test_websocket_plugin_worker_supports_handshake_and_handler_invoke( - self, - ) -> None: - with tempfile.TemporaryDirectory() as temp_dir: - plugin_root = Path(temp_dir) / "websocket_plugin" - write_websocket_plugin(plugin_root) - - server_transport = WebSocketServerTransport( - host="127.0.0.1", port=0, path="/ws" - ) - runtime = PluginWorkerRuntime( - plugin_dir=plugin_root, transport=server_transport - ) - runtime_task = asyncio.create_task(runtime.start()) - - try: - for _ in range(100): - if server_transport.port != 0: - break - await asyncio.sleep(0.02) - - core_router = CapabilityRouter() - core_peer = Peer( - transport=WebSocketClientTransport(url=server_transport.url), - peer_info=PeerInfo( - name="websocket-core", role="core", version="v4" - ), - ) - core_peer.set_initialize_handler( - lambda _message: asyncio.sleep( - 0, - result=InitializeOutput( - peer=PeerInfo( - name="websocket-core", role="core", version="v4" - ), - capabilities=core_router.descriptors(), - metadata={}, - ), - ) - ) - core_peer.set_invoke_handler( - lambda message, cancel_token: core_router.execute( - message.capability, - message.input, - stream=message.stream, - cancel_token=cancel_token, - request_id=message.id, - ) - ) - await core_peer.start() - try: - await asyncio.wait_for(runtime_task, timeout=5) - await core_peer.wait_until_remote_initialized() - handler_id = core_peer.remote_handlers[0].id - self.assertEqual( - core_peer.remote_metadata["plugin_id"], "websocket_plugin" - ) - self.assertEqual( - core_peer.remote_handlers[0].trigger.command, "hello" - ) - - await core_peer.invoke( - "handler.invoke", - { - "handler_id": handler_id, - "event": { - "text": "hello-websocket", - "session_id": "session-ws", - "user_id": "user-1", - "platform": "test", - }, - }, - request_id="call-ws", - ) - - self.assertEqual( - [item.get("text") for item in core_router.sent_messages], - ["ws:hello-websocket"], - ) - finally: - await core_peer.stop() - finally: - if not runtime_task.done(): - runtime_task.cancel() - with contextlib.suppress(asyncio.CancelledError): - await runtime_task - await runtime.stop() - - -class BenchmarkMigrationTest(unittest.IsolatedAsyncioTestCase): - async def asyncSetUp(self) -> None: - self.left, self.right = make_transport_pair() - self.core = Peer( - transport=self.left, - peer_info=PeerInfo(name="benchmark-core", role="core", version="v4"), - ) - self.core.set_initialize_handler( - lambda _message: asyncio.sleep( - 0, - result=InitializeOutput( - peer=PeerInfo(name="benchmark-core", role="core", version="v4"), - capabilities=[], - metadata={}, - ), - ) - ) - await self.core.start() - - async def asyncTearDown(self) -> None: - await self.core.stop() - - async def test_benchmark_style_runtime_report_covers_multi_plugin_workers( - self, - ) -> None: - plugin_count = 8 - with tempfile.TemporaryDirectory() as temp_dir: - plugins_dir = Path(temp_dir) / "plugins" - for index in range(plugin_count): - write_benchmark_plugin(plugins_dir, index) - - runtime = SupervisorRuntime( - transport=self.right, - plugins_dir=plugins_dir, - env_manager=FakeEnvManager(), - ) - started_at = time.perf_counter() - try: - await runtime.start() - await self.core.wait_until_remote_initialized() - measured_at = time.perf_counter() - - worker_pids = sorted( - process.pid - for session in runtime.worker_sessions.values() - if ( - process := getattr( - getattr(session.peer, "transport", None), "_process", None - ) - ) - is not None - and process.returncode is None - ) - report = { - "plugin_count": plugin_count, - "loaded_plugin_count": len(runtime.loaded_plugins), - "loaded_plugins": sorted(runtime.loaded_plugins), - "aggregated_handler_ids": list( - self.core.remote_metadata["aggregated_handler_ids"] - ), - "startup_total_duration_ms": round( - (measured_at - started_at) * 1000, 2 - ), - "worker_pids": worker_pids, - } - - handler_id = next( - item.id - for item in self.core.remote_handlers - if item.id.startswith("plugin_002:") - ) - await self.core.invoke( - "handler.invoke", - { - "handler_id": handler_id, - "event": { - "text": "/bench_002", - "session_id": "bench-session", - "user_id": "user-1", - "platform": "test", - }, - }, - request_id="bench-call", - ) - - self.assertEqual(report["loaded_plugin_count"], plugin_count) - self.assertEqual(len(report["worker_pids"]), plugin_count) - self.assertEqual(len(report["aggregated_handler_ids"]), plugin_count) - self.assertEqual( - report["loaded_plugins"], - [f"plugin_{index:03d}" for index in range(plugin_count)], - ) - self.assertGreaterEqual(report["startup_total_duration_ms"], 0) - self.assertIn( - "plugin_002:bench_002", - runtime.capability_router.sent_messages[-1]["text"], - ) - finally: - await runtime.stop() - - -if __name__ == "__main__": - unittest.main() diff --git a/tests_v4/test_supervisor_migration.py b/tests_v4/test_supervisor_migration.py deleted file mode 100644 index aceafb950..000000000 --- a/tests_v4/test_supervisor_migration.py +++ /dev/null @@ -1,172 +0,0 @@ -from __future__ import annotations - -import asyncio -import tempfile -import unittest -from pathlib import Path - -import yaml - -from astrbot_sdk.protocol.messages import InitializeOutput, PeerInfo -from astrbot_sdk.runtime.bootstrap import SupervisorRuntime -from astrbot_sdk.runtime.loader import discover_plugins -from astrbot_sdk.runtime.peer import Peer - -from tests_v4.helpers import FakeEnvManager, copy_sample_plugin, make_transport_pair - - -def prepare_sample_plugin( - root: Path, - folder_name: str, - *, - sample_name: str = "new", - plugin_name: str | None = None, - python_version: str | None = None, - include_requirements: bool = True, -) -> Path: - plugin_dir = copy_sample_plugin(sample_name, root / folder_name) - manifest_path = plugin_dir / "plugin.yaml" - manifest = yaml.safe_load(manifest_path.read_text(encoding="utf-8")) or {} - manifest["name"] = plugin_name or folder_name - manifest["display_name"] = folder_name - if python_version == "__missing__": - manifest.pop("runtime", None) - elif python_version is not None: - manifest["runtime"] = {"python": python_version} - manifest_path.write_text( - yaml.safe_dump(manifest, allow_unicode=True, sort_keys=False), - encoding="utf-8", - ) - requirements_path = plugin_dir / "requirements.txt" - if include_requirements: - requirements_path.write_text( - requirements_path.read_text(encoding="utf-8") - if requirements_path.exists() - else "", - encoding="utf-8", - ) - elif requirements_path.exists(): - requirements_path.unlink() - return plugin_dir - - -class PluginDiscoveryMigrationTest(unittest.TestCase): - def test_discover_plugins_keeps_old_supervisor_filtering_rules(self) -> None: - with tempfile.TemporaryDirectory() as temp_dir: - root = Path(temp_dir) - prepare_sample_plugin(root, "plugin_one", plugin_name="plugin_one") - prepare_sample_plugin( - root, - "plugin_two", - plugin_name="plugin_two", - python_version="__missing__", - ) - prepare_sample_plugin( - root, - "plugin_three", - plugin_name="plugin_three", - include_requirements=False, - ) - - discovery = discover_plugins(root) - - self.assertEqual([plugin.name for plugin in discovery.plugins], ["plugin_one"]) - self.assertIn("plugin_two", discovery.skipped_plugins) - self.assertIn("plugin_three", discovery.skipped_plugins) - - -class SupervisorMigrationTest(unittest.IsolatedAsyncioTestCase): - async def asyncSetUp(self) -> None: - self.left, self.right = make_transport_pair() - self.core = Peer( - transport=self.left, - peer_info=PeerInfo(name="test-core", role="core", version="v4"), - ) - self.core.set_initialize_handler( - lambda _message: asyncio.sleep( - 0, - result=InitializeOutput( - peer=PeerInfo(name="test-core", role="core", version="v4"), - capabilities=[], - metadata={}, - ), - ) - ) - await self.core.start() - - async def asyncTearDown(self) -> None: - await self.core.stop() - - async def test_supervisor_aggregates_handlers_and_routes_target_plugin( - self, - ) -> None: - with tempfile.TemporaryDirectory() as temp_dir: - plugins_dir = Path(temp_dir) / "plugins" - prepare_sample_plugin( - plugins_dir, - "plugin_one", - plugin_name="plugin_one", - ) - prepare_sample_plugin( - plugins_dir, - "plugin_two", - plugin_name="plugin_two", - ) - - runtime = SupervisorRuntime( - transport=self.right, - plugins_dir=plugins_dir, - env_manager=FakeEnvManager(), - ) - try: - await runtime.start() - await self.core.wait_until_remote_initialized() - - self.assertEqual( - sorted(runtime.loaded_plugins), ["plugin_one", "plugin_two"] - ) - self.assertEqual( - self.core.remote_metadata["plugins"], ["plugin_one", "plugin_two"] - ) - plugin_one_handlers = [ - item.id - for item in self.core.remote_handlers - if item.id.startswith("plugin_one:") - ] - plugin_two_handlers = [ - item.id - for item in self.core.remote_handlers - if item.id.startswith("plugin_two:") - ] - self.assertTrue(plugin_one_handlers) - self.assertTrue(plugin_two_handlers) - - handler_id = next( - item.id - for item in self.core.remote_handlers - if item.id.startswith("plugin_two:") and item.id.endswith(".hello") - ) - await self.core.invoke( - "handler.invoke", - { - "handler_id": handler_id, - "event": { - "text": "/hello", - "session_id": "session-1", - "user_id": "user-1", - "platform": "test", - }, - }, - request_id="call-route", - ) - - texts = [ - item.get("text") for item in runtime.capability_router.sent_messages - ] - self.assertEqual(texts, ["Echo: /hello", "Echo: stream"]) - finally: - await runtime.stop() - - -if __name__ == "__main__": - unittest.main() diff --git a/tests_v4/test_testing_module.py b/tests_v4/test_testing_module.py deleted file mode 100644 index 19870656a..000000000 --- a/tests_v4/test_testing_module.py +++ /dev/null @@ -1,156 +0,0 @@ -"""Tests for the public local-dev/testing helpers.""" - -from __future__ import annotations - -import asyncio -from pathlib import Path - -import pytest - -import astrbot_sdk.testing as testing_module -from astrbot_sdk.testing import ( - LocalRuntimeConfig, - MockCapabilityRouter, - MockContext, - MockMessageEvent, - MockPeer, - PluginHarness, -) - - -class TestTestingModule: - """Tests for `astrbot_sdk.testing` exports and behavior.""" - - def test_public_all_matches_stable_testing_surface(self): - """testing.__all__ should stay aligned with the documented stable helper API.""" - assert testing_module.__all__ == [ - "InMemoryDB", - "InMemoryMemory", - "LocalRuntimeConfig", - "MockCapabilityRouter", - "MockContext", - "MockLLMClient", - "MockMessageEvent", - "MockPeer", - "MockPlatformClient", - "PluginHarness", - "RecordedSend", - "StdoutPlatformSink", - ] - - @pytest.mark.asyncio - async def test_mock_peer_stream_emits_event_messages(self): - """MockPeer.invoke_stream should behave like a peer-level event stream.""" - router = MockCapabilityRouter() - peer = MockPeer(router) - - stream = await peer.invoke_stream( - "llm.stream_chat", - {"prompt": "hi"}, - include_completed=True, - ) - phases = [] - chunks = [] - async for event in stream: - phases.append(event.phase) - if event.phase == "delta": - chunks.append(event.data["text"]) - - assert phases[0] == "started" - assert phases[-1] == "completed" - assert "".join(chunks) == "Echo: hi" - - @pytest.mark.asyncio - async def test_plugin_harness_dispatches_v4_sample_plugin(self): - """PluginHarness should run the maintained v4 sample against the local mock core.""" - harness = PluginHarness( - LocalRuntimeConfig(plugin_dir=Path("test_plugin/new")), - ) - - async with harness: - records = await harness.dispatch_text("hello") - - assert [item.text for item in records if item.kind == "text"] == [ - "Echo: hello", - "Echo: stream", - ] - - @pytest.mark.asyncio - async def test_plugin_harness_can_invoke_plugin_capabilities(self): - """Harness should expose plugin-provided capabilities for local assertions.""" - harness = PluginHarness( - LocalRuntimeConfig(plugin_dir=Path("test_plugin/new")), - ) - - async with harness: - result = await harness.invoke_capability("demo.echo", {"text": "abc"}) - - assert result == { - "echo": "abc", - "plugin_id": "astrbot_plugin_v4demo", - } - - @pytest.mark.asyncio - async def test_mock_context_and_event_support_direct_handler_unit_tests(self): - """MockContext/MockMessageEvent should support direct handler tests without a full harness.""" - ctx = MockContext(plugin_id="demo-test") - event = MockMessageEvent(text="hello", context=ctx) - ctx.llm.mock_response("你好!") - - async def handler(mock_event, mock_ctx): - reply = await mock_ctx.llm.chat("hello") - await mock_event.reply(reply) - return mock_event.plain_result("done") - - result = await handler(event, ctx) - - assert result.text == "done" - assert event.replies == ["你好!"] - ctx.platform.assert_sent("你好!") - - @pytest.mark.asyncio - async def test_plugin_harness_reuses_session_waiter_across_followups( - self, - tmp_path: Path, - ): - """Follow-up messages from the same session should be routed into the active waiter.""" - plugin_dir = tmp_path / "legacy_waiter" - plugin_dir.mkdir() - (plugin_dir / "main.py").write_text( - """ -from astrbot.core.utils.session_waiter import SessionController, session_waiter -from astrbot_sdk.api.components.command import CommandComponent -from astrbot_sdk.api.event import AstrMessageEvent, filter -from astrbot_sdk.api.message import MessageChain - - -class WaiterPlugin(CommandComponent): - @filter.command("ask") - async def ask(self, event: AstrMessageEvent): - await event.send(MessageChain().message("请输入确认内容")) - - @session_waiter(timeout=0.2) - async def waiter(controller: SessionController, ev: AstrMessageEvent): - await ev.send(MessageChain().message(f"收到:{ev.message_str}")) - controller.stop() - - await waiter(event) -""".strip(), - encoding="utf-8", - ) - - harness = PluginHarness( - LocalRuntimeConfig(plugin_dir=plugin_dir, platform="test"), - ) - - async with harness: - first = asyncio.create_task(harness.dispatch_text("ask")) - await asyncio.sleep(0.05) - follow_up = await harness.dispatch_text("确认") - await first - - assert [item.text for item in follow_up if item.kind == "text"] == ["收到:确认"] - assert [item.text for item in harness.sent_messages if item.kind == "text"] == [ - "请输入确认内容", - "收到:确认", - ] diff --git a/tests_v4/test_top_level_modules.py b/tests_v4/test_top_level_modules.py deleted file mode 100644 index 0d465d86d..000000000 --- a/tests_v4/test_top_level_modules.py +++ /dev/null @@ -1,520 +0,0 @@ -""" -Tests for first-layer modules under astrbot_sdk. -""" - -from __future__ import annotations - -import importlib -import runpy -import sys -import zipfile -from pathlib import Path -from unittest.mock import AsyncMock, Mock, patch - -import astrbot_sdk -import astrbot_sdk.compat as compat_module -import astrbot_sdk.runtime as runtime_module -import pytest -from click.testing import CliRunner - -from astrbot_sdk._legacy_api import ( - CommandComponent, - LegacyContext, - LegacyConversationManager, -) -from astrbot_sdk.cli import cli, setup_logger -from astrbot_sdk.context import Context -from astrbot_sdk.decorators import on_command -from astrbot_sdk.errors import AstrBotError, ErrorCodes -from astrbot_sdk.events import MessageEvent -from astrbot_sdk.runtime.capability_router import CapabilityRouter, StreamExecution -from astrbot_sdk.runtime.handler_dispatcher import HandlerDispatcher -from astrbot_sdk.runtime.peer import Peer -from astrbot_sdk.runtime.transport import ( - MessageHandler, - StdioTransport, - Transport, - WebSocketClientTransport, - WebSocketServerTransport, -) -from astrbot_sdk.star import Star -from astrbot_sdk.testing import _PluginLoadError - -REPO_ROOT = Path(__file__).resolve().parents[1] -TOP_LEVEL_MODULES = [ - "astrbot_sdk", - "astrbot_sdk._legacy_api", - "astrbot_sdk.cli", - "astrbot_sdk.compat", - "astrbot_sdk.context", - "astrbot_sdk.decorators", - "astrbot_sdk.errors", - "astrbot_sdk.events", - "astrbot_sdk.runtime", - "astrbot_sdk.star", - "astrbot_sdk.testing", -] - - -class TestTopLevelImports: - """Tests for package first-layer module imports and exports.""" - - @pytest.mark.parametrize("module_name", TOP_LEVEL_MODULES) - def test_first_layer_modules_are_importable(self, module_name: str): - """All first-layer modules should be importable directly.""" - assert importlib.import_module(module_name) is not None - - def test_package_reexports_expected_symbols(self): - """astrbot_sdk package should re-export the public API surface.""" - assert astrbot_sdk.AstrBotError is AstrBotError - assert astrbot_sdk.Context is Context - assert astrbot_sdk.MessageEvent is MessageEvent - assert astrbot_sdk.Star is Star - assert astrbot_sdk.on_command is not None - assert astrbot_sdk.on_event is not None - assert astrbot_sdk.on_message is not None - assert astrbot_sdk.on_schedule is not None - assert astrbot_sdk.provide_capability is not None - assert astrbot_sdk.require_admin is not None - - def test_package_all_matches_public_exports(self): - """astrbot_sdk.__all__ should stay aligned with top-level exports.""" - assert astrbot_sdk.__all__ == [ - "AstrBotError", - "Context", - "MessageEvent", - "Star", - "on_command", - "on_event", - "on_message", - "on_schedule", - "provide_capability", - "require_admin", - ] - - def test_compat_module_reexports_legacy_symbols(self): - """compat module should proxy legacy compatibility types.""" - assert compat_module.CommandComponent is CommandComponent - assert compat_module.Context is LegacyContext - assert compat_module.LegacyContext is LegacyContext - assert compat_module.LegacyConversationManager is LegacyConversationManager - assert compat_module.__all__ == [ - "CommandComponent", - "Context", - "LegacyContext", - "LegacyConversationManager", - ] - - def test_runtime_module_reexports_advanced_runtime_primitives(self): - """runtime module should expose only the small advanced runtime surface.""" - assert runtime_module.Peer is Peer - assert runtime_module.CapabilityRouter is CapabilityRouter - assert runtime_module.HandlerDispatcher is HandlerDispatcher - assert runtime_module.Transport is Transport - assert runtime_module.MessageHandler is MessageHandler - assert runtime_module.StdioTransport is StdioTransport - assert runtime_module.WebSocketClientTransport is WebSocketClientTransport - assert runtime_module.WebSocketServerTransport is WebSocketServerTransport - assert runtime_module.StreamExecution is StreamExecution - - def test_runtime_module_does_not_reexport_loader_or_bootstrap_details(self): - """runtime root should not expose loader/bootstrap internals as stable API.""" - assert not hasattr(runtime_module, "PluginEnvironmentManager") - assert not hasattr(runtime_module, "PluginWorkerRuntime") - assert not hasattr(runtime_module, "SupervisorRuntime") - assert not hasattr(runtime_module, "WorkerSession") - assert not hasattr(runtime_module, "LoadedPlugin") - assert not hasattr(runtime_module, "run_supervisor") - - def test_runtime_module_all_matches_narrow_public_surface(self): - """runtime.__all__ should stay aligned with the narrowed advanced API.""" - assert runtime_module.__all__ == [ - "CapabilityRouter", - "HandlerDispatcher", - "MessageHandler", - "Peer", - "StdioTransport", - "StreamExecution", - "Transport", - "WebSocketClientTransport", - "WebSocketServerTransport", - ] - - -class TestCliModule: - """Tests for cli.py and __main__.py.""" - - @pytest.mark.parametrize( - ("verbose", "expected_level"), - [ - (False, "INFO"), - (True, "DEBUG"), - ], - ) - def test_setup_logger_configures_level(self, verbose: bool, expected_level: str): - """setup_logger() should rebuild loguru handlers with the expected level.""" - mock_logger = Mock() - - with patch("astrbot_sdk.cli.logger", mock_logger): - setup_logger(verbose=verbose) - - mock_logger.remove.assert_called_once_with() - mock_logger.add.assert_called_once() - assert mock_logger.add.call_args.args[0] is sys.stderr - assert mock_logger.add.call_args.kwargs["level"] == expected_level - assert mock_logger.add.call_args.kwargs["colorize"] is True - - def test_cli_group_sets_up_logging_from_verbose_flag(self): - """cli group should pass the verbose flag through to setup_logger().""" - runner = CliRunner() - - with ( - patch("astrbot_sdk.cli.setup_logger") as setup_logger_mock, - patch("astrbot_sdk.cli.run_supervisor", new=Mock(return_value=object())), - patch("astrbot_sdk.cli.asyncio.run"), - ): - result = runner.invoke(cli, ["--verbose", "run"]) - - assert result.exit_code == 0 - setup_logger_mock.assert_called_once_with(True) - - @pytest.mark.parametrize( - ("args", "target", "kwargs"), - [ - ( - ["run", "--plugins-dir", "plugins-dev"], - "run_supervisor", - {"plugins_dir": Path("plugins-dev")}, - ), - ( - ["worker", "--plugin-dir", "plugins/demo"], - "run_plugin_worker", - {"plugin_dir": Path("plugins/demo")}, - ), - ( - ["websocket", "--port", "9000"], - "run_websocket_server", - {"port": 9000}, - ), - ], - ) - def test_cli_commands_delegate_to_bootstrap_functions( - self, args, target: str, kwargs - ): - """Each CLI command should pass normalized arguments to its bootstrap entrypoint.""" - runner = CliRunner() - sentinel = object() - - with ( - patch( - f"astrbot_sdk.cli.{target}", - new=Mock(return_value=sentinel), - ) as entrypoint_mock, - patch("astrbot_sdk.cli.asyncio.run") as asyncio_run_mock, - ): - result = runner.invoke(cli, args) - - assert result.exit_code == 0 - entrypoint_mock.assert_called_once_with(**kwargs) - asyncio_run_mock.assert_called_once_with(sentinel) - - def test_dev_command_delegates_to_local_runtime(self): - """dev --local should delegate to the local harness entrypoint.""" - runner = CliRunner() - sentinel = object() - - with ( - patch( - "astrbot_sdk.cli._run_local_dev", - new=Mock(return_value=sentinel), - ) as dev_mock, - patch("astrbot_sdk.cli.asyncio.run") as asyncio_run_mock, - ): - result = runner.invoke( - cli, - [ - "dev", - "--plugin-dir", - "test_plugin/new", - "--local", - "--event-text", - "hello", - ], - ) - - assert result.exit_code == 0 - dev_mock.assert_called_once_with( - plugin_dir=Path("test_plugin/new"), - event_text="hello", - interactive=False, - session_id="local-session", - user_id="local-user", - platform="test", - group_id=None, - event_type="message", - ) - asyncio_run_mock.assert_called_once_with(sentinel) - - def test_dev_command_requires_local_mode(self): - """dev should reject invocations that do not opt into local mode.""" - runner = CliRunner() - - result = runner.invoke( - cli, - [ - "dev", - "--plugin-dir", - "test_plugin/new", - "--event-text", - "hello", - ], - ) - - assert result.exit_code == 2 - assert "--local/--standalone" in result.output - - def test_dev_command_maps_plugin_load_errors_to_exit_code_3(self): - """Known plugin load failures should render a friendly error and exit code 3.""" - runner = CliRunner() - - async def fail(*args, **kwargs): - raise _PluginLoadError("missing plugin") - - with patch("astrbot_sdk.cli._run_local_dev", new=fail): - result = runner.invoke( - cli, - [ - "dev", - "--plugin-dir", - "missing-plugin", - "--local", - "--event-text", - "hello", - ], - ) - - assert result.exit_code == 3 - assert "Error[plugin_load_error]" in result.output - assert "Suggestion:" in result.output - - def test_init_command_creates_plugin_skeleton(self): - """init should generate a loader-compatible plugin skeleton.""" - runner = CliRunner() - - with runner.isolated_filesystem(): - result = runner.invoke(cli, ["init", "demo-plugin"]) - - plugin_dir = Path("demo-plugin") - manifest = (plugin_dir / "plugin.yaml").read_text(encoding="utf-8") - main_file = (plugin_dir / "main.py").read_text(encoding="utf-8") - test_file = (plugin_dir / "tests" / "test_plugin.py").read_text( - encoding="utf-8" - ) - - assert result.exit_code == 0 - assert "已创建插件骨架" in result.output - assert "name: demo_plugin" in manifest - assert ( - f'python: "{sys.version_info.major}.{sys.version_info.minor}"' in manifest - ) - assert "class DemoPlugin(Star):" in main_file - assert "MockContext" in test_file - assert "MockMessageEvent" in test_file - - def test_validate_command_checks_real_plugin_fixture(self): - """validate should reuse loader-based discovery against a real v4 fixture.""" - runner = CliRunner() - - result = runner.invoke( - cli, - [ - "validate", - "--plugin-dir", - str(REPO_ROOT / "test_plugin" / "new"), - ], - ) - - assert result.exit_code == 0 - assert "校验通过:astrbot_plugin_v4demo" in result.output - assert "handlers:" in result.output - assert "capabilities:" in result.output - - def test_validate_command_maps_invalid_component_to_exit_code_3(self): - """validate should fail with a friendly plugin-load error on broken manifests.""" - runner = CliRunner() - - with runner.isolated_filesystem(): - plugin_dir = Path("broken-plugin") - plugin_dir.mkdir() - (plugin_dir / "requirements.txt").write_text("", encoding="utf-8") - (plugin_dir / "plugin.yaml").write_text( - "\n".join( - [ - "name: broken_plugin", - "runtime:", - ' python: "3.12"', - "components:", - " - class: broken", - ] - ), - encoding="utf-8", - ) - - result = runner.invoke(cli, ["validate", "--plugin-dir", str(plugin_dir)]) - - assert result.exit_code == 3 - assert "Error[plugin_load_error]" in result.output - assert "components[0].class" in result.output - - def test_build_command_creates_zip_artifact(self): - """build should validate first and then package the plugin directory into a zip.""" - runner = CliRunner() - - with runner.isolated_filesystem(): - init_result = runner.invoke(cli, ["init", "buildable-plugin"]) - assert init_result.exit_code == 0 - - result = runner.invoke( - cli, - [ - "build", - "--plugin-dir", - "buildable-plugin", - ], - ) - - artifact_dir = Path("buildable-plugin") / "dist" - artifacts = sorted(artifact_dir.glob("*.zip")) - assert len(artifacts) == 1 - with zipfile.ZipFile(artifacts[0]) as archive: - names = set(archive.namelist()) - - assert result.exit_code == 0 - assert "构建完成:" in result.output - assert "plugin.yaml" in names - assert "main.py" in names - assert "requirements.txt" in names - assert "tests/test_plugin.py" in names - - def test_main_module_invokes_cli_entrypoint(self): - """Running astrbot_sdk.__main__ as a script should call cli().""" - cli_mock = Mock() - - with patch("astrbot_sdk.cli.cli", cli_mock): - runpy.run_module("astrbot_sdk.__main__", run_name="__main__") - - cli_mock.assert_called_once_with() - - -class TestErrorsModule: - """Tests for errors.py.""" - - @pytest.mark.parametrize( - ("factory", "args", "expected"), - [ - ( - AstrBotError.cancelled, - (), - { - "code": "cancelled", - "message": "调用被取消", - "hint": "", - "retryable": False, - }, - ), - ( - AstrBotError.capability_not_found, - ("memory.save",), - { - "code": "capability_not_found", - "message": "未找到能力:memory.save", - "hint": "请确认 AstrBot Core 是否已注册该 capability", - "retryable": False, - }, - ), - ( - AstrBotError.invalid_input, - ("bad payload",), - { - "code": "invalid_input", - "message": "bad payload", - "hint": "请检查调用参数", - "retryable": False, - }, - ), - ], - ) - def test_error_factories_build_expected_payloads(self, factory, args, expected): - """Factory helpers should populate stable error payloads.""" - error = factory(*args) - - assert str(error) == expected["message"] - assert error.to_payload() == expected - - def test_from_payload_applies_defaults_for_missing_fields(self): - """from_payload() should fill in the documented fallback values.""" - error = AstrBotError.from_payload({"message": "boom", "retryable": 1}) - - assert error.code == ErrorCodes.UNKNOWN_ERROR - assert error.message == "boom" - assert error.hint == "" - assert error.retryable is True - - def test_error_code_constants_match_factory_outputs(self): - """核心工厂方法应复用统一错误码常量。""" - assert AstrBotError.cancelled().code == ErrorCodes.CANCELLED - assert ( - AstrBotError.capability_not_found("memory.get").code - == ErrorCodes.CAPABILITY_NOT_FOUND - ) - assert AstrBotError.invalid_input("bad").code == ErrorCodes.INVALID_INPUT - assert ( - AstrBotError.protocol_version_mismatch("bad").code - == ErrorCodes.PROTOCOL_VERSION_MISMATCH - ) - - -class TestStarModule: - """Tests for star.py.""" - - def test_handlers_collect_across_inheritance(self): - """Star subclasses should inherit decorated handlers from base classes.""" - - class BasePlugin(Star): - @on_command("base") - async def base(self): - return None - - class ChildPlugin(BasePlugin): - @on_command("child") - async def child(self): - return None - - assert ChildPlugin.__handlers__ == ("base", "child") - - @pytest.mark.asyncio - @pytest.mark.parametrize( - ("error", "expected_reply"), - [ - ( - AstrBotError(code="retryable", message="later", retryable=True), - "请求失败,请稍后重试", - ), - (AstrBotError.invalid_input("bad payload"), "请检查调用参数"), - (AstrBotError(code="plain", message="plain failure"), "plain failure"), - (RuntimeError("boom"), "出了点问题,请联系插件作者"), - ], - ) - async def test_on_error_replies_with_expected_message( - self, error, expected_reply: str - ): - """on_error() should translate failures into the expected user-facing reply.""" - event = AsyncMock() - event.reply = AsyncMock() - star = Star() - - with patch("astrbot_sdk.star.logger.error") as log_error: - await star.on_error(error, event, ctx=None) - - event.reply.assert_awaited_once_with(expected_reply) - log_error.assert_called_once()