From cdf72b75a450319935bef3faed36bbe065f9c7e9 Mon Sep 17 00:00:00 2001 From: whatevertogo Date: Fri, 13 Mar 2026 03:17:03 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=AE=9E=E7=8E=B0=20v4=20API=20?= =?UTF-8?q?=E5=85=BC=E5=AE=B9=E5=B1=82=EF=BC=8C=E6=94=AF=E6=8C=81=E6=97=A7?= =?UTF-8?q?=E7=89=88=E6=8F=92=E4=BB=B6=E5=AF=BC=E5=85=A5=E8=B7=AF=E5=BE=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 新增模块: - api/basic/: AstrBotConfig, BaseConversationManager, Conversation - api/event/: AstrMessageEvent, AstrBotMessage, EventType, MessageType, MessageSession, EventResult 等核心事件类型 - api/message/: MessageChain 及所有消息组件 (Plain, At, Image, File 等) - api/platform/: PlatformMetadata 平台元数据类型 - api/provider/: LLMResponse 提供者响应实体 - api/star/star.py: StarMetadata 插件元数据类型 更新模块: - api/__init__.py: 导出所有子模块 - api/components/: 扩展 Command 兼容性 - api/event/filter.py: 增强 filter 装饰器兼容性 - context.py, decorators.py, events.py: 顶层兼容入口 测试更新: - test_api_event_filter.py: 验证 filter 兼容性 - test_api_modules.py: 验证新模块可导入 - test_handler_dispatcher.py: 验证处理器分发 文档更新: - AGENTS.md/CLAUDE.md: 添加兼容层设计说明,避免重复造轮子 设计原则: - 兼容层通过 thin re-export 方式暴露旧版 API - 不复制独立运行时逻辑,保持架构清晰 - 新版推荐使用顶层模块导入路径 --- AGENTS.md | 3 +- CLAUDE.md | 3 +- src-new/astrbot_sdk/api/__init__.py | 137 +------ src-new/astrbot_sdk/api/basic/__init__.py | 7 + .../astrbot_sdk/api/basic/astrbot_config.py | 8 + .../astrbot_sdk/api/basic/conversation_mgr.py | 5 + src-new/astrbot_sdk/api/basic/entities.py | 20 + .../astrbot_sdk/api/components/__init__.py | 18 +- src-new/astrbot_sdk/api/components/command.py | 35 +- src-new/astrbot_sdk/api/event/__init__.py | 71 ++-- .../api/event/astr_message_event.py | 335 ++++++++++++++++ .../astrbot_sdk/api/event/astrbot_message.py | 55 +++ src-new/astrbot_sdk/api/event/event_result.py | 57 +++ src-new/astrbot_sdk/api/event/event_type.py | 16 + src-new/astrbot_sdk/api/event/filter.py | 369 ++++++++++++------ .../astrbot_sdk/api/event/message_session.py | 29 ++ src-new/astrbot_sdk/api/event/message_type.py | 11 + src-new/astrbot_sdk/api/message/__init__.py | 59 +++ src-new/astrbot_sdk/api/message/chain.py | 76 ++++ src-new/astrbot_sdk/api/message/components.py | 207 ++++++++++ src-new/astrbot_sdk/api/platform/__init__.py | 5 + .../api/platform/platform_metadata.py | 15 + src-new/astrbot_sdk/api/provider/__init__.py | 5 + src-new/astrbot_sdk/api/provider/entities.py | 110 ++++++ src-new/astrbot_sdk/api/star/__init__.py | 36 +- src-new/astrbot_sdk/api/star/context.py | 39 +- src-new/astrbot_sdk/api/star/star.py | 30 ++ src-new/astrbot_sdk/context.py | 48 +-- src-new/astrbot_sdk/decorators.py | 60 +-- src-new/astrbot_sdk/events.py | 62 +-- .../astrbot_sdk/runtime/handler_dispatcher.py | 25 +- src-new/astrbot_sdk/star.py | 45 +-- tests_v4/test_api_event_filter.py | 43 +- tests_v4/test_api_modules.py | 35 +- tests_v4/test_handler_dispatcher.py | 58 ++- 35 files changed, 1512 insertions(+), 625 deletions(-) create mode 100644 src-new/astrbot_sdk/api/basic/__init__.py create mode 100644 src-new/astrbot_sdk/api/basic/astrbot_config.py create mode 100644 src-new/astrbot_sdk/api/basic/conversation_mgr.py create mode 100644 src-new/astrbot_sdk/api/basic/entities.py create mode 100644 src-new/astrbot_sdk/api/event/astr_message_event.py create mode 100644 src-new/astrbot_sdk/api/event/astrbot_message.py create mode 100644 src-new/astrbot_sdk/api/event/event_result.py create mode 100644 src-new/astrbot_sdk/api/event/event_type.py create mode 100644 src-new/astrbot_sdk/api/event/message_session.py create mode 100644 src-new/astrbot_sdk/api/event/message_type.py create mode 100644 src-new/astrbot_sdk/api/message/__init__.py create mode 100644 src-new/astrbot_sdk/api/message/chain.py create mode 100644 src-new/astrbot_sdk/api/message/components.py create mode 100644 src-new/astrbot_sdk/api/platform/__init__.py create mode 100644 src-new/astrbot_sdk/api/platform/platform_metadata.py create mode 100644 src-new/astrbot_sdk/api/provider/__init__.py create mode 100644 src-new/astrbot_sdk/api/provider/entities.py create mode 100644 src-new/astrbot_sdk/api/star/star.py diff --git a/AGENTS.md b/AGENTS.md index 406d7b73b..1764efef0 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -7,6 +7,7 @@ - 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. - 2026-03-13: `CapabilityRouter.register(..., stream_handler=...)` uses the signature `(request_id, payload, cancel_token)`, not the peer-level `(message, token)`. Reusing the peer handler shape in router tests causes an immediate failed stream event before the test logic runs. - 2026-03-13: `Peer` had an early-cancel race for inbound invokes: if `CancelMessage` arrived before the invoke task executed its first line, the task could be cancelled before sending any terminal event, leaving the caller's stream iterator waiting forever. Preserve a per-request start event and pre-check the cancel token at the top of `_handle_invoke`. +- 2026-03-13: `src-new/astrbot_sdk/api` and several top-level module docstrings overstated v4 compatibility gaps by labeling migrated or compat-backed APIs as "missing". Treat `_legacy_api.py`, `astrbot_sdk.api.*` thin re-exports, and top-level `events.py` / `decorators.py` as a split compatibility surface; do not mechanically recreate the old tree from stale TODO comments. # 开发命令 @@ -33,4 +34,4 @@ python run_tests.py --cov # 运行测试并生成覆盖率报告 ``` ## 重要 -新实现要兼容旧实现但是还要保证架构良好,设计原则不变和最佳实践 \ No newline at end of file +新实现要兼容旧实现但是还要保证架构良好,设计原则不变和最佳实践 diff --git a/CLAUDE.md b/CLAUDE.md index 406d7b73b..1764efef0 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -7,6 +7,7 @@ - 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. - 2026-03-13: `CapabilityRouter.register(..., stream_handler=...)` uses the signature `(request_id, payload, cancel_token)`, not the peer-level `(message, token)`. Reusing the peer handler shape in router tests causes an immediate failed stream event before the test logic runs. - 2026-03-13: `Peer` had an early-cancel race for inbound invokes: if `CancelMessage` arrived before the invoke task executed its first line, the task could be cancelled before sending any terminal event, leaving the caller's stream iterator waiting forever. Preserve a per-request start event and pre-check the cancel token at the top of `_handle_invoke`. +- 2026-03-13: `src-new/astrbot_sdk/api` and several top-level module docstrings overstated v4 compatibility gaps by labeling migrated or compat-backed APIs as "missing". Treat `_legacy_api.py`, `astrbot_sdk.api.*` thin re-exports, and top-level `events.py` / `decorators.py` as a split compatibility surface; do not mechanically recreate the old tree from stale TODO comments. # 开发命令 @@ -33,4 +34,4 @@ python run_tests.py --cov # 运行测试并生成覆盖率报告 ``` ## 重要 -新实现要兼容旧实现但是还要保证架构良好,设计原则不变和最佳实践 \ No newline at end of file +新实现要兼容旧实现但是还要保证架构良好,设计原则不变和最佳实践 diff --git a/src-new/astrbot_sdk/api/__init__.py b/src-new/astrbot_sdk/api/__init__.py index 24d7d68ba..19657eca8 100644 --- a/src-new/astrbot_sdk/api/__init__.py +++ b/src-new/astrbot_sdk/api/__init__.py @@ -1,124 +1,23 @@ -"""AstrBot SDK 公共 API 模块。 +"""旧版 ``astrbot_sdk.api`` 的兼容入口。 -此模块提供插件开发所需的公共接口,包括: -- components: 命令组件基类 -- event: 事件处理相关工具(过滤器、事件类) -- star: 插件上下文 +新版 SDK 的推荐导入路径在顶层模块: -注意:大部分 API 是为了兼容旧版插件而保留的兼容层。 -新版 API 请参考 astrbot_sdk.context.Context 和 astrbot_sdk.decorators 模块。 +- ``astrbot_sdk.context.Context`` +- ``astrbot_sdk.events.MessageEvent`` +- ``astrbot_sdk.decorators`` +- ``astrbot_sdk.star.Star`` -# TODO: 相比旧版 API (src/astrbot_sdk/api),新版缺少以下模块: - -## 1. basic/ 模块 (完全缺失) -旧版路径: src/astrbot_sdk/api/basic/ -- AstrBotConfig: AstrBot 配置类,继承自 dict -- BaseConversationManager: 会话管理基类,提供以下方法: - - new_conversation(): 新建对话 - - switch_conversation(): 切换对话 - - delete_conversation(): 删除对话 - - get_curr_conversation_id(): 获取当前对话 ID - - get_conversation(): 获取对话 - - get_conversations(): 获取对话列表 - - get_filtered_conversations(): 获取过滤后的对话列表 - - update_conversation(): 更新对话 - - add_message_pair(): 添加消息对 - - get_human_readable_context(): 获取人类可读上下文 -- Conversation: 对话实体数据类 (platform_id, user_id, cid, history, title, persona_id, created_at, updated_at) - -## 2. message/ 模块 (完全缺失) -旧版路径: src/astrbot_sdk/api/message/ -- MessageChain: 消息链类,提供链式 API: - - message(): 添加文本 - - at(): 添加 @ 提及 - - at_all(): 添加 @ 全体成员 - - url_image(): 添加网络图片 - - file_image(): 添加本地图片 - - base64_image(): 添加 base64 图片 - - use_t2i(): 设置文本转图片 - - get_plain_text(): 获取纯文本 - - squash_plain(): 合并文本段 -- 消息组件类 (components.py): - - Plain, Image, Record, Video, File (基础类型) - - At, AtAll, Reply, Face, Node, Nodes (IM 类型) - - Share, Contact, Location, Music, Poke, Forward, Json, WechatEmoji 等 - - ComponentType 枚举, BaseMessageComponent 基类 - -## 3. event/ 模块 (部分缺失) -旧版路径: src/astrbot_sdk/api/event/ -已有: -- filter.py (简化版): command, regex, permission 装饰器 -缺失: -- astrbot_message.py: - - MessageMember: 消息成员数据类 (user_id, nickname) - - Group: 群组数据类 (group_id, group_name, group_avatar, group_owner, group_admins, members) - - AstrBotMessage: AstrBot 消息对象 (type, self_id, session_id, message_id, sender, message, message_str, raw_message, timestamp, group) -- astr_message_event.py: - - AstrMessageEvent: 消息事件类,核心方法: - - get_platform_name/id(), get_message_str/messages/type() - - get_session_id/group_id/self_id/sender_id/sender_name() - - set_extra/get_extra/clear_extra() - - is_private_chat/is_wake_up/is_admin() - - set_result/stop_event/continue_event/is_stopped() - - make_result/plain_result/image_result/chain_result() - - send(), react(), get_group() - - AstrMessageEventModel: Pydantic 模型版本 -- event_result.py: - - EventResultType: 事件结果类型枚举 (CONTINUE, STOP) - - ResultContentType: 结果内容类型枚举 (LLM_RESULT, GENERAL_RESULT, STREAMING_RESULT, STREAMING_FINISH) - - MessageEventResult: 消息事件结果类,继承 MessageChain -- event_type.py: - - EventType: 内部事件类型枚举 (OnAstrBotLoadedEvent, OnPlatformLoadedEvent, AdapterMessageEvent, OnLLMRequestEvent, OnLLMResponseEvent, OnDecoratingResultEvent, OnCallingFuncToolEvent, OnAfterMessageSentEvent) -- message_session.py: - - MessageSession: 消息会话标识类 (platform_name, message_type, session_id) -- message_type.py: - - MessageType: 消息类型枚举 (GROUP_MESSAGE, FRIEND_MESSAGE, OTHER_MESSAGE) - -## 4. platform/ 模块 (完全缺失) -旧版路径: src/astrbot_sdk/api/platform/ -- PlatformMetadata: 平台元数据类 (name, description, id, default_config_tmpl, adapter_display_name, logo_path) - -## 5. provider/ 模块 (完全缺失) -旧版路径: src/astrbot_sdk/api/provider/ -- LLMResponse: LLM 响应数据类 - - role, result_chain, tools_call_args/tools_call_name/tools_call_ids - - raw_completion (支持 OpenAI/Anthropic/Google 格式) - - to_openai_tool_calls(), to_openai_to_calls_model() - -## 6. star/ 模块 (部分缺失) -旧版路径: src/astrbot_sdk/api/star/ -已有: -- context.py (兼容层): Context 类 -缺失: -- star.py: - - StarMetadata: 插件元数据类 (name, author, desc, version, repo, module_path, root_dir_name, reserved, activated, config, star_handler_full_names, display_name, logo_path) - -## 7. components/ 模块 (部分缺失) -旧版路径: src/astrbot_sdk/api/components/ -已有: -- command.py: CommandComponent 兼容层 -缺失: (无其他文件,旧版也只有 command.py) - -## 8. filter.py 装饰器 (部分缺失) -旧版 filter.py 导出的装饰器: -已有: command, regex, permission -缺失: -- custom_filter: 自定义过滤器 -- event_message_type: 事件消息类型 -- platform_adapter_type: 平台适配器类型 -- after_message_sent: 消息发送后钩子 -- on_astrbot_loaded: AstrBot 加载完成钩子 -- on_platform_loaded: 平台加载完成钩子 -- on_decorating_result: 结果装饰钩子 -- on_llm_request: LLM 请求钩子 -- on_llm_response: LLM 响应钩子 -- command_group: 命令组 -- llm_tool: LLM 工具注册 (已注释) - -旧版还导出: -- CustomFilter, EventMessageType, EventMessageTypeFilter -- PermissionType, PermissionTypeFilter -- PlatformAdapterType, PlatformAdapterTypeFilter +这里保留 ``api`` 目录,目的是兼容旧版插件的导入路径,而不是复制一套独立的新运行时。 """ -__all__: list[str] = [] +from . import basic, components, event, message, platform, provider, star + +__all__ = [ + "basic", + "components", + "event", + "message", + "platform", + "provider", + "star", +] diff --git a/src-new/astrbot_sdk/api/basic/__init__.py b/src-new/astrbot_sdk/api/basic/__init__.py new file mode 100644 index 000000000..b3aa64cc8 --- /dev/null +++ b/src-new/astrbot_sdk/api/basic/__init__.py @@ -0,0 +1,7 @@ +"""旧版 ``astrbot_sdk.api.basic`` 的兼容入口。""" + +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 new file mode 100644 index 000000000..ea0bf67f2 --- /dev/null +++ b/src-new/astrbot_sdk/api/basic/astrbot_config.py @@ -0,0 +1,8 @@ +"""旧版配置对象兼容类型。""" + + +class AstrBotConfig(dict): + """兼容旧版 ``AstrBotConfig``。 + + 旧版实现本身就是 ``dict`` 的薄封装,兼容层保持这一行为。 + """ diff --git a/src-new/astrbot_sdk/api/basic/conversation_mgr.py b/src-new/astrbot_sdk/api/basic/conversation_mgr.py new file mode 100644 index 000000000..00fa43c9f --- /dev/null +++ b/src-new/astrbot_sdk/api/basic/conversation_mgr.py @@ -0,0 +1,5 @@ +"""旧版会话管理器兼容导出。""" + +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 new file mode 100644 index 000000000..7ac4b433c --- /dev/null +++ b/src-new/astrbot_sdk/api/basic/entities.py @@ -0,0 +1,20 @@ +"""旧版基础实体兼容类型。""" + +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 index b22555ad0..34c73c009 100644 --- a/src-new/astrbot_sdk/api/components/__init__.py +++ b/src-new/astrbot_sdk/api/components/__init__.py @@ -1,20 +1,4 @@ -"""命令组件模块。 - -提供 CommandComponent 基类,用于定义命令处理器。 -CommandComponent 是旧版 Star 基类的别名,用于向后兼容。 - -新版插件建议直接使用 astrbot_sdk.star.Star 和装饰器模式。 - -# TODO: 新旧版 components 模块对比: - -## 旧版 components 模块结构: -- command.py: CommandComponent 基类 - -## 新版与旧版基本一致: -此模块功能完整,与旧版保持兼容。 - -## 无缺失内容 -""" +"""旧版 ``astrbot_sdk.api.components`` 的兼容入口。""" from .command import CommandComponent diff --git a/src-new/astrbot_sdk/api/components/command.py b/src-new/astrbot_sdk/api/components/command.py index 65f372c21..0e4396f71 100644 --- a/src-new/astrbot_sdk/api/components/command.py +++ b/src-new/astrbot_sdk/api/components/command.py @@ -1,37 +1,4 @@ -"""命令组件兼容层。 - -此模块提供旧版 CommandComponent 的向后兼容导入。 -CommandComponent 是插件命令处理器的基类。 - -迁移说明: -- 旧版: 继承 CommandComponent,实现 command() 方法 -- 新版: 继承 astrbot_sdk.star.Star,使用 @on_command 装饰器 - -示例: - # 旧版写法 - class MyCommand(CommandComponent): - async def command(self, ctx: Context): - ... - - # 新版写法 - from astrbot_sdk import Star, on_command - - class MyPlugin(Star): - @on_command("hello") - async def handle_hello(self, ctx: Context): - ... - -# TODO: 新旧版 components 模块对比: - -## 旧版 components 模块结构: -- command.py: CommandComponent 基类(已有兼容层) - -## 新版与旧版基本一致: -旧版 components 模块仅包含 command.py,新版已提供兼容层。 - -## 无缺失内容 -此模块功能完整,与旧版保持兼容。 -""" +"""旧版 ``CommandComponent`` 的兼容导出。""" from ..._legacy_api import CommandComponent diff --git a/src-new/astrbot_sdk/api/event/__init__.py b/src-new/astrbot_sdk/api/event/__init__.py index a03040e55..2137ec120 100644 --- a/src-new/astrbot_sdk/api/event/__init__.py +++ b/src-new/astrbot_sdk/api/event/__init__.py @@ -1,51 +1,26 @@ -"""事件处理 API 模块。 +"""旧版 ``astrbot_sdk.api.event`` 的兼容入口。""" -提供事件相关的公共接口: -- AstrMessageEvent: 消息事件类,包含消息文本、用户信息、平台信息等 -- filter: 事件过滤器命名空间,提供命令、正则、权限等装饰器 -- ADMIN: 管理员权限常量 - -此模块是旧版 astrbot_sdk.api.event 的兼容层。 -新版 API 建议直接使用 astrbot_sdk.events.MessageEvent 和 astrbot_sdk.decorators。 - -# TODO: 相比旧版 event 模块,新版缺少以下内容: - -## 缺失的文件: -1. astrbot_message.py: - - MessageMember: 消息成员数据类 - - Group: 群组数据类 - - AstrBotMessage: AstrBot 消息对象 - -2. astr_message_event.py: - - AstrMessageEvent: 完整的消息事件类(当前只有 MessageEvent 别名) - - AstrMessageEventModel: Pydantic 模型版本 - -3. event_result.py: - - EventResultType: 事件结果类型枚举 - - ResultContentType: 结果内容类型枚举 - - MessageEventResult: 消息事件结果类 - -4. event_type.py: - - EventType: 内部事件类型枚举 - -5. message_session.py: - - MessageSession: 消息会话标识类 - -6. message_type.py: - - MessageType: 消息类型枚举 - -## 缺失的功能: -旧版 AstrMessageEvent 提供的便捷方法: -- get_platform_name/id(), get_message_str/messages/type() -- get_session_id/group_id/self_id/sender_id/sender_name() -- set_extra/get_extra/clear_extra() 额外信息存储 -- is_private_chat/is_wake_up/is_admin() 状态检查 -- set_result/stop_event/continue_event/is_stopped() 事件控制 -- make_result/plain_result/image_result/chain_result() 结果构建 -- send(), react(), get_group() 消息操作 -""" - -from ...events import MessageEvent as AstrMessageEvent +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", "AstrMessageEvent", "filter"] +__all__ = [ + "ADMIN", + "AstrBotMessage", + "AstrMessageEvent", + "AstrMessageEventModel", + "EventResultType", + "EventType", + "Group", + "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 new file mode 100644 index 000000000..85a99d136 --- /dev/null +++ b/src-new/astrbot_sdk/api/event/astr_message_event.py @@ -0,0 +1,335 @@ +"""旧版 ``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) + self.platform = self.platform_meta or self.platform + + @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, + 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 + return str(self.raw.get("platform_name") or self.raw.get("platform") or "") + + def get_platform_id(self) -> str: + if self.platform_meta is not None: + return self.platform_meta.id + return str(self.raw.get("platform_id") or self.raw.get("platform") or "") + + def get_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: + return self.message_obj.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 + 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 new file mode 100644 index 000000000..6bb1ea640 --- /dev/null +++ b/src-new/astrbot_sdk/api/event/astrbot_message.py @@ -0,0 +1,55 @@ +"""旧版消息对象兼容类型。""" + +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 new file mode 100644 index 000000000..0ffb4399e --- /dev/null +++ b/src-new/astrbot_sdk/api/event/event_result.py @@ -0,0 +1,57 @@ +"""旧版事件结果兼容类型。""" + +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 new file mode 100644 index 000000000..4ef4b76b2 --- /dev/null +++ b/src-new/astrbot_sdk/api/event/event_type.py @@ -0,0 +1,16 @@ +"""旧版事件类型兼容枚举。""" + +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 index 1f942a6ef..198533ab2 100644 --- a/src-new/astrbot_sdk/api/event/filter.py +++ b/src-new/astrbot_sdk/api/event/filter.py @@ -1,47 +1,253 @@ -"""事件过滤器模块。 +"""旧版事件过滤器兼容层。 -提供事件处理器的注册装饰器,用于声明式地定义事件触发条件。 -此模块是旧版 filter API 的兼容层。 +当前兼容层保证以下能力可运行: -使用方式: - # 方式一:直接使用函数 - @command("hello") - async def handle_hello(ctx): - ... +- ``command(name)`` -> ``on_command(name)`` +- ``regex(pattern)`` -> ``on_message(regex=pattern)`` +- ``permission(ADMIN)`` / ``permission_type(PermissionType.ADMIN)`` + -> ``require_admin`` - # 方式二:使用 filter 命名空间(旧版风格) - @filter.command("hello") - async def handle_hello(ctx): - ... +其余旧版高级过滤器和生命周期钩子在 v4 运行时中没有等价执行链路, +兼容层保留名称用于导入兼容,但会在调用时显式报错,避免静默失效。 +""" -新版建议直接使用 astrbot_sdk.decorators 模块中的装饰器。 +from __future__ import annotations -# TODO: 相比旧版 filter.py,新版缺少以下装饰器和类型: +import enum +from abc import ABCMeta, abstractmethod +from typing import Any -## 缺失的装饰器: -1. custom_filter: 自定义过滤器装饰器 -2. event_message_type: 事件消息类型过滤器 -3. platform_adapter_type: 平台适配器类型过滤器 -4. after_message_sent: 消息发送后钩子 -5. on_astrbot_loaded: AstrBot 加载完成钩子 -6. on_platform_loaded: 平台加载完成钩子 -7. on_decorating_result: 结果装饰钩子 -8. on_llm_request: LLM 请求钩子 -9. on_llm_response: LLM 响应钩子 -10. command_group: 命令组装饰器 -11. llm_tool: LLM 工具注册 (旧版已注释) +from ...decorators import on_command, on_message, require_admin +from ..basic.astrbot_config import AstrBotConfig +from .astr_message_event import AstrMessageEvent +from .message_type import MessageType -## 缺失的类型导出: -1. CustomFilter: 自定义过滤器基类 -2. EventMessageType: 事件消息类型枚举 -3. EventMessageTypeFilter: 事件消息类型过滤器类 -4. PermissionType: 权限类型枚举 -5. PermissionTypeFilter: 权限类型过滤器类 -6. PlatformAdapterType: 平台适配器类型枚举 -7. PlatformAdapterTypeFilter: 平台适配器类型过滤器类 +ADMIN = "admin" + + +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() + 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 + | 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, + "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) + + +def command(name: str): + return on_command(name) + + +def regex(pattern: str): + return on_message(regex=pattern) + + +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) + + +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 + + +custom_filter = _unsupported_factory("custom_filter") +event_message_type = _unsupported_factory("event_message_type") +platform_adapter_type = _unsupported_factory("platform_adapter_type") +after_message_sent = _unsupported_factory("after_message_sent") +on_astrbot_loaded = _unsupported_factory("on_astrbot_loaded") +on_platform_loaded = _unsupported_factory("on_platform_loaded") +on_decorating_result = _unsupported_factory("on_decorating_result") +on_llm_request = _unsupported_factory("on_llm_request") +on_llm_response = _unsupported_factory("on_llm_response") +command_group = _unsupported_factory("command_group") + + +class _FilterNamespace: + 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) + command_group = staticmethod(command_group) + + +filter = _FilterNamespace() -## 旧版导出列表 (参考): __all__ = [ + "ADMIN", "CustomFilter", "EventMessageType", "EventMessageTypeFilter", @@ -54,103 +260,14 @@ __all__ = [ "command_group", "custom_filter", "event_message_type", - # "llm_tool", + "filter", "on_astrbot_loaded", "on_decorating_result", "on_llm_request", "on_llm_response", "on_platform_loaded", + "permission", "permission_type", "platform_adapter_type", "regex", ] -""" - -from __future__ import annotations - -from ...decorators import on_command, on_message, require_admin - -# 管理员权限级别常量 -ADMIN = "admin" - - -def command(name: str): - """注册命令处理器装饰器。 - - Args: - name: 命令名称,用户发送以此开头的消息时触发 - - Returns: - 装饰器函数 - - 示例: - @command("hello") - async def handle_hello(ctx): - await ctx.reply("Hello!") - """ - return on_command(name) - - -def regex(pattern: str): - """注册正则匹配处理器装饰器。 - - Args: - pattern: 正则表达式模式,匹配的消息将触发处理器 - - Returns: - 装饰器函数 - - 示例: - @regex(r"hello\\s+(\\w+)") - async def handle_hello(ctx, match): - name = match.group(1) - await ctx.reply(f"Hello, {name}!") - """ - return on_message(regex=pattern) - - -def permission(level): - """权限检查装饰器。 - - Args: - level: 权限级别,目前仅支持 ADMIN - - Returns: - 装饰器函数,仅当用户具有指定权限时才执行处理器 - - 示例: - @command("admin_cmd") - @permission(ADMIN) - async def admin_only(ctx): - await ctx.reply("管理员命令已执行") - """ - if level == ADMIN: - return require_admin - - def decorator(func): - return func - - return decorator - - -class _FilterNamespace: - """过滤器命名空间,提供旧版风格的方法调用。 - - 用于支持 filter.command()、filter.regex() 等调用方式, - 保持与旧版 API 的兼容性。 - - 示例: - @filter.command("hello") - async def handle_hello(ctx): - ... - """ - - command = staticmethod(command) - regex = staticmethod(regex) - permission = staticmethod(permission) - - -# 过滤器命名空间实例,支持 filter.command() 等调用方式 -filter = _FilterNamespace() - -__all__ = ["ADMIN", "command", "regex", "permission", "filter"] diff --git a/src-new/astrbot_sdk/api/event/message_session.py b/src-new/astrbot_sdk/api/event/message_session.py new file mode 100644 index 000000000..605d800bb --- /dev/null +++ b/src-new/astrbot_sdk/api/event/message_session.py @@ -0,0 +1,29 @@ +"""旧版消息会话标识兼容类型。""" + +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 new file mode 100644 index 000000000..bb5115947 --- /dev/null +++ b/src-new/astrbot_sdk/api/event/message_type.py @@ -0,0 +1,11 @@ +"""旧版消息类型兼容枚举。""" + +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 new file mode 100644 index 000000000..6cbde2a76 --- /dev/null +++ b/src-new/astrbot_sdk/api/message/__init__.py @@ -0,0 +1,59 @@ +"""旧版 ``astrbot_sdk.api.message`` 的兼容入口。""" + +from .chain import MessageChain +from .components import ( + At, + AtAll, + BaseMessageComponent, + ComponentType, + 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", + "ComponentType", + "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 new file mode 100644 index 000000000..6b64e0b95 --- /dev/null +++ b/src-new/astrbot_sdk/api/message/chain.py @@ -0,0 +1,76 @@ +"""旧版消息链兼容实现。""" + +from __future__ import annotations + +from dataclasses import dataclass, field + +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 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 new file mode 100644 index 000000000..b9e359ae8 --- /dev/null +++ b/src-new/astrbot_sdk/api/message/components.py @@ -0,0 +1,207 @@ +"""旧版消息组件兼容类型。""" + +from __future__ import annotations + +from enum import Enum +from typing import Literal + +from pydantic import 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() + + +class Plain(BaseMessageComponent): + type: Literal[CompT.Plain] = CompT.Plain + text: str + + +class Image(BaseMessageComponent): + type: Literal[CompT.Image] = CompT.Image + file: str + + +class Record(BaseMessageComponent): + type: Literal[CompT.Record] = CompT.Record + file: str + + +class Video(BaseMessageComponent): + type: Literal[CompT.Video] = CompT.Video + file: str + + +class File(BaseMessageComponent): + type: Literal[CompT.File] = CompT.File + file_name: str + mime_type: str | None = None + file: str + + +class At(BaseMessageComponent): + type: Literal[CompT.At] = CompT.At + user_id: str | None = None + user_name: str | None = None + + +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 + nickname: str | None = None + 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/platform/__init__.py b/src-new/astrbot_sdk/api/platform/__init__.py new file mode 100644 index 000000000..291e06d7e --- /dev/null +++ b/src-new/astrbot_sdk/api/platform/__init__.py @@ -0,0 +1,5 @@ +"""旧版 ``astrbot_sdk.api.platform`` 的兼容入口。""" + +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 new file mode 100644 index 000000000..bf7b471b8 --- /dev/null +++ b/src-new/astrbot_sdk/api/platform/platform_metadata.py @@ -0,0 +1,15 @@ +"""旧版平台元数据兼容类型。""" + +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 new file mode 100644 index 000000000..b7a954f9b --- /dev/null +++ b/src-new/astrbot_sdk/api/provider/__init__.py @@ -0,0 +1,5 @@ +"""旧版 ``astrbot_sdk.api.provider`` 的兼容入口。""" + +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 new file mode 100644 index 000000000..a982a32fd --- /dev/null +++ b/src-new/astrbot_sdk/api/provider/entities.py @@ -0,0 +1,110 @@ +"""旧版 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 + + 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 index db7fbae27..2beb8dae5 100644 --- a/src-new/astrbot_sdk/api/star/__init__.py +++ b/src-new/astrbot_sdk/api/star/__init__.py @@ -1,36 +1,6 @@ -"""插件上下文 API 模块。 - -提供插件运行时上下文 Context 类,用于: -- 调用 LLM 生成文本 -- 发送消息 -- 管理会话 -- 存储键值数据 - -此模块是旧版 astrbot_sdk.api.star 的兼容层。 -Context 实际上是 LegacyContext 的别名,用于向后兼容旧版插件。 - -新版插件建议使用 astrbot_sdk.context.Context。 - -# TODO: 相比旧版 star 模块,新版缺少以下内容: - -## 缺失的文件: -- star.py: StarMetadata 插件元数据类 - -## 旧版 star 模块结构: -1. context.py - Context 抽象类(已有兼容层) -2. star.py - StarMetadata 数据类(缺失) - -StarMetadata 用于描述插件的元信息,包括: -- 基础信息: name, author, desc, version, repo -- 模块路径: module_path, root_dir_name -- 状态标志: reserved, activated -- 配置: config (AstrBotConfig) -- Handler 信息: star_handler_full_names -- 展示信息: display_name, logo_path - -建议在新版中提供等效的插件元数据访问接口。 -""" +"""旧版 ``astrbot_sdk.api.star`` 的兼容入口。""" from .context import Context +from .star import StarMetadata -__all__ = ["Context"] +__all__ = ["Context", "StarMetadata"] diff --git a/src-new/astrbot_sdk/api/star/context.py b/src-new/astrbot_sdk/api/star/context.py index c8a395bfc..d90cb425d 100644 --- a/src-new/astrbot_sdk/api/star/context.py +++ b/src-new/astrbot_sdk/api/star/context.py @@ -1,41 +1,4 @@ -"""插件上下文兼容层。 - -此模块提供旧版 Context 类的向后兼容导入。 -Context 是插件运行时的核心接口,提供: - -- llm_generate(): 调用 LLM 生成文本 -- tool_loop_agent(): 运行 LLM Agent 循环 -- send_message(): 发送消息到会话 -- conversation_manager: 会话管理器 -- put_kv_data()/get_kv_data()/delete_kv_data(): 键值存储 - -迁移说明: -- 旧版: from astrbot_sdk.api.star import Context -- 新版: from astrbot_sdk import Context (astrbot_sdk.context.Context) - -新版 Context 提供更丰富的接口: -- ctx.llm.chat_raw(): LLM 调用 -- ctx.platform.send(): 发送消息 -- ctx.db.set()/get()/delete(): 数据存储 - -注意:使用旧版 API 会触发弃用警告。 - -# TODO: 相比旧版 star 模块,新版缺少以下内容: - -## 缺失的文件: -1. star.py: - - StarMetadata: 插件元数据类 - - name, author, desc, version, repo: 基础信息 - - module_path, root_dir_name: 模块路径信息 - - reserved, activated: 状态标志 - - config: AstrBotConfig 插件配置 - - star_handler_full_names: Handler 全名列表 - - display_name, logo_path: 展示信息 - -## 缺失的导入(旧版 star/__init__.py 为空): -旧版 star 模块主要通过 context.py 提供 Context 类, -新版通过兼容层导入,但缺少 StarMetadata 的公开导出。 -""" +"""旧版 ``Context`` 的兼容导出。""" from ..._legacy_api import Context diff --git a/src-new/astrbot_sdk/api/star/star.py b/src-new/astrbot_sdk/api/star/star.py new file mode 100644 index 000000000..cf1c50b11 --- /dev/null +++ b/src-new/astrbot_sdk/api/star/star.py @@ -0,0 +1,30 @@ +"""旧版插件元数据兼容类型。""" + +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/context.py b/src-new/astrbot_sdk/context.py index ec9513134..ab749e135 100644 --- a/src-new/astrbot_sdk/context.py +++ b/src-new/astrbot_sdk/context.py @@ -1,50 +1,4 @@ -# ============================================================================= -# 新旧对比 - context.py -# ============================================================================= -# -# 【旧版 src/astrbot_sdk/api/star/context.py】 -# - Context 是抽象类 (ABC) -# - 包含 conversation_manager、persona_manager 属性 -# - 提供方法: llm_generate(), tool_loop_agent(), send_message(), -# add_llm_tools(), put_kv_data(), get_kv_data(), delete_kv_data() -# - 所有方法都是抽象方法 (...) -# - 依赖: BaseConversationManager, ToolSet, FunctionTool, Message, LLMResponse, MessageChain -# -# 【新版 src-new/astrbot_sdk/context.py】 -# - Context 是具体类,直接实例化 -# - 包含客户端属性: llm, memory, db, platform -# - 包含: plugin_id, logger, cancel_token -# - 新增 CancelToken 数据类用于取消控制 -# - 通过 CapabilityProxy 代理实现跨进程调用 -# -# 【架构差异】 -# - 旧版: 抽象基类,由 AstrBot Core 实现具体逻辑 -# - 新版: 具体类,通过 CapabilityProxy 代理调用远程能力 -# -# ============================================================================= -# TODO: 功能缺失 -# ============================================================================= -# -# 1. 缺少 conversation_manager 属性 -# - 旧版: conversation_manager: BaseConversationManager -# - 新版: 已移至 _legacy_api.py 的 LegacyConversationManager -# - 迁移: 使用 ctx.db 直接操作会话数据,或使用 compat.LegacyConversationManager -# -# 2. 缺少 persona_manager 属性 -# - 旧版: persona_manager: Any -# - 新版: 无对应实现 -# - TODO: 需要确定是否需要在 clients/ 中添加 PersonaClient -# -# 3. 缺少 _register_component() 方法 -# - 旧版: 用于注册组件实例及其公共方法 -# - 新版: 架构变化,不再需要此方法 -# -# 4. 方法签名变化 -# - 旧版 llm_generate(chat_provider_id, ...): 需要 chat_provider_id -# - 新版 LLMClient.chat_raw(prompt, ...): 不需要 chat_provider_id -# - 迁移: 使用 ctx.llm.chat_raw() 替代 ctx.llm_generate() -# -# ============================================================================= +"""v4 原生运行时上下文。""" from __future__ import annotations diff --git a/src-new/astrbot_sdk/decorators.py b/src-new/astrbot_sdk/decorators.py index 0e0e733dd..9bd8dbbfb 100644 --- a/src-new/astrbot_sdk/decorators.py +++ b/src-new/astrbot_sdk/decorators.py @@ -1,58 +1,8 @@ -# ============================================================================= -# 新旧对比 - decorators.py -# ============================================================================= -# -# 【旧版 src/astrbot_sdk/api/event/filter.py】 -# 导出的装饰器和类型: -# - 装饰器: command, regex, custom_filter, event_message_type, permission_type, -# platform_adapter_type, after_message_sent, on_astrbot_loaded, -# on_platform_loaded, on_decorating_result, on_llm_request, on_llm_response, -# command_group, llm_tool (注释掉) -# - 类型: CustomFilter, EventMessageType, EventMessageTypeFilter, -# PermissionType, PermissionTypeFilter, PlatformAdapterType, PlatformAdapterTypeFilter -# -# 【新版 src-new/astrbot_sdk/decorators.py】 -# 提供的装饰器: -# - on_command, on_message, on_event, on_schedule, require_admin -# - 内部类型: HandlerMeta, HandlerCallable -# -# ============================================================================= -# TODO: 功能缺失 -# ============================================================================= -# -# 1. 缺少装饰器 -# - custom_filter: 自定义过滤器装饰器 -# - event_message_type: 消息类型过滤器 -# - permission_type: 权限类型过滤器 (有 require_admin 但更通用) -# - platform_adapter_type: 平台适配器类型过滤器 -# - after_message_sent: 消息发送后钩子 -# - on_astrbot_loaded: AstrBot 加载完成钩子 -# - on_platform_loaded: 平台加载完成钩子 -# - on_decorating_result: 结果装饰钩子 -# - on_llm_request: LLM 请求钩子 -# - on_llm_response: LLM 响应钩子 -# - command_group: 命令组装饰器 -# - llm_tool: LLM 工具装饰器 (旧版已注释) -# -# 2. 缺少类型定义 -# - CustomFilter: 自定义过滤器基类 -# - EventMessageType: 消息类型枚举 -# - EventMessageTypeFilter: 消息类型过滤器 -# - PermissionType: 权限类型枚举 -# - PermissionTypeFilter: 权限类型过滤器 -# - PlatformAdapterType: 平台适配器类型枚举 -# - PlatformAdapterTypeFilter: 平台适配器类型过滤器 -# -# 3. 命名差异 -# - 旧版 command -> 新版 on_command -# - 旧版 regex -> 新版 on_message(regex=...) -# - 新版 on_message 支持关键词和平台过滤 -# -# 4. 新增功能 -# - on_schedule: 定时任务装饰器 (旧版无) -# - require_admin: 管理员权限快捷装饰器 (旧版用 permission_type) -# -# ============================================================================= +"""v4 原生装饰器。 + +旧版 ``astrbot_sdk.api.event.filter`` 的兼容与降级边界由 compat 模块处理, +这里仅保留 v4 原生 trigger/permission 元数据建模。 +""" from __future__ import annotations diff --git a/src-new/astrbot_sdk/events.py b/src-new/astrbot_sdk/events.py index d2f25b273..0c3a55ee7 100644 --- a/src-new/astrbot_sdk/events.py +++ b/src-new/astrbot_sdk/events.py @@ -1,59 +1,9 @@ -# ============================================================================= -# 新旧对比 - events.py -# ============================================================================= -# -# 【旧版 src/astrbot_sdk/api/event/】 -# 包含多个文件: -# - astr_message_event.py: AstrMessageEvent 类(约 370 行) -# - astrbot_message.py: AstrBotMessage 消息对象 -# - event_result.py: MessageEventResult 事件结果 -# - event_type.py: EventType 枚举 -# - message_session.py: MessageSession 会话 -# - message_type.py: MessageType 枚举 -# -# 【新版 src-new/astrbot_sdk/events.py】 -# 仅包含: -# - MessageEvent: 简化的消息事件类 -# - PlainTextResult: 纯文本结果数据类 -# -# ============================================================================= -# TODO: 功能缺失 -# ============================================================================= -# -# 1. AstrMessageEvent 大量功能缺失 -# - 属性缺失: -# - message_obj: AstrBotMessage 消息对象 -# - platform_meta: PlatformMetadata 平台元信息 -# - role: "admin" | "member" 角色 -# - is_wake, is_at_or_wake_command: 唤醒状态 -# - session, unified_msg_origin: 会话标识 -# -# - 方法缺失: -# - get_platform_name(), get_platform_id(): 平台信息 -# - get_messages(): 获取消息链 -# - get_message_type(): 获取消息类型 -# - get_group_id(), get_self_id(), get_sender_id(), get_sender_name(): ID 获取 -# - set_extra(), get_extra(), clear_extra(): 额外信息存储 -# - is_private_chat(), is_wake_up(), is_admin(): 状态判断 -# - set_result(), stop_event(), continue_event(), is_stopped(): 事件控制 -# - should_call_llm(), get_result(), clear_result(): LLM 控制 -# - make_result(), plain_result(), image_result(), chain_result(): 结果构建 -# - send(), react(), get_group(): 消息操作 -# -# 2. 缺少关联类型 -# - AstrBotMessage: 消息对象(包含 sender, message, type 等) -# - MessageEventResult: 事件结果(包含 chain, result_type 等) -# - MessageSession: 会话标识 -# - MessageType: 消息类型枚举 (FRIEND_MESSAGE, GROUP_MESSAGE 等) -# - PlatformMetadata: 平台元信息 -# -# 3. 新版 MessageEvent 特性 -# - 简化设计,仅包含核心属性: text, user_id, group_id, platform, session_id -# - 通过 reply_handler 实现回复功能 -# - 支持从 payload 构建: from_payload() -# - 支持序列化: to_payload() -# -# ============================================================================= +"""v4 原生事件对象。 + +顶层 ``MessageEvent`` 保持精简,只承载 v4 运行时真正需要的基础能力。 +旧版 ``AstrMessageEvent`` 的便捷方法与结果对象由 +``astrbot_sdk.api.event`` 兼容层承接,而不是继续塞回顶层事件类型。 +""" from __future__ import annotations diff --git a/src-new/astrbot_sdk/runtime/handler_dispatcher.py b/src-new/astrbot_sdk/runtime/handler_dispatcher.py index 235266111..371edcb2d 100644 --- a/src-new/astrbot_sdk/runtime/handler_dispatcher.py +++ b/src-new/astrbot_sdk/runtime/handler_dispatcher.py @@ -241,10 +241,16 @@ class HandlerDispatcher: if len(non_none_types) == 1: param_type = non_none_types[0] - # 注入 MessageEvent 及其子类 - if param_type is MessageEvent or ( - isinstance(param_type, type) and issubclass(param_type, MessageEvent) - ): + # 注入 MessageEvent 及其子类。旧版 compat 事件类型会通过 + # from_message_event() 包装成带便捷方法的对象。 + if param_type is MessageEvent: + return event + if isinstance(param_type, type) and issubclass(param_type, MessageEvent): + if isinstance(event, param_type): + return event + factory = getattr(param_type, "from_message_event", None) + if callable(factory): + return factory(event) return event # 注入 Context 及其子类 @@ -256,6 +262,13 @@ class HandlerDispatcher: return None async def _consume_legacy_result(self, item: Any, event: MessageEvent) -> None: + from ..api.event.event_result import MessageEventResult + + if isinstance(item, MessageEventResult): + plain_text = item.get_plain_text() + if plain_text: + await event.reply(plain_text) + return if isinstance(item, PlainTextResult): await event.reply(item.text) return @@ -273,6 +286,8 @@ class HandlerDispatcher: ctx: Context, ) -> None: if hasattr(owner, "on_error") and callable(owner.on_error): - await owner.on_error(exc, event, ctx) + result = owner.on_error(exc, event, ctx) + if inspect.isawaitable(result): + await result return await Star().on_error(exc, event, ctx) diff --git a/src-new/astrbot_sdk/star.py b/src-new/astrbot_sdk/star.py index d8a20f52d..e26cef742 100644 --- a/src-new/astrbot_sdk/star.py +++ b/src-new/astrbot_sdk/star.py @@ -1,43 +1,8 @@ -# ============================================================================= -# 新旧对比 - star.py -# ============================================================================= -# -# 【旧版 src/astrbot_sdk/api/star/star.py】 -# - StarMetadata: 插件元数据类(dataclass) -# - 属性: name, author, desc, version, repo, module_path, root_dir_name, -# reserved, activated, config, star_handler_full_names, display_name, logo_path -# - 旧版没有 Star 基类定义(定义在其他文件中) -# -# 【新版 src-new/astrbot_sdk/star.py】 -# - Star: 插件基类 -# - __handlers__: 处理器方法名元组 -# - __init_subclass__(): 自动收集装饰器标记的方法 -# - on_start(), on_stop(), on_error(): 生命周期钩子 -# - __astrbot_is_new_star__(): 标识新版 Star -# -# 【架构差异】 -# - 旧版: StarMetadata 独立定义,Star 类在其他地方 -# - 新版: Star 基类在此文件,元数据通过装饰器自动收集 -# -# ============================================================================= -# TODO: 功能缺失 -# ============================================================================= -# -# 1. 缺少 StarMetadata 类 -# - 旧版: @dataclass class StarMetadata -# - 新版: 无对应实现 -# - 属性: name, author, desc, version, repo, module_path, root_dir_name, -# reserved, activated, config, star_handler_full_names, display_name, logo_path -# - 迁移: 可能需要在 api/star/__init__.py 或单独文件中实现 -# -# 2. 生命周期方法差异 -# - 新版 on_start/on_stop 参数类型为 Any | None -# - 应该使用更精确的类型注解 -# -# 3. 缺少旧版 Star 接口方法(如果有的话) -# - 需要确认旧版 Star 是否有其他必须实现的接口方法 -# -# ============================================================================= +"""v4 原生插件基类。 + +旧版 ``StarMetadata`` 等兼容数据类型保留在 ``astrbot_sdk.api.star``, +这里仅承载新版插件生命周期与 handler 收集逻辑。 +""" from __future__ import annotations diff --git a/tests_v4/test_api_event_filter.py b/tests_v4/test_api_event_filter.py index 75fe34187..cc9b72c0c 100644 --- a/tests_v4/test_api_event_filter.py +++ b/tests_v4/test_api_event_filter.py @@ -5,7 +5,18 @@ Tests for api/event/filter.py - Event filter decorators and utilities. from __future__ import annotations -from astrbot_sdk.api.event.filter import ADMIN, command, filter, permission, regex +import pytest + +from astrbot_sdk.api.event.filter import ( + ADMIN, + PermissionType, + command, + command_group, + filter, + permission, + permission_type, + regex, +) from astrbot_sdk.decorators import get_handler_meta from astrbot_sdk.protocol.descriptors import CommandTrigger, MessageTrigger @@ -99,6 +110,27 @@ class TestPermissionFilter: 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.""" @@ -169,3 +201,12 @@ class TestModuleExports: assert "regex" in __all__ assert "permission" in __all__ assert "filter" in __all__ + + +class TestUnsupportedCompatFilters: + """Tests for explicitly unsupported legacy helpers.""" + + def test_unsupported_filter_raises_explicitly(self): + """Unsupported helpers should fail loudly instead of silently no-oping.""" + with pytest.raises(NotImplementedError, match="command_group"): + command_group("group_name") diff --git a/tests_v4/test_api_modules.py b/tests_v4/test_api_modules.py index 1ad223d10..8d56bf4b5 100644 --- a/tests_v4/test_api_modules.py +++ b/tests_v4/test_api_modules.py @@ -21,6 +21,14 @@ class TestApiStarModule: 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" + class TestApiComponentsModule: """Tests for api/components module exports.""" @@ -50,12 +58,12 @@ class TestApiEventModule: assert filter is not None assert AstrMessageEvent is not None - def test_astr_message_event_is_message_event(self): - """AstrMessageEvent should be MessageEvent.""" + 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 AstrMessageEvent is MessageEvent + assert issubclass(AstrMessageEvent, MessageEvent) def test_all_exports(self): """api.event should export all expected names.""" @@ -65,6 +73,18 @@ class TestApiEventModule: 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 + class TestApiModule: """Tests for top-level api module.""" @@ -74,3 +94,12 @@ class TestApiModule: import astrbot_sdk.api assert astrbot_sdk.api is not None + + def test_api_subpackages_exist(self): + """New compat subpackages should be importable.""" + from astrbot_sdk.api import basic, message, platform, provider + + assert basic is not None + assert message is not None + assert platform is not None + assert provider is not None diff --git a/tests_v4/test_handler_dispatcher.py b/tests_v4/test_handler_dispatcher.py index b19b8f2c4..0712ebb67 100644 --- a/tests_v4/test_handler_dispatcher.py +++ b/tests_v4/test_handler_dispatcher.py @@ -10,6 +10,7 @@ from unittest.mock import AsyncMock, MagicMock import pytest +from astrbot_sdk.api.event import AstrMessageEvent from astrbot_sdk.context import CancelToken, Context from astrbot_sdk.events import MessageEvent, PlainTextResult from astrbot_sdk.protocol.descriptors import ( @@ -39,7 +40,10 @@ class MockPeer: ) -> dict[str, Any]: """模拟 peer.invoke 方法,用于 CapabilityProxy""" if name == "platform.send": - await self.send(payload.get("session_id", ""), payload.get("text", "")) + await self.send( + payload.get("session", payload.get("session_id", "")), + payload.get("text", ""), + ) return {} return {} @@ -314,6 +318,58 @@ class TestHandlerDispatcherInvoke: # 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"}] + class TestHandlerDispatcherCancel: """Tests for HandlerDispatcher.cancel method."""