From 9d04cec8a48a5c7117239435e9d69a5d94f58f96 Mon Sep 17 00:00:00 2001 From: whatevertogo Date: Thu, 12 Mar 2026 21:42:46 +0800 Subject: [PATCH] feat: Implement Peer and Transport layers for asynchronous communication - Added Peer class for managing peer-to-peer communication, including initialization, invocation, and cancellation of requests. - Introduced Transport abstract base class with StdioTransport and WebSocketTransport implementations for message handling. - Created Star class for error handling in event-driven architecture. - Developed MemoryTransport for testing purposes, allowing in-memory communication between peers. - Implemented unit tests for Peer functionality, protocol message parsing, and runtime integration with plugins. - Established entry point tests to ensure package import and command-line interface functionality. --- CLAUDE.md | 3 + pyproject.toml | 10 + refactor.md | 1385 +++++++++++++++++ src-new.rar | Bin 0 -> 100560 bytes src-new/astrbot_sdk/__init__.py | 17 + src-new/astrbot_sdk/__main__.py | 5 + src-new/astrbot_sdk/api/__init__.py | 1 + .../astrbot_sdk/api/components/__init__.py | 3 + src-new/astrbot_sdk/api/components/command.py | 3 + src-new/astrbot_sdk/api/event/__init__.py | 4 + src-new/astrbot_sdk/api/event/filter.py | 32 + src-new/astrbot_sdk/api/star/__init__.py | 3 + src-new/astrbot_sdk/api/star/context.py | 3 + src-new/astrbot_sdk/cli.py | 56 + src-new/astrbot_sdk/clients/__init__.py | 13 + src-new/astrbot_sdk/clients/_proxy.py | 40 + src-new/astrbot_sdk/clients/db.py | 25 + src-new/astrbot_sdk/clients/llm.py | 77 + src-new/astrbot_sdk/clients/memory.py | 20 + src-new/astrbot_sdk/clients/platform.py | 26 + src-new/astrbot_sdk/compat.py | 143 ++ src-new/astrbot_sdk/context.py | 51 + src-new/astrbot_sdk/decorators.py | 98 ++ src-new/astrbot_sdk/errors.py | 85 + src-new/astrbot_sdk/events.py | 67 + src-new/astrbot_sdk/protocol/__init__.py | 21 + src-new/astrbot_sdk/protocol/descriptors.py | 69 + .../astrbot_sdk/protocol/legacy_adapter.py | 90 ++ src-new/astrbot_sdk/protocol/messages.py | 96 ++ src-new/astrbot_sdk/runtime/__init__.py | 1 + src-new/astrbot_sdk/runtime/bootstrap.py | 352 +++++ .../astrbot_sdk/runtime/capability_router.py | 330 ++++ .../astrbot_sdk/runtime/handler_dispatcher.py | 102 ++ src-new/astrbot_sdk/runtime/loader.py | 307 ++++ src-new/astrbot_sdk/runtime/peer.py | 346 ++++ src-new/astrbot_sdk/runtime/transport.py | 285 ++++ src-new/astrbot_sdk/star.py | 26 + tests_v4/__init__.py | 1 + tests_v4/helpers.py | 40 + tests_v4/test_entrypoints.py | 37 + tests_v4/test_peer.py | 189 +++ tests_v4/test_protocol.py | 53 + tests_v4/test_runtime.py | 149 ++ 43 files changed, 4664 insertions(+) create mode 100644 CLAUDE.md create mode 100644 refactor.md create mode 100644 src-new.rar create mode 100644 src-new/astrbot_sdk/__init__.py create mode 100644 src-new/astrbot_sdk/__main__.py create mode 100644 src-new/astrbot_sdk/api/__init__.py create mode 100644 src-new/astrbot_sdk/api/components/__init__.py create mode 100644 src-new/astrbot_sdk/api/components/command.py create mode 100644 src-new/astrbot_sdk/api/event/__init__.py create mode 100644 src-new/astrbot_sdk/api/event/filter.py create mode 100644 src-new/astrbot_sdk/api/star/__init__.py create mode 100644 src-new/astrbot_sdk/api/star/context.py create mode 100644 src-new/astrbot_sdk/cli.py create mode 100644 src-new/astrbot_sdk/clients/__init__.py create mode 100644 src-new/astrbot_sdk/clients/_proxy.py create mode 100644 src-new/astrbot_sdk/clients/db.py create mode 100644 src-new/astrbot_sdk/clients/llm.py create mode 100644 src-new/astrbot_sdk/clients/memory.py create mode 100644 src-new/astrbot_sdk/clients/platform.py create mode 100644 src-new/astrbot_sdk/compat.py create mode 100644 src-new/astrbot_sdk/context.py create mode 100644 src-new/astrbot_sdk/decorators.py create mode 100644 src-new/astrbot_sdk/errors.py create mode 100644 src-new/astrbot_sdk/events.py create mode 100644 src-new/astrbot_sdk/protocol/__init__.py create mode 100644 src-new/astrbot_sdk/protocol/descriptors.py create mode 100644 src-new/astrbot_sdk/protocol/legacy_adapter.py create mode 100644 src-new/astrbot_sdk/protocol/messages.py create mode 100644 src-new/astrbot_sdk/runtime/__init__.py create mode 100644 src-new/astrbot_sdk/runtime/bootstrap.py create mode 100644 src-new/astrbot_sdk/runtime/capability_router.py create mode 100644 src-new/astrbot_sdk/runtime/handler_dispatcher.py create mode 100644 src-new/astrbot_sdk/runtime/loader.py create mode 100644 src-new/astrbot_sdk/runtime/peer.py create mode 100644 src-new/astrbot_sdk/runtime/transport.py create mode 100644 src-new/astrbot_sdk/star.py create mode 100644 tests_v4/__init__.py create mode 100644 tests_v4/helpers.py create mode 100644 tests_v4/test_entrypoints.py create mode 100644 tests_v4/test_peer.py create mode 100644 tests_v4/test_protocol.py create mode 100644 tests_v4/test_runtime.py diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 000000000..e67ff070a --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,3 @@ +# CLAUDE Notes + +- 2026-03-12: `refactor.md` on disk was empty, while the active collaboration context contained the full v4 refactor design. Treat the conversation-approved v4 design as source of truth unless a newer committed document replaces it. diff --git a/pyproject.toml b/pyproject.toml index 3c173e398..c8e559f43 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,3 +1,7 @@ +[build-system] +requires = ["setuptools>=80", "wheel"] +build-backend = "setuptools.build_meta" + [project] name = "astrbot-sdk" version = "0.1.0" @@ -19,3 +23,9 @@ dependencies = [ [project.scripts] astr = "astrbot_sdk.cli:cli" + +[tool.setuptools] +package-dir = {"" = "src-new"} + +[tool.setuptools.packages.find] +where = ["src-new"] diff --git a/refactor.md b/refactor.md new file mode 100644 index 000000000..cc27256fc --- /dev/null +++ b/refactor.md @@ -0,0 +1,1385 @@ +# AstrBot SDK 重构架构设计 v4 + +--- + +## 一、全局架构图 + +``` +╔══════════════════════════════════════════════════════════════════════╗ +║ 插件作者的世界 ║ +║ ║ +║ class MyPlugin(Star): ║ +║ @on_command("hello") ║ +║ async def hello(self, event: MessageEvent, ctx: Context): ║ +║ reply = await ctx.llm.chat(event.text) ║ +║ await event.reply(reply) ║ +║ ║ +╚══════════════════════════════════════════════════════════════════════╝ + │ Star / 装饰器 / Event │ Context / Clients + ▼ ▼ +┌─────────────────────┐ ┌────────────────────────────────┐ +│ Handler 系统 │ │ Capability 调用系统 │ +│ │ │ │ +│ HandlerDescriptor │ │ ctx.llm.chat() │ +│ HandlerDispatcher │ │ ctx.memory.search() │ +│ │ │ ctx.db.get() │ +│ 插件 → 主进程 │ │ ctx.platform.send() │ +│ "我能响应这些事件" │ │ │ +│ │ │ 插件 → 主进程 │ +│ │ │ "帮我调用这个能力" │ +└──────────┬──────────┘ └──────────────┬─────────────────┘ + │ │ + └─────────────────┬────────────────┘ + ▼ +┌──────────────────────────────────────────────────────────────────┐ +│ 通信层 │ +│ │ +│ 所有消息统一使用 id 字段关联请求与响应 │ +│ │ +│ Peer.initialize(handlers=[...]) │ +│ Peer.invoke("llm.chat", input, stream=false) → result │ +│ Peer.invoke("llm.stream_chat", input, stream=true) → event* │ +│ Peer.invoke("handler.invoke", {handler_id, event}) │ +│ │ +│ Transport: StdioTransport / WebSocketTransport │ +└──────────────────────────────────────────────────────────────────┘ + │ JSON 消息流 + ▼ +┌──────────────────────────────────────────────────────────────────┐ +│ 主进程(AstrBot Core) │ +│ │ +│ CapabilityRouter ──► "llm.chat" ──► LLM Service │ +│ ──► "db.get" ──► Storage │ +│ ──► "handler.invoke" ──► 转发给插件 │ +│ │ +│ HandlerDispatcher ◄── 外部消息 ──► 匹配订阅 ──► 回调插件 │ +└──────────────────────────────────────────────────────────────────┘ + + ┌─────────────────────┐ + │ compat.py(旁路) │ ← 不是核心层 + │ 旧 API → 转发新 API │ 新代码不感知它 + └─────────────────────┘ +``` + +--- + +## 二、两个核心概念的区分 + +``` +┌──────────────────────────────────────────────────────────────┐ +│ HandlerDescriptor │ +│ 方向:插件 ──► 主进程(initialize 时声明) │ +│ 含义:插件订阅"我能响应哪些事件" │ +│ 例子:@on_command("hello") → 订阅 /hello 命令 │ +└──────────────────────────────────────────────────────────────┘ + +┌──────────────────────────────────────────────────────────────┐ +│ CapabilityInvocation │ +│ 方向:插件 ──► 主进程(运行时按需调用) │ +│ 含义:插件请求"帮我执行这个能力" │ +│ 例子:ctx.llm.chat() → invoke "llm.chat" │ +└──────────────────────────────────────────────────────────────┘ + +┌──────────────────────────────────────────────────────────────┐ +│ CapabilityDescriptor │ +│ 方向:主进程 ──► 插件(initialize_result 时返回) │ +│ 含义:主进程声明"我提供哪些能力" │ +│ 例子:{ name: "llm.chat", supports_stream: false, ... } │ +└──────────────────────────────────────────────────────────────┘ +``` + +| | HandlerDescriptor | CapabilityDescriptor | CapabilityInvocation | +|---|---|---|---| +| 谁发 | 插件 | 主进程 | 插件 | +| 何时 | initialize 时 | initialize_result 时 | 运行时 | +| 主进程动作 | 注册订阅 | 告知可用能力 | 执行并返回结果 | + +--- + +## 三、分层职责 + +``` +┌─────────────────────────────────────────────────────┐ +│ Layer 1:用户层 │ +│ Star / 装饰器 / MessageEvent │ +│ 插件作者只接触这一层 │ +│ 不知道:RPC、进程、序列化、订阅协议 │ +└──────────────────────────┬──────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────────────────┐ +│ Layer 2:API 层 │ +│ Context / LLMClient / DBClient / MemoryClient │ +│ PlatformClient │ +│ 把能力包装成类型化 API │ +│ 不知道:JSON 格式、id、transport │ +└──────────────────────────┬──────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────────────────┐ +│ Layer 3:翻译层 │ +│ CapabilityProxy │ +│ API 调用 → Peer.invoke(name, input, stream) │ +│ output dict → 返回类型 │ +│ 无业务逻辑,一一对应 │ +└──────────────────────────┬──────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────────────────┐ +│ Layer 4:通信层 │ +│ Peer / Transport / Protocol Messages │ +│ 可靠收发消息 │ +│ 不知道业务,只知道消息格式 │ +└─────────────────────────────────────────────────────┘ + + ※ compat.py 不是第五层,是用户层和 API 层的旁路入口。 + 新代码不 import 它,可整体删除。 +``` + +--- + +## 四、目录结构 + +``` +astrbot_sdk/ +│ +├── star.py +├── context.py +├── decorators.py +├── events.py +├── errors.py +├── compat.py ← 旁路,不是核心层 +│ +├── clients/ +│ ├── llm.py +│ ├── memory.py +│ ├── db.py +│ └── platform.py +│ +├── runtime/ +│ ├── peer.py +│ ├── transport.py +│ ├── capability_router.py +│ ├── handler_dispatcher.py +│ ├── loader.py +│ └── bootstrap.py +│ +└── protocol/ + ├── messages.py ← 所有协议消息类型 + ├── descriptors.py ← HandlerDescriptor / CapabilityDescriptor + └── legacy_adapter.py ← 旧线协议翻译,只做翻译无业务逻辑 +``` + +--- + +## 五、协议消息定义(完整版) + +### 五条硬规则 + +**规则一:统一使用 `id` 字段关联所有请求与响应** +``` +所有消息只用一个关联字段:id +不区分 request_id / invocation_id,全部统一成 id。 +发送方生成 id,接收方响应时原样带回,双方按 id 配对。 +``` + +**规则二:event 只用于 stream=true 的调用** +``` +stream=false 的调用只能以单个 result 结束。 +stream=true 的调用只能以 event 序列结束。 +stream=false 的调用不得发送 event(started/delta/completed/failed)。 +违反此规则的实现视为协议错误。 +``` + +**规则三:插件 handler 回调走统一 invoke,不新增消息类型** +``` +主进程触发插件处理器时: + capability: "handler.invoke" + input: { handler_id: str, event: { 纯数据 } } + +ctx 不通过线协议传输。 +ctx 由插件进程本地重建并注入处理器。 +看到处理器签名有 ctx 参数,不要误以为需要从主进程发过来。 +``` + +**规则四:cancel 是"请求停止",不是"立即停止"** +``` +收到 cancel 后: + 若调用已结束 → 忽略,不报错 + 若调用仍在执行 → 尽力中断,发送统一终止态 + +统一终止态: + stream=true: event { phase: "failed", error: { code: "cancelled" } } + stream=false: result { success: false, error: { code: "cancelled" } } + +调用方收到 cancel 后必须等待终止态,不能认为发完 cancel 就已结束。 +``` + +**规则五:initialize 失败后连接进入不可用状态** +``` +initialize 失败(协议版本不兼容 / handlers 非法 / 元信息缺失)时: + 返回 result { kind: "initialize_result", success: false, error: {...} } + 连接进入不可用状态 + 除关闭连接外,不得继续发送普通 invoke + 对端收到失败的 initialize_result 后应立即关闭连接 +``` + +--- + +### 消息格式 + +**initialize** +```json +{ + "type": "initialize", + "id": "msg_001", + "protocol_version": "1.0", + "peer": { + "name": "my-plugin", + "role": "plugin", + "version": "1.2.0" + }, + "handlers": [ "HandlerDescriptor ..." ], + "metadata": {} +} +``` + +**initialize_result(成功)** +```json +{ + "type": "result", + "id": "msg_001", + "kind": "initialize_result", + "success": true, + "output": { + "peer": { "name": "astrbot-core", "role": "core" }, + "capabilities": [ "CapabilityDescriptor ..." ], + "metadata": {} + } +} +``` + +**initialize_result(失败)** +```json +{ + "type": "result", + "id": "msg_001", + "kind": "initialize_result", + "success": false, + "error": { + "code": "protocol_version_mismatch", + "message": "服务端支持协议版本 1.0,客户端请求版本 2.0", + "hint": "请升级 astrbot_sdk 至最新版本", + "retryable": false + } +} +``` +※ 失败后连接进入不可用状态,对端应立即关闭连接。 + +**invoke(普通能力)** +```json +{ + "type": "invoke", + "id": "msg_002", + "capability": "llm.chat", + "input": { "prompt": "hi", "system": null }, + "stream": false +} +``` + +**invoke(流式能力)** +```json +{ + "type": "invoke", + "id": "msg_003", + "capability": "llm.stream_chat", + "input": { "prompt": "hi" }, + "stream": true +} +``` + +**invoke(handler 回调)** +```json +{ + "type": "invoke", + "id": "msg_010", + "capability": "handler.invoke", + "input": { + "handler_id": "handler_abc123", + "event": { + "text": "/hello", + "user_id": "u_001", + "group_id": null, + "platform": "qq" + } + }, + "stream": false +} +``` +※ input.event 只含纯数据字段。ctx 由插件进程本地构建并注入,不经过线协议传输。 + +**result(成功)** +```json +{ + "type": "result", + "id": "msg_002", + "success": true, + "output": { "text": "你好!" } +} +``` + +**result(失败)** +```json +{ + "type": "result", + "id": "msg_002", + "success": false, + "error": { + "code": "llm_not_configured", + "message": "未找到可用的大模型配置", + "hint": "请在管理面板的「模型管理」中添加模型", + "retryable": false + } +} +``` + +**event 序列(stream=true 专用)** +```json +{ "type": "event", "id": "msg_003", "phase": "started" } +{ "type": "event", "id": "msg_003", "phase": "delta", "data": { "text": "你" } } +{ "type": "event", "id": "msg_003", "phase": "delta", "data": { "text": "好" } } +{ "type": "event", "id": "msg_003", "phase": "completed", "output": { "text": "你好" } } +``` + +**event(取消终止态)** +```json +{ + "type": "event", + "id": "msg_003", + "phase": "failed", + "error": { + "code": "cancelled", + "message": "调用被取消", + "hint": "", + "retryable": false + } +} +``` + +**cancel** +```json +{ + "type": "cancel", + "id": "msg_003", + "reason": "user_cancelled" +} +``` + +--- + +## 六、描述符定义 + +### HandlerDescriptor + +``` +HandlerDescriptor +{ + id: str 唯一标识,主进程回调时填入 handler_id + trigger: CommandTrigger + | MessageTrigger + | EventTrigger + | ScheduleTrigger + priority: int 默认 0,越大越先执行 + permissions: { + require_admin: bool + level: int + } +} +``` + +trigger 判别联合:不同 type 只允许对应字段出现,其他字段必须省略。 + +``` +CommandTrigger +{ + type: "command" + command: str 必填 + aliases: [str] 可选,默认 [] + description: str 可选 +} + +MessageTrigger +{ + type: "message" + regex: str | null 可选 + keywords: [str] 可选,默认 [] + platforms: [str] 可选,默认 [](空表示所有平台) +} + +EventTrigger +{ + type: "event" + event_type: str 必填 +} + +ScheduleTrigger +{ + type: "schedule" + cron: str | null + interval_seconds: int | null +} +规则:cron 和 interval_seconds 必须且只能有一个非 null +``` + +### CapabilityDescriptor + +主进程在 initialize_result.output.capabilities 中返回。 + +``` +CapabilityDescriptor +{ + name: str capability name,如 "llm.chat" + description: str 一句话说明 + input_schema: JSONSchema | null 输入结构定义 + output_schema: JSONSchema | null 输出结构定义 + supports_stream: bool 是否支持 stream=true 调用 + cancelable: bool 是否支持 cancel +} + +schema 治理规则: + 内建核心 capability(llm.* / db.* / memory.* / platform.*) + 必须提供 input_schema 和 output_schema + 兼容期或动态注册的 capability + 允许为 null,但应在路线图中补全 + 不得以"动态能力"为由长期保持 null +``` + +示例: +```json +{ + "name": "llm.chat", + "description": "发送对话请求,返回模型回复文本", + "input_schema": { + "type": "object", + "properties": { + "prompt": { "type": "string" }, + "system": { "type": "string" }, + "model": { "type": "string" }, + "temperature": { "type": "number" } + }, + "required": ["prompt"] + }, + "output_schema": { + "type": "object", + "properties": { + "text": { "type": "string" } + }, + "required": ["text"] + }, + "supports_stream": false, + "cancelable": false +} +``` + +--- + +## 七、Capability Name 约定 + +``` +格式:{namespace}.{method} + +内建 capability 列表: + llm.chat ctx.llm.chat() + llm.chat_raw ctx.llm.chat_raw() + llm.stream_chat ctx.llm.stream_chat() + memory.search ctx.memory.search() + memory.save ctx.memory.save() + memory.delete ctx.memory.delete() + db.get ctx.db.get() + db.set ctx.db.set() + db.delete ctx.db.delete() + db.list ctx.db.list() + platform.send ctx.platform.send() + platform.send_image ctx.platform.send_image() + platform.get_members ctx.platform.get_members() + +保留命名空间(插件不可使用这些前缀): + handler.* 框架内部:处理器回调 + system.* 框架内部:系统级操作 + internal.* 框架内部:保留扩展 + +命名规则: + 全小写,点分隔命名空间,下划线分隔单词,不用驼峰 + capability name 是协议约定,手写定义,不自动从方法名推导 + 方法名重构不影响协议;协议变更需同步更新方法名和文档 +``` + +--- + +## 八、错误模型 + +```python +@dataclass +class AstrBotError(Exception): + code: str # 机器可读,如 "llm_not_configured" + message: str # 发生了什么 + hint: str # 用户怎么修 + retryable: bool # True = 可重试(超时、网络抖动、临时不可用) + # False = 重试无意义(权限不足、能力不存在、配置缺失) +``` + +``` +可重试(retryable=true) 不可重试(retryable=false) +───────────────────────── ──────────────────────────── +CapabilityTimeout LLMNotConfigured +NetworkError CapabilityNotFound +LLMTemporaryError PermissionDenied + LLMError(模型返回错误) + InvalidInput + Cancelled + ProtocolVersionMismatch +``` + +**Star.on_error 默认兜底:** +``` +AstrBotError retryable=true → 回复"请求失败,请稍后重试" +AstrBotError retryable=false → 回复 error.hint +其他异常 → 回复"出了点问题,请联系插件作者" +所有情况均打完整 traceback 日志 +插件作者覆盖 on_error 可完全自定义 +``` + +--- + +## 九、Context 设计规则 + +```python +class Context: + + # 第一类:插件常用能力 Client(稳定,只扩展不删除) + llm: LLMClient + memory: MemoryClient + db: DBClient + platform: PlatformClient + + # 第二类:少量基础运行时信息 + plugin_id: str + logger: Logger # 自动带插件名前缀 + cancel_token: ... # 取消当前调用 + + # ❌ 不直接挂顶层: + # tools / runtime / scheduler / http / + # storage / persona / workflow / config + # 有需要时设计专属 Client 后再加 +``` + +--- + +## 十、LLM Client 分层 + +```python +class LLMClient: + + async def chat( + self, + prompt: str, + *, + system: str | None = None, + history: list[ChatMessage] | None = None, + model: str | None = None, + temperature: float | None = None, + ) -> str: + """发送对话请求,返回回复文本。爱好者场景首选。""" + + async def chat_raw( + self, + prompt: str, + **kwargs, + ) -> LLMResponse: + """返回完整响应,含 usage / finish_reason / tool_calls。""" + + async def stream_chat( + self, + prompt: str, + *, + system: str | None = None, + history: list[ChatMessage] | None = None, + ) -> AsyncGenerator[str, None]: + """流式对话,逐字返回文本片段。""" +``` + +chat() 和 chat_raw() 是唯二入口,不再增加第三种变体。 + +--- + +## 十一、关键数据流 + +### 11.1 插件加载与握手 + +``` +框架启动 + → loader.py 扫描目录,发现 Star 子类 + → 收集 __handlers__,转成 HandlerDescriptor 列表 + → Peer 发送 initialize { id: "msg_001", handlers: [...] } + → 主进程注册事件订阅 + → 主进程返回 initialize_result { id: "msg_001", + success: true, + capabilities: [...] } + → 插件 CapabilityProxy 缓存 capabilities + → 插件就绪 + +握手失败时: + → 主进程返回 initialize_result { success: false, error: {...} } + → 连接进入不可用状态 + → 插件进程关闭连接,打错误日志 + → 不发送任何 invoke +``` + +### 11.2 外部消息触发处理器 + +``` +外部用户发送 /hello + → 主进程 HandlerDispatcher 匹配订阅 + → 主进程发送 invoke { + id: "msg_010", + capability: "handler.invoke", + input: { + handler_id: "handler_abc", + event: { text: "/hello", user_id: "u_001", ... } + }, + stream: false + } + → 插件 handler_dispatcher 找到处理器方法 + → 本地构建 ctx,注入 event 和 ctx,执行处理器 + → 处理器内调用 ctx.llm.chat()(进入 11.3) +``` + +注:ctx 在插件进程本地构建,不经过线协议传输。 + +### 11.3 非流式能力调用 + +``` +ctx.llm.chat("hi") + → CapabilityProxy 构造 input + → Peer.invoke("llm.chat", {prompt:"hi"}, stream=false) id="msg_020" + → 发送 { type:"invoke", id:"msg_020", capability:"llm.chat", + input:{...}, stream:false } + ← 收到 { type:"result", id:"msg_020", success:true, + output:{text:"你好"} } + → 解包 output.text → 返回 str + +※ stream=false 不会收到任何 event 消息 +``` + +### 11.4 流式能力调用 + +``` +async for chunk in ctx.llm.stream_chat("hi"): + → Peer.invoke("llm.stream_chat", {...}, stream=true) id="msg_030" + ← event { id:"msg_030", phase:"started" } + ← event { id:"msg_030", phase:"delta", data:{text:"你"} } → yield "你" + ← event { id:"msg_030", phase:"delta", data:{text:"好"} } → yield "好" + ← event { id:"msg_030", phase:"completed", output:{text:"你好"} } + → 生成器结束 + +※ stream=true 不会收到 result 消息,只收到 event 序列 +``` + +--- + +## 十二、兼容层 + +``` +compat.py 三条铁律: + 1. 新代码不 import compat.py + 2. compat.py 只 import 新代码 + 3. compat.py 里只有"转发",无业务逻辑 + +旧 API 映射: + +旧写法 新写法 +────────────────────────────────────────────────────────────── +CommandComponent → Star +context.llm_generate(prompt) → ctx.llm.chat(prompt) +context.tool_loop_agent(...) → ctx.llm.chat_raw(...) 含 tools +context.send_message(session, mc) → ctx.platform.send(session, text) +context.put_kv_data(key, value) → ctx.db.set(key, value) +context.get_kv_data(key) → ctx.db.get(key) +@filter.command("hello") → @on_command("hello") +@filter.regex("pattern") → @on_message(regex="pattern") +@filter.permission(ADMIN) → @require_admin +yield event.plain_result("hi") → await event.reply("hi") + +deprecated warning 格式(每个方法只打一次): + [AstrBot] 警告:context.llm_generate() 已过时。 + 请替换为:ctx.llm.chat(prompt) + 迁移文档:https://docs.astrbot.app/migration/v3 + +流式兼容: + 旧 yield 写法只在 compat 层兜底处理 + 新 API 只推荐 AsyncGenerator,不双轨并行 + +legacy_adapter.py 职责边界: + 只翻译旧线协议消息 ↔ 新线协议消息 + 不含业务逻辑,不被新代码 import + 生命周期结束时整个删掉,新代码零修改 +``` + +--- + +## 十三、迁移计划 + +``` +阶段 0:立骨架(当前可开始) +────────────────────────────────────────────────────── + 做什么: + ✦ 新建 star / context / decorators / events / errors / clients + ✦ protocol/descriptors.py 写清 HandlerDescriptor(判别联合) + 和 CapabilityDescriptor(含 schema 治理规则) + ✦ Peer 用 mock 占位(invoke 返回假数据) + ✦ 写 compat.py + + 验收: + ✦ 旧插件加载不报错 + ✦ 新写法能跑通基本流程 + ✦ IDE 对 ctx.llm / ctx.db 有完整补全 + + +阶段 1:接通信层 +────────────────────────────────────────────────────── + 做什么: + ✦ 实现 Transport / Peer(统一 id 字段) + ✦ 实现 capability_router + handler_dispatcher + ✦ 实现 legacy_adapter(旧协议翻译) + ✦ clients/ 接上真实 capability 调用 + ✦ 实现 cancel 语义(请求停止,等终止态) + ✦ 实现 initialize 失败处理(连接不可用 + 关闭) + + 验收: + ✦ 端到端调用成功 + ✦ 流式响应正常,stream=false 不出现 event 消息 + ✦ initialize 失败时连接正确关闭 + ✦ retryable 错误触发自动提示,不可重试触发 hint + + +阶段 2:清理旧实现 +────────────────────────────────────────────────────── + 做什么: + ✦ 删除 api/star/context.py(旧 Context) + ✦ 删除 runtime/rpc/ 旧角色划分 + ✦ 删除 runtime/stars/filter/ 旧装饰器实现 + ✦ deprecated warning 升级为更显眼提示 + + 验收: + ✦ 旧插件仍通过 compat.py 运行 + ✦ 核心路径无旧抽象引用 + + +阶段 3:废弃旧 API(下一大版本) +────────────────────────────────────────────────────── + 做什么: + ✦ deprecated warning 变启动报错 + ✦ 生态迁移完成后删除 compat.py 和 legacy_adapter.py + + 验收: + ✦ 删除 compat.py 后新代码零修改 +``` + +--- + +## 十四、设计决策记录 + +| 问题 | 决策 | 理由 | +|------|------|------| +| 关联字段用什么名 | 统一 `id` | 防止 request_id / invocation_id 在 initialize_result 处产生歧义 | +| event 能用于非流式吗 | 不能,硬规则 | 防止"非流式先发 started 再发 result"污染处理逻辑 | +| initialize 失败后能继续发 invoke 吗 | 不能,连接进入不可用状态 | 防止在无效连接上堆积调用 | +| handler 回调走什么机制 | handler.invoke,不新增消息类型 | 协议保持五种消息 | +| ctx 从哪来 | 插件进程本地构建,不经线协议 | ctx 含运行时状态不可序列化,且无需传输 | +| HandlerDescriptor trigger 结构 | 判别联合 | 防止大量可空字段,方便校验和类型推导 | +| CapabilityDescriptor schema 是否可为 null | 可以,但内建 capability 必须提供 | 防止所有人偷懒填 null 导致 schema 形同虚设 | +| 保留命名空间 | handler.* / system.* / internal.* | 集中声明,防止插件误用或冲突 | +| 错误模型 | code + message + hint + retryable | retryable 区分策略差异,hint 直接告诉用户怎么修 | +| cancel 语义 | 请求停止,等终止态 | 避免实现侧歧义,调用方行为确定 | +| compat 定位 | 旁路入口,不是核心层 | 新代码不感知,可整体删除 | +| Context 扩展规则 | 只放常用能力 Client + 少量运行时信息 | 防止变成圣诞树 | +| chat() 返回类型 | str;进阶用 chat_raw() | 爱好者不拆包装,进阶有专用入口,两个定死 | +| 序列化 | 默认 JSON,不用 pickle | 跨语言,安全,可观测 | + +--- + +*本文档是代码的源头。Python SDK、通信层、主进程三端有分歧时,以本文档为准。* + +*v4 修正:补充 event 只用于 stream=true 的硬规则;initialize 失败场景和连接不可用状态;ctx 不经线协议传输的明确说明;CapabilityDescriptor schema 治理规则;保留命名空间集中声明(handler.* / system.* / internal.*);initialize_result 失败示例。* +# AstrBot SDK 重构架构设计 v4 + +--- + +## 一、全局架构图 + +``` +╔══════════════════════════════════════════════════════════════════════╗ +║ 插件作者的世界 ║ +║ ║ +║ class MyPlugin(Star): ║ +║ @on_command("hello") ║ +║ async def hello(self, event: MessageEvent, ctx: Context): ║ +║ reply = await ctx.llm.chat(event.text) ║ +║ await event.reply(reply) ║ +║ ║ +╚══════════════════════════════════════════════════════════════════════╝ + │ Star / 装饰器 / Event │ Context / Clients + ▼ ▼ +┌─────────────────────┐ ┌────────────────────────────────┐ +│ Handler 系统 │ │ Capability 调用系统 │ +│ │ │ │ +│ HandlerDescriptor │ │ ctx.llm.chat() │ +│ HandlerDispatcher │ │ ctx.memory.search() │ +│ │ │ ctx.db.get() │ +│ 插件 → 主进程 │ │ ctx.platform.send() │ +│ "我能响应这些事件" │ │ │ +│ │ │ 插件 → 主进程 │ +│ │ │ "帮我调用这个能力" │ +└──────────┬──────────┘ └──────────────┬─────────────────┘ + │ │ + └─────────────────┬────────────────┘ + ▼ +┌──────────────────────────────────────────────────────────────────┐ +│ 通信层 │ +│ │ +│ 所有消息统一使用 id 字段关联请求与响应 │ +│ │ +│ Peer.initialize(handlers=[...]) │ +│ Peer.invoke("llm.chat", input, stream=false) → result │ +│ Peer.invoke("llm.stream_chat", input, stream=true) → event* │ +│ Peer.invoke("handler.invoke", {handler_id, event}) │ +│ │ +│ Transport: StdioTransport / WebSocketTransport │ +└──────────────────────────────────────────────────────────────────┘ + │ JSON 消息流 + ▼ +┌──────────────────────────────────────────────────────────────────┐ +│ 主进程(AstrBot Core) │ +│ │ +│ CapabilityRouter ──► "llm.chat" ──► LLM Service │ +│ ──► "db.get" ──► Storage │ +│ ──► "handler.invoke" ──► 转发给插件 │ +│ │ +│ HandlerDispatcher ◄── 外部消息 ──► 匹配订阅 ──► 回调插件 │ +└──────────────────────────────────────────────────────────────────┘ + + ┌─────────────────────┐ + │ compat.py(旁路) │ ← 不是核心层 + │ 旧 API → 转发新 API │ 新代码不感知它 + └─────────────────────┘ +``` + +--- + +## 二、两个核心概念的区分 + +``` +┌──────────────────────────────────────────────────────────────┐ +│ HandlerDescriptor │ +│ 方向:插件 ──► 主进程(initialize 时声明) │ +│ 含义:插件订阅"我能响应哪些事件" │ +│ 例子:@on_command("hello") → 订阅 /hello 命令 │ +└──────────────────────────────────────────────────────────────┘ + +┌──────────────────────────────────────────────────────────────┐ +│ CapabilityInvocation │ +│ 方向:插件 ──► 主进程(运行时按需调用) │ +│ 含义:插件请求"帮我执行这个能力" │ +│ 例子:ctx.llm.chat() → invoke "llm.chat" │ +└──────────────────────────────────────────────────────────────┘ + +┌──────────────────────────────────────────────────────────────┐ +│ CapabilityDescriptor │ +│ 方向:主进程 ──► 插件(initialize_result 时返回) │ +│ 含义:主进程声明"我提供哪些能力" │ +│ 例子:{ name: "llm.chat", supports_stream: false, ... } │ +└──────────────────────────────────────────────────────────────┘ +``` + +| | HandlerDescriptor | CapabilityDescriptor | CapabilityInvocation | +|---|---|---|---| +| 谁发 | 插件 | 主进程 | 插件 | +| 何时 | initialize 时 | initialize_result 时 | 运行时 | +| 主进程动作 | 注册订阅 | 告知可用能力 | 执行并返回结果 | + +--- + +## 三、分层职责 + +``` +┌─────────────────────────────────────────────────────┐ +│ Layer 1:用户层 │ +│ Star / 装饰器 / MessageEvent │ +│ 插件作者只接触这一层 │ +│ 不知道:RPC、进程、序列化、订阅协议 │ +└──────────────────────────┬──────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────────────────┐ +│ Layer 2:API 层 │ +│ Context / LLMClient / DBClient / MemoryClient │ +│ PlatformClient │ +│ 把能力包装成类型化 API │ +│ 不知道:JSON 格式、id、transport │ +└──────────────────────────┬──────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────────────────┐ +│ Layer 3:翻译层 │ +│ CapabilityProxy │ +│ API 调用 → Peer.invoke(name, input, stream) │ +│ output dict → 返回类型 │ +│ 无业务逻辑,一一对应 │ +└──────────────────────────┬──────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────────────────┐ +│ Layer 4:通信层 │ +│ Peer / Transport / Protocol Messages │ +│ 可靠收发消息 │ +│ 不知道业务,只知道消息格式 │ +└─────────────────────────────────────────────────────┘ + + ※ compat.py 不是第五层,是用户层和 API 层的旁路入口。 + 新代码不 import 它,可整体删除。 +``` + +--- + +## 四、目录结构 + +``` +astrbot_sdk/ +│ +├── star.py +├── context.py +├── decorators.py +├── events.py +├── errors.py +├── compat.py ← 旁路,不是核心层 +│ +├── clients/ +│ ├── llm.py +│ ├── memory.py +│ ├── db.py +│ └── platform.py +│ +├── runtime/ +│ ├── peer.py +│ ├── transport.py +│ ├── capability_router.py +│ ├── handler_dispatcher.py +│ ├── loader.py +│ └── bootstrap.py +│ +└── protocol/ + ├── messages.py + ├── descriptors.py + └── legacy_adapter.py +``` + +--- + +## 五、协议消息定义(完整版) + +### 五条硬规则 + +**规则一:统一使用 `id` 字段关联所有请求与响应** +``` +所有消息只用一个关联字段:id +不区分 request_id / invocation_id,全部统一成 id。 +发送方生成 id,接收方响应时原样带回,双方按 id 配对。 +``` + +**规则二:event 只用于 stream=true 的调用** +``` +stream=false 的调用只能以单个 result 结束。 +stream=true 的调用只能以 event 序列结束。 +stream=false 的调用不得发送 event(started/delta/completed/failed)。 +违反此规则的实现视为协议错误。 +``` + +**规则三:插件 handler 回调走统一 invoke,不新增消息类型** +``` +主进程触发插件处理器时: + capability: "handler.invoke" + input: { handler_id: str, event: { 纯数据 } } + +ctx 不通过线协议传输。 +ctx 由插件进程本地重建并注入处理器。 +看到处理器签名有 ctx 参数,不要误以为需要从主进程发过来。 +``` + +**规则四:cancel 是"请求停止",不是"立即停止"** +``` +收到 cancel 后: + 若调用已结束 → 忽略,不报错 + 若调用仍在执行 → 尽力中断,发送统一终止态 + +统一终止态: + stream=true: event { phase: "failed", error: { code: "cancelled" } } + stream=false: result { success: false, error: { code: "cancelled" } } + +调用方收到 cancel 后必须等待终止态,不能认为发完 cancel 就已结束。 +``` + +**规则五:initialize 失败后连接进入不可用状态** +``` +initialize 失败(协议版本不兼容 / handlers 非法 / 元信息缺失)时: + 返回 result { kind: "initialize_result", success: false, error: {...} } + 连接进入不可用状态 + 除关闭连接外,不得继续发送普通 invoke + 对端收到失败的 initialize_result 后应立即关闭连接 +``` + +--- + +### 消息格式 + +**initialize** +```json +{ + "type": "initialize", + "id": "msg_001", + "protocol_version": "1.0", + "peer": { + "name": "my-plugin", + "role": "plugin", + "version": "1.2.0" + }, + "handlers": [ "HandlerDescriptor ..." ], + "metadata": {} +} +``` + +**initialize_result(成功)** +```json +{ + "type": "result", + "id": "msg_001", + "kind": "initialize_result", + "success": true, + "output": { + "peer": { "name": "astrbot-core", "role": "core" }, + "capabilities": [ "CapabilityDescriptor ..." ], + "metadata": {} + } +} +``` + +**initialize_result(失败)** +```json +{ + "type": "result", + "id": "msg_001", + "kind": "initialize_result", + "success": false, + "error": { + "code": "protocol_version_mismatch", + "message": "服务端支持协议版本 1.0,客户端请求版本 2.0", + "hint": "请升级 astrbot_sdk 至最新版本", + "retryable": false + } +} +``` +※ 失败后连接进入不可用状态,对端应立即关闭连接。 + +**invoke(普通能力)** +```json +{ + "type": "invoke", + "id": "msg_002", + "capability": "llm.chat", + "input": { "prompt": "hi", "system": null }, + "stream": false +} +``` + +**invoke(流式能力)** +```json +{ + "type": "invoke", + "id": "msg_003", + "capability": "llm.stream_chat", + "input": { "prompt": "hi" }, + "stream": true +} +``` + +**invoke(handler 回调)** +```json +{ + "type": "invoke", + "id": "msg_010", + "capability": "handler.invoke", + "input": { + "handler_id": "handler_abc123", + "event": { + "text": "/hello", + "user_id": "u_001", + "group_id": null, + "platform": "qq" + } + }, + "stream": false +} +``` +※ input.event 只含纯数据字段。ctx 由插件进程本地构建并注入,不经过线协议传输。 + +**result(成功)** +```json +{ + "type": "result", + "id": "msg_002", + "success": true, + "output": { "text": "你好!" } +} +``` + +**result(失败)** +```json +{ + "type": "result", + "id": "msg_002", + "success": false, + "error": { + "code": "llm_not_configured", + "message": "未找到可用的大模型配置", + "hint": "请在管理面板的「模型管理」中添加模型", + "retryable": false + } +} +``` + +**event 序列(stream=true 专用)** +```json +{ "type": "event", "id": "msg_003", "phase": "started" } +{ "type": "event", "id": "msg_003", "phase": "delta", "data": { "text": "你" } } +{ "type": "event", "id": "msg_003", "phase": "delta", "data": { "text": "好" } } +{ "type": "event", "id": "msg_003", "phase": "completed", "output": { "text": "你好" } } +``` + +**event(取消终止态)** +```json +{ + "type": "event", + "id": "msg_003", + "phase": "failed", + "error": { + "code": "cancelled", + "message": "调用被取消", + "hint": "", + "retryable": false + } +} +``` + +**cancel** +```json +{ + "type": "cancel", + "id": "msg_003", + "reason": "user_cancelled" +} +``` + +--- + +## 六、描述符定义 + +### HandlerDescriptor + +``` +HandlerDescriptor +{ + id: str + trigger: CommandTrigger + | MessageTrigger + | EventTrigger + | ScheduleTrigger + priority: int + permissions: { + require_admin: bool + level: int + } +} +``` + +trigger 判别联合:不同 type 只允许对应字段出现,其他字段必须省略。 + +``` +CommandTrigger +{ + type: "command" + command: str + aliases: [str] + description: str +} + +MessageTrigger +{ + type: "message" + regex: str | null + keywords: [str] + platforms: [str] +} + +EventTrigger +{ + type: "event" + event_type: str +} + +ScheduleTrigger +{ + type: "schedule" + cron: str | null + interval_seconds: int | null +} +``` + +### CapabilityDescriptor + +``` +CapabilityDescriptor +{ + name: str + description: str + input_schema: JSONSchema | null + output_schema: JSONSchema | null + supports_stream: bool + cancelable: bool +} +``` + +schema 治理规则: +``` +内建核心 capability(llm.* / db.* / memory.* / platform.*)必须提供 input_schema 和 output_schema +兼容期或动态注册的 capability 允许为 null,但应在路线图中补全 +不得以“动态能力”为由长期保持 null +``` + +--- + +## 七、Capability Name 约定 + +``` +格式:{namespace}.{method} + +内建 capability 列表: + llm.chat + llm.chat_raw + llm.stream_chat + memory.search + memory.save + memory.delete + db.get + db.set + db.delete + db.list + platform.send + platform.send_image + platform.get_members + +保留命名空间: + handler.* + system.* + internal.* +``` + +--- + +## 八、错误模型 + +```python +@dataclass +class AstrBotError(Exception): + code: str + message: str + hint: str + retryable: bool +``` + +--- + +## 九、Context 设计规则 + +```python +class Context: + llm: LLMClient + memory: MemoryClient + db: DBClient + platform: PlatformClient + plugin_id: str + logger: Logger + cancel_token: ... +``` + +--- + +## 十、LLM Client 分层 + +```python +class LLMClient: + async def chat(...) -> str: ... + async def chat_raw(...) -> LLMResponse: ... + async def stream_chat(...) -> AsyncGenerator[str, None]: ... +``` + +--- + +## 十一、关键数据流 + +- 插件启动后发送 `initialize` +- 主进程返回 `initialize_result` +- 外部消息触发 `handler.invoke` +- 非流式能力调用只返回 `result` +- 流式能力调用只返回 `event` 序列 + +--- + +## 十二、兼容层 + +``` +compat.py 三条铁律: + 1. 新代码不 import compat.py + 2. compat.py 只 import 新代码 + 3. compat.py 里只有转发,无业务逻辑 +``` + +--- + +## 十三、迁移计划 + +- 阶段 0:立骨架 +- 阶段 1:接通信层 +- 阶段 2:清理旧实现 +- 阶段 3:废弃旧 API + +--- + +## 十四、设计决策记录 + +- 统一 `id` +- `event` 只用于 `stream=true` +- initialize 失败后连接不可用 +- handler 回调走 `handler.invoke` +- ctx 在插件进程本地构建 +- HandlerDescriptor 使用判别联合 +- 内建 capability schema 必填 +- 保留命名空间 `handler.* / system.* / internal.*` +- 错误模型固定为 `code + message + hint + retryable` +- cancel 为“请求停止,等待终止态” +- compat 是旁路,不是核心层 +- Context 只挂常用 client 与少量运行时信息 +- `chat()` 返回 `str`,进阶使用 `chat_raw()` +- 序列化使用 JSON,不用 pickle + +--- + +*本文档是代码的源头。Python SDK、通信层、主进程三端有分歧时,以本文档为准。* diff --git a/src-new.rar b/src-new.rar new file mode 100644 index 0000000000000000000000000000000000000000..f9dc50ca23372e16f63fd6c056b9c3841d495214 GIT binary patch literal 100560 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 F{tr5mwu=A& literal 0 HcmV?d00001 diff --git a/src-new/astrbot_sdk/__init__.py b/src-new/astrbot_sdk/__init__.py new file mode 100644 index 000000000..b9e2d98df --- /dev/null +++ b/src-new/astrbot_sdk/__init__.py @@ -0,0 +1,17 @@ +from .context import Context +from .decorators import on_command, on_event, on_message, on_schedule, require_admin +from .errors import AstrBotError +from .events import MessageEvent +from .star import Star + +__all__ = [ + "AstrBotError", + "Context", + "MessageEvent", + "Star", + "on_command", + "on_event", + "on_message", + "on_schedule", + "require_admin", +] diff --git a/src-new/astrbot_sdk/__main__.py b/src-new/astrbot_sdk/__main__.py new file mode 100644 index 000000000..491a4d136 --- /dev/null +++ b/src-new/astrbot_sdk/__main__.py @@ -0,0 +1,5 @@ +from .cli import cli + + +if __name__ == "__main__": + cli() diff --git a/src-new/astrbot_sdk/api/__init__.py b/src-new/astrbot_sdk/api/__init__.py new file mode 100644 index 000000000..c9c2ef67b --- /dev/null +++ b/src-new/astrbot_sdk/api/__init__.py @@ -0,0 +1 @@ +__all__: list[str] = [] diff --git a/src-new/astrbot_sdk/api/components/__init__.py b/src-new/astrbot_sdk/api/components/__init__.py new file mode 100644 index 000000000..9d075b559 --- /dev/null +++ b/src-new/astrbot_sdk/api/components/__init__.py @@ -0,0 +1,3 @@ +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 new file mode 100644 index 000000000..487fa288b --- /dev/null +++ b/src-new/astrbot_sdk/api/components/command.py @@ -0,0 +1,3 @@ +from ...compat import CommandComponent + +__all__ = ["CommandComponent"] diff --git a/src-new/astrbot_sdk/api/event/__init__.py b/src-new/astrbot_sdk/api/event/__init__.py new file mode 100644 index 000000000..611042749 --- /dev/null +++ b/src-new/astrbot_sdk/api/event/__init__.py @@ -0,0 +1,4 @@ +from ...events import MessageEvent as AstrMessageEvent +from .filter import ADMIN, filter + +__all__ = ["ADMIN", "AstrMessageEvent", "filter"] diff --git a/src-new/astrbot_sdk/api/event/filter.py b/src-new/astrbot_sdk/api/event/filter.py new file mode 100644 index 000000000..dc6b70bf2 --- /dev/null +++ b/src-new/astrbot_sdk/api/event/filter.py @@ -0,0 +1,32 @@ +from __future__ import annotations + +from ...decorators import on_command, on_message, require_admin + +ADMIN = "admin" + + +def command(name: str): + return on_command(name) + + +def regex(pattern: str): + return on_message(regex=pattern) + + +def permission(level): + if level == ADMIN: + return require_admin + + def decorator(func): + return func + + return decorator + + +class _FilterNamespace: + command = staticmethod(command) + regex = staticmethod(regex) + permission = staticmethod(permission) + + +filter = _FilterNamespace() diff --git a/src-new/astrbot_sdk/api/star/__init__.py b/src-new/astrbot_sdk/api/star/__init__.py new file mode 100644 index 000000000..43a016780 --- /dev/null +++ b/src-new/astrbot_sdk/api/star/__init__.py @@ -0,0 +1,3 @@ +from .context import Context + +__all__ = ["Context"] diff --git a/src-new/astrbot_sdk/api/star/context.py b/src-new/astrbot_sdk/api/star/context.py new file mode 100644 index 000000000..4b29df5c9 --- /dev/null +++ b/src-new/astrbot_sdk/api/star/context.py @@ -0,0 +1,3 @@ +from ...compat import Context + +__all__ = ["Context"] diff --git a/src-new/astrbot_sdk/cli.py b/src-new/astrbot_sdk/cli.py new file mode 100644 index 000000000..01748a64d --- /dev/null +++ b/src-new/astrbot_sdk/cli.py @@ -0,0 +1,56 @@ +from __future__ import annotations + +import asyncio +import sys +from pathlib import Path + +import click +from loguru import logger + +from .runtime.bootstrap import run_plugin_worker, run_supervisor, run_websocket_server + + +def setup_logger(verbose: bool = False) -> None: + logger.remove() + logger.add( + sys.stderr, + format="{time:HH:mm:ss} | {level: <8} | {message}", + level="DEBUG" if verbose else "INFO", + colorize=True, + ) + + +@click.group() +@click.option("-v", "--verbose", is_flag=True, help="Enable verbose output") +@click.pass_context +def cli(ctx, verbose: bool) -> None: + ctx.ensure_object(dict) + ctx.obj["verbose"] = verbose + setup_logger(verbose) + + +@cli.command() +@click.option( + "--plugins-dir", + default="plugins", + type=click.Path(file_okay=False, dir_okay=True, path_type=Path), + help="Directory containing plugin folders", +) +def run(plugins_dir: Path) -> None: + asyncio.run(run_supervisor(plugins_dir=plugins_dir)) + + +@cli.command(hidden=True) +@click.option( + "--plugin-dir", + required=True, + type=click.Path(file_okay=False, dir_okay=True, path_type=Path), +) +def worker(plugin_dir: Path) -> None: + asyncio.run(run_plugin_worker(plugin_dir=plugin_dir)) + + +@cli.command(hidden=True) +@click.option("--port", default=8765, type=int) +def websocket(port: int) -> None: + asyncio.run(run_websocket_server(port=port)) diff --git a/src-new/astrbot_sdk/clients/__init__.py b/src-new/astrbot_sdk/clients/__init__.py new file mode 100644 index 000000000..0d66078df --- /dev/null +++ b/src-new/astrbot_sdk/clients/__init__.py @@ -0,0 +1,13 @@ +from .db import DBClient +from .llm import ChatMessage, LLMClient, LLMResponse +from .memory import MemoryClient +from .platform import PlatformClient + +__all__ = [ + "ChatMessage", + "DBClient", + "LLMClient", + "LLMResponse", + "MemoryClient", + "PlatformClient", +] diff --git a/src-new/astrbot_sdk/clients/_proxy.py b/src-new/astrbot_sdk/clients/_proxy.py new file mode 100644 index 000000000..3c3f58531 --- /dev/null +++ b/src-new/astrbot_sdk/clients/_proxy.py @@ -0,0 +1,40 @@ +from __future__ import annotations + +from collections.abc import AsyncIterator +from typing import Any + +from ..errors import AstrBotError + + +class CapabilityProxy: + def __init__(self, peer) -> None: + self._peer = peer + + def _get_descriptor(self, name: str): + return self._peer.remote_capability_map.get(name) + + def _ensure_available(self, name: str, *, stream: bool) -> None: + descriptor = self._get_descriptor(name) + if descriptor is None: + if self._peer.remote_capability_map: + raise AstrBotError.capability_not_found(name) + return + if stream and not descriptor.supports_stream: + raise AstrBotError.invalid_input(f"{name} 不支持 stream=true") + if not stream and descriptor.supports_stream is False: + return + + async def call(self, name: str, payload: dict[str, Any]) -> dict[str, Any]: + self._ensure_available(name, stream=False) + return await self._peer.invoke(name, payload, stream=False) + + async def stream( + self, + name: str, + payload: dict[str, Any], + ) -> AsyncIterator[dict[str, Any]]: + self._ensure_available(name, stream=True) + event_stream = await self._peer.invoke_stream(name, payload) + async for event in event_stream: + if event.phase == "delta": + yield event.data diff --git a/src-new/astrbot_sdk/clients/db.py b/src-new/astrbot_sdk/clients/db.py new file mode 100644 index 000000000..98e85a967 --- /dev/null +++ b/src-new/astrbot_sdk/clients/db.py @@ -0,0 +1,25 @@ +from __future__ import annotations + +from typing import Any + +from ._proxy import CapabilityProxy + + +class DBClient: + def __init__(self, proxy: CapabilityProxy) -> None: + self._proxy = proxy + + async def get(self, key: str) -> dict[str, Any] | None: + output = await self._proxy.call("db.get", {"key": key}) + value = output.get("value") + return value if isinstance(value, dict) else None + + async def set(self, key: str, value: dict[str, Any]) -> None: + await self._proxy.call("db.set", {"key": key, "value": value}) + + async def delete(self, key: str) -> None: + await self._proxy.call("db.delete", {"key": key}) + + async def list(self, prefix: str | None = None) -> list[str]: + output = await self._proxy.call("db.list", {"prefix": prefix}) + return [str(item) for item in output.get("keys", [])] diff --git a/src-new/astrbot_sdk/clients/llm.py b/src-new/astrbot_sdk/clients/llm.py new file mode 100644 index 000000000..ac20c3487 --- /dev/null +++ b/src-new/astrbot_sdk/clients/llm.py @@ -0,0 +1,77 @@ +from __future__ import annotations + +from collections.abc import AsyncGenerator +from typing import Any + +from pydantic import BaseModel, Field + +from ._proxy import CapabilityProxy + + +class ChatMessage(BaseModel): + role: str + content: str + + +class LLMResponse(BaseModel): + text: str + usage: dict[str, Any] | None = None + finish_reason: str | None = None + tool_calls: list[dict[str, Any]] = Field(default_factory=list) + + +class LLMClient: + def __init__(self, proxy: CapabilityProxy) -> None: + self._proxy = proxy + + async def chat( + self, + prompt: str, + *, + system: str | None = None, + history: list[ChatMessage] | None = None, + model: str | None = None, + temperature: float | None = None, + ) -> str: + output = await self._proxy.call( + "llm.chat", + { + "prompt": prompt, + "system": system, + "history": [item.model_dump() for item in history or []], + "model": model, + "temperature": temperature, + }, + ) + return str(output.get("text", "")) + + async def chat_raw( + self, + prompt: str, + **kwargs: Any, + ) -> LLMResponse: + output = await self._proxy.call( + "llm.chat_raw", + { + "prompt": prompt, + **kwargs, + }, + ) + return LLMResponse.model_validate(output) + + async def stream_chat( + self, + prompt: str, + *, + system: str | None = None, + history: list[ChatMessage] | None = None, + ) -> AsyncGenerator[str, None]: + async for data in self._proxy.stream( + "llm.stream_chat", + { + "prompt": prompt, + "system": system, + "history": [item.model_dump() for item in history or []], + }, + ): + yield str(data.get("text", "")) diff --git a/src-new/astrbot_sdk/clients/memory.py b/src-new/astrbot_sdk/clients/memory.py new file mode 100644 index 000000000..f8a5528b8 --- /dev/null +++ b/src-new/astrbot_sdk/clients/memory.py @@ -0,0 +1,20 @@ +from __future__ import annotations + +from typing import Any + +from ._proxy import CapabilityProxy + + +class MemoryClient: + def __init__(self, proxy: CapabilityProxy) -> None: + self._proxy = proxy + + async def search(self, query: str) -> list[dict[str, Any]]: + output = await self._proxy.call("memory.search", {"query": query}) + return list(output.get("items", [])) + + async def save(self, key: str, value: dict[str, Any]) -> None: + await self._proxy.call("memory.save", {"key": key, "value": value}) + + async def delete(self, key: str) -> None: + await self._proxy.call("memory.delete", {"key": key}) diff --git a/src-new/astrbot_sdk/clients/platform.py b/src-new/astrbot_sdk/clients/platform.py new file mode 100644 index 000000000..d5c5b2b75 --- /dev/null +++ b/src-new/astrbot_sdk/clients/platform.py @@ -0,0 +1,26 @@ +from __future__ import annotations + +from typing import Any + +from ._proxy import CapabilityProxy + + +class PlatformClient: + def __init__(self, proxy: CapabilityProxy) -> None: + self._proxy = proxy + + async def send(self, session: str, text: str) -> dict[str, Any]: + return await self._proxy.call( + "platform.send", + {"session": session, "text": text}, + ) + + async def send_image(self, session: str, image_url: str) -> dict[str, Any]: + return await self._proxy.call( + "platform.send_image", + {"session": session, "image_url": image_url}, + ) + + async def get_members(self, session: str) -> list[dict[str, Any]]: + output = await self._proxy.call("platform.get_members", {"session": session}) + return list(output.get("members", [])) diff --git a/src-new/astrbot_sdk/compat.py b/src-new/astrbot_sdk/compat.py new file mode 100644 index 000000000..3d70f3141 --- /dev/null +++ b/src-new/astrbot_sdk/compat.py @@ -0,0 +1,143 @@ +from __future__ import annotations + +from collections import defaultdict +from typing import Any + +from loguru import logger + +from .clients.llm import LLMResponse +from .context import Context as NewContext + +_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] 警告:{} 已过时。请替换为:{}", + old_name, + replacement, + ) + + +class LegacyConversationManager: + def __init__(self, parent: "LegacyContext") -> None: + self._parent = parent + self._counters: defaultdict[str, int] = defaultdict(int) + + def _ctx(self) -> NewContext: + return self._parent.require_runtime_context() + + 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: + ctx = self._ctx() + self._counters[unified_msg_origin] += 1 + conversation_id = f"{ctx.plugin_id}-conv-{self._counters[unified_msg_origin]}" + stored = await ctx.db.get("__compat_conversations__") or {} + stored[conversation_id] = { + "unified_msg_origin": unified_msg_origin, + "platform_id": platform_id, + "content": content or [], + "title": title, + "persona_id": persona_id, + } + await ctx.db.set("__compat_conversations__", stored) + return conversation_id + + +class LegacyContext: + def __init__(self, plugin_id: str) -> None: + self.plugin_id = plugin_id + self._runtime_context: NewContext | None = None + self.conversation_manager = LegacyConversationManager(self) + + 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 + + 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, + **kwargs: Any, + ) -> LLMResponse: + _warn_once("context.llm_generate()", "ctx.llm.chat(prompt)") + ctx = self.require_runtime_context() + return await ctx.llm.chat_raw( + prompt or "", + system=system_prompt, + history=contexts or [], + image_urls=image_urls or [], + tools=tools, + **kwargs, + ) + + 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, + **kwargs: Any, + ) -> LLMResponse: + _warn_once("context.tool_loop_agent()", "ctx.llm.chat_raw(...)") + ctx = self.require_runtime_context() + return await ctx.llm.chat_raw( + prompt or "", + system=system_prompt, + history=contexts or [], + image_urls=image_urls or [], + tools=tools, + max_steps=max_steps, + **kwargs, + ) + + async def send_message(self, session: str, message_chain: Any) -> None: + _warn_once("context.send_message()", "ctx.platform.send(session, text)") + ctx = self.require_runtime_context() + await ctx.platform.send(session, str(message_chain)) + + async def add_llm_tools(self, *tools: Any) -> None: + _warn_once("context.add_llm_tools()", "ctx.llm.chat_raw(..., tools=...)") + return None + + async def put_kv_data(self, key: str, value: dict[str, 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) -> dict[str, Any] | None: + _warn_once("context.get_kv_data()", "ctx.db.get(key)") + ctx = self.require_runtime_context() + return await ctx.db.get(key) + + 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) + + +class CommandComponent: + pass + + +Context = LegacyContext diff --git a/src-new/astrbot_sdk/context.py b/src-new/astrbot_sdk/context.py new file mode 100644 index 000000000..216caec23 --- /dev/null +++ b/src-new/astrbot_sdk/context.py @@ -0,0 +1,51 @@ +from __future__ import annotations + +import asyncio +from dataclasses import dataclass +from typing import Any + +from loguru import logger as base_logger + +from .clients import DBClient, LLMClient, MemoryClient, PlatformClient +from .clients._proxy import CapabilityProxy + + +@dataclass(slots=True) +class CancelToken: + _cancelled: asyncio.Event + + def __init__(self) -> None: + self._cancelled = asyncio.Event() + + def cancel(self) -> None: + self._cancelled.set() + + @property + def cancelled(self) -> bool: + return self._cancelled.is_set() + + async def wait(self) -> None: + await self._cancelled.wait() + + def raise_if_cancelled(self) -> None: + if self.cancelled: + raise asyncio.CancelledError + + +class Context: + def __init__( + self, + *, + peer, + plugin_id: str, + cancel_token: CancelToken | None = None, + logger: Any | None = None, + ) -> None: + proxy = CapabilityProxy(peer) + self.llm = LLMClient(proxy) + self.memory = MemoryClient(proxy) + self.db = DBClient(proxy) + self.platform = PlatformClient(proxy) + self.plugin_id = plugin_id + self.logger = logger or base_logger.bind(plugin_id=plugin_id) + self.cancel_token = cancel_token or CancelToken() diff --git a/src-new/astrbot_sdk/decorators.py b/src-new/astrbot_sdk/decorators.py new file mode 100644 index 000000000..58e59f640 --- /dev/null +++ b/src-new/astrbot_sdk/decorators.py @@ -0,0 +1,98 @@ +from __future__ import annotations + +from dataclasses import dataclass, field +from typing import Any, Callable + +from .protocol.descriptors import ( + CommandTrigger, + EventTrigger, + MessageTrigger, + Permissions, + ScheduleTrigger, +) + +HandlerCallable = Callable[..., Any] +HANDLER_META_ATTR = "__astrbot_handler_meta__" + + +@dataclass(slots=True) +class HandlerMeta: + trigger: CommandTrigger | MessageTrigger | EventTrigger | ScheduleTrigger | None = None + priority: int = 0 + permissions: Permissions = field(default_factory=Permissions) + + +def _get_or_create_meta(func: HandlerCallable) -> HandlerMeta: + meta = getattr(func, HANDLER_META_ATTR, None) + if meta is None: + meta = HandlerMeta() + setattr(func, HANDLER_META_ATTR, meta) + return meta + + +def get_handler_meta(func: HandlerCallable) -> HandlerMeta | None: + return getattr(func, HANDLER_META_ATTR, None) + + +def on_command( + command: str, + *, + aliases: list[str] | None = None, + description: str | None = None, +) -> Callable[[HandlerCallable], HandlerCallable]: + def decorator(func: HandlerCallable) -> HandlerCallable: + meta = _get_or_create_meta(func) + meta.trigger = CommandTrigger( + command=command, + aliases=aliases or [], + description=description, + ) + return func + + return decorator + + +def on_message( + *, + regex: str | None = None, + keywords: list[str] | None = None, + platforms: list[str] | None = None, +) -> Callable[[HandlerCallable], HandlerCallable]: + def decorator(func: HandlerCallable) -> HandlerCallable: + meta = _get_or_create_meta(func) + meta.trigger = MessageTrigger( + regex=regex, + keywords=keywords or [], + platforms=platforms or [], + ) + return func + + return decorator + + +def on_event(event_type: str) -> Callable[[HandlerCallable], HandlerCallable]: + def decorator(func: HandlerCallable) -> HandlerCallable: + meta = _get_or_create_meta(func) + meta.trigger = EventTrigger(event_type=event_type) + return func + + return decorator + + +def on_schedule( + *, + cron: str | None = None, + interval_seconds: int | None = None, +) -> Callable[[HandlerCallable], HandlerCallable]: + def decorator(func: HandlerCallable) -> HandlerCallable: + meta = _get_or_create_meta(func) + meta.trigger = ScheduleTrigger(cron=cron, interval_seconds=interval_seconds) + return func + + return decorator + + +def require_admin(func: HandlerCallable) -> HandlerCallable: + meta = _get_or_create_meta(func) + meta.permissions.require_admin = True + return func diff --git a/src-new/astrbot_sdk/errors.py b/src-new/astrbot_sdk/errors.py new file mode 100644 index 000000000..a8629d722 --- /dev/null +++ b/src-new/astrbot_sdk/errors.py @@ -0,0 +1,85 @@ +from __future__ import annotations + +from dataclasses import dataclass + + +@dataclass(slots=True) +class AstrBotError(Exception): + code: str + message: str + hint: str = "" + retryable: bool = False + + def __str__(self) -> str: + return self.message + + @classmethod + def cancelled(cls, message: str = "调用被取消") -> "AstrBotError": + return cls( + code="cancelled", + message=message, + hint="", + retryable=False, + ) + + @classmethod + def capability_not_found(cls, name: str) -> "AstrBotError": + return cls( + code="capability_not_found", + message=f"未找到能力:{name}", + hint="请确认 AstrBot Core 是否已注册该 capability", + retryable=False, + ) + + @classmethod + def invalid_input(cls, message: str) -> "AstrBotError": + return cls( + code="invalid_input", + message=message, + hint="请检查调用参数", + retryable=False, + ) + + @classmethod + def protocol_version_mismatch(cls, message: str) -> "AstrBotError": + return cls( + code="protocol_version_mismatch", + message=message, + hint="请升级 astrbot_sdk 至最新版本", + retryable=False, + ) + + @classmethod + def protocol_error(cls, message: str) -> "AstrBotError": + return cls( + code="protocol_error", + message=message, + hint="请检查通信双方的协议实现", + retryable=False, + ) + + @classmethod + def internal_error(cls, message: str) -> "AstrBotError": + return cls( + code="internal_error", + message=message, + hint="请联系插件作者", + retryable=False, + ) + + def to_payload(self) -> dict[str, object]: + return { + "code": self.code, + "message": self.message, + "hint": self.hint, + "retryable": self.retryable, + } + + @classmethod + def from_payload(cls, payload: dict[str, object]) -> "AstrBotError": + return cls( + code=str(payload.get("code", "unknown_error")), + message=str(payload.get("message", "未知错误")), + hint=str(payload.get("hint", "")), + retryable=bool(payload.get("retryable", False)), + ) diff --git a/src-new/astrbot_sdk/events.py b/src-new/astrbot_sdk/events.py new file mode 100644 index 000000000..9965b7188 --- /dev/null +++ b/src-new/astrbot_sdk/events.py @@ -0,0 +1,67 @@ +from __future__ import annotations + +from dataclasses import dataclass +from typing import TYPE_CHECKING, Any + +if TYPE_CHECKING: + from .context import Context + + +@dataclass(slots=True) +class PlainTextResult: + text: str + + +class 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: "Context | None" = None, + ) -> None: + self.text = text + self.user_id = user_id + self.group_id = group_id + self.platform = platform + self.session_id = session_id or group_id or user_id or "" + self.raw = raw or {} + self._context = context + + @classmethod + def from_payload( + cls, + payload: dict[str, Any], + *, + context: "Context | None" = None, + ) -> "MessageEvent": + return cls( + text=str(payload.get("text", "")), + 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, + ) + + def to_payload(self) -> dict[str, Any]: + return { + "text": self.text, + "user_id": self.user_id, + "group_id": self.group_id, + "platform": self.platform, + "session_id": self.session_id, + } + + async def reply(self, text: str) -> None: + if self._context is None: + raise RuntimeError("MessageEvent 未绑定 Context,无法 reply") + await self._context.platform.send(self.session_id, text) + + def plain_result(self, text: str) -> PlainTextResult: + return PlainTextResult(text=text) diff --git a/src-new/astrbot_sdk/protocol/__init__.py b/src-new/astrbot_sdk/protocol/__init__.py new file mode 100644 index 000000000..882adb401 --- /dev/null +++ b/src-new/astrbot_sdk/protocol/__init__.py @@ -0,0 +1,21 @@ +from .descriptors import CapabilityDescriptor, HandlerDescriptor, Permissions +from .messages import ( + CancelMessage, + EventMessage, + InitializeMessage, + InitializeOutput, + InvokeMessage, + ResultMessage, +) + +__all__ = [ + "CapabilityDescriptor", + "CancelMessage", + "EventMessage", + "HandlerDescriptor", + "InitializeMessage", + "InitializeOutput", + "InvokeMessage", + "Permissions", + "ResultMessage", +] diff --git a/src-new/astrbot_sdk/protocol/descriptors.py b/src-new/astrbot_sdk/protocol/descriptors.py new file mode 100644 index 000000000..1481c5132 --- /dev/null +++ b/src-new/astrbot_sdk/protocol/descriptors.py @@ -0,0 +1,69 @@ +from __future__ import annotations + +from typing import Annotated, Any, Literal + +from pydantic import BaseModel, ConfigDict, Field, model_validator + + +class _DescriptorBase(BaseModel): + model_config = ConfigDict(extra="forbid") + + +class Permissions(_DescriptorBase): + require_admin: bool = False + level: int = 0 + + +class CommandTrigger(_DescriptorBase): + type: Literal["command"] = "command" + command: str + aliases: list[str] = Field(default_factory=list) + description: str | None = None + + +class MessageTrigger(_DescriptorBase): + type: Literal["message"] = "message" + regex: str | None = None + keywords: list[str] = Field(default_factory=list) + platforms: list[str] = Field(default_factory=list) + + +class EventTrigger(_DescriptorBase): + type: Literal["event"] = "event" + event_type: str + + +class ScheduleTrigger(_DescriptorBase): + type: Literal["schedule"] = "schedule" + cron: str | None = None + interval_seconds: int | None = None + + @model_validator(mode="after") + def validate_schedule(self) -> "ScheduleTrigger": + has_cron = self.cron is not None + has_interval = self.interval_seconds is not None + if has_cron == has_interval: + raise ValueError("cron 和 interval_seconds 必须且只能有一个非 null") + return self + + +Trigger = Annotated[ + CommandTrigger | MessageTrigger | EventTrigger | ScheduleTrigger, + Field(discriminator="type"), +] + + +class HandlerDescriptor(_DescriptorBase): + id: str + trigger: Trigger + priority: int = 0 + permissions: Permissions = Field(default_factory=Permissions) + + +class CapabilityDescriptor(_DescriptorBase): + name: str + description: str + input_schema: dict[str, Any] | None = None + output_schema: dict[str, Any] | None = None + supports_stream: bool = False + cancelable: bool = False diff --git a/src-new/astrbot_sdk/protocol/legacy_adapter.py b/src-new/astrbot_sdk/protocol/legacy_adapter.py new file mode 100644 index 000000000..de63424bc --- /dev/null +++ b/src-new/astrbot_sdk/protocol/legacy_adapter.py @@ -0,0 +1,90 @@ +from __future__ import annotations + +from typing import Any + +from .messages import CancelMessage, EventMessage, InvokeMessage, ResultMessage + + +def legacy_request_to_invoke(payload: dict[str, Any]) -> InvokeMessage: + method = str(payload.get("method", "")) + params = payload.get("params") or {} + request_id = str(payload.get("id", "")) + if method == "call_handler": + return InvokeMessage( + id=request_id, + capability="handler.invoke", + input={ + "handler_id": params.get("handler_full_name", ""), + "event": params.get("event", {}), + "args": params.get("args", {}), + }, + stream=False, + ) + return InvokeMessage( + id=request_id, + capability=method, + input=params if isinstance(params, dict) else {}, + stream=False, + ) + + +def invoke_to_legacy_request(message: InvokeMessage) -> dict[str, Any]: + if message.capability == "handler.invoke": + return { + "jsonrpc": "2.0", + "id": message.id, + "method": "call_handler", + "params": { + "handler_full_name": message.input.get("handler_id"), + "event": message.input.get("event", {}), + "args": message.input.get("args", {}), + }, + } + return { + "jsonrpc": "2.0", + "id": message.id, + "method": message.capability, + "params": message.input, + } + + +def result_to_legacy_response(message: ResultMessage) -> dict[str, Any]: + if message.success: + return { + "jsonrpc": "2.0", + "id": message.id, + "result": message.output, + } + return { + "jsonrpc": "2.0", + "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(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} + 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": "2.0", "method": method, "params": params} + + +def cancel_to_legacy_request(message: CancelMessage) -> dict[str, Any]: + return { + "jsonrpc": "2.0", + "id": message.id, + "method": "cancel", + "params": {"reason": message.reason}, + } diff --git a/src-new/astrbot_sdk/protocol/messages.py b/src-new/astrbot_sdk/protocol/messages.py new file mode 100644 index 000000000..1f25775cd --- /dev/null +++ b/src-new/astrbot_sdk/protocol/messages.py @@ -0,0 +1,96 @@ +from __future__ import annotations + +import json +from typing import Any, Literal + +from pydantic import BaseModel, ConfigDict, Field + +from .descriptors import CapabilityDescriptor, HandlerDescriptor + + +class _MessageBase(BaseModel): + model_config = ConfigDict(extra="forbid") + + +class ErrorPayload(_MessageBase): + code: str + message: str + hint: str = "" + retryable: bool = False + + +class PeerInfo(_MessageBase): + name: str + role: Literal["plugin", "core"] + version: str | None = None + + +class InitializeMessage(_MessageBase): + type: Literal["initialize"] = "initialize" + id: str + protocol_version: str + peer: PeerInfo + handlers: list[HandlerDescriptor] = Field(default_factory=list) + metadata: dict[str, Any] = Field(default_factory=dict) + + +class InitializeOutput(_MessageBase): + peer: PeerInfo + capabilities: list[CapabilityDescriptor] = Field(default_factory=list) + metadata: dict[str, Any] = Field(default_factory=dict) + + +class ResultMessage(_MessageBase): + type: Literal["result"] = "result" + id: str + kind: str | None = None + success: bool + output: dict[str, Any] = Field(default_factory=dict) + error: ErrorPayload | None = None + + +class InvokeMessage(_MessageBase): + type: Literal["invoke"] = "invoke" + id: str + capability: str + input: dict[str, Any] = Field(default_factory=dict) + stream: bool = False + + +class EventMessage(_MessageBase): + type: Literal["event"] = "event" + id: str + phase: Literal["started", "delta", "completed", "failed"] + data: dict[str, Any] = Field(default_factory=dict) + output: dict[str, Any] = Field(default_factory=dict) + error: ErrorPayload | None = None + + +class CancelMessage(_MessageBase): + type: Literal["cancel"] = "cancel" + id: str + reason: str = "user_cancelled" + + +ProtocolMessage = ( + InitializeMessage | ResultMessage | InvokeMessage | EventMessage | CancelMessage +) + + +def parse_message(payload: str | bytes | dict[str, Any]) -> ProtocolMessage: + if isinstance(payload, bytes): + payload = payload.decode("utf-8") + if isinstance(payload, str): + payload = json.loads(payload) + message_type = payload.get("type") + if message_type == "initialize": + return InitializeMessage.model_validate(payload) + if message_type == "result": + return ResultMessage.model_validate(payload) + if message_type == "invoke": + return InvokeMessage.model_validate(payload) + if message_type == "event": + return EventMessage.model_validate(payload) + if message_type == "cancel": + return CancelMessage.model_validate(payload) + raise ValueError(f"未知消息类型:{message_type}") diff --git a/src-new/astrbot_sdk/runtime/__init__.py b/src-new/astrbot_sdk/runtime/__init__.py new file mode 100644 index 000000000..c9c2ef67b --- /dev/null +++ b/src-new/astrbot_sdk/runtime/__init__.py @@ -0,0 +1 @@ +__all__: list[str] = [] diff --git a/src-new/astrbot_sdk/runtime/bootstrap.py b/src-new/astrbot_sdk/runtime/bootstrap.py new file mode 100644 index 000000000..08b2204ec --- /dev/null +++ b/src-new/astrbot_sdk/runtime/bootstrap.py @@ -0,0 +1,352 @@ +from __future__ import annotations + +import asyncio +import os +import signal +import sys +from pathlib import Path +from typing import IO, Any + +from loguru import logger + +from ..errors import AstrBotError +from ..protocol.messages import InitializeOutput, PeerInfo +from .capability_router import CapabilityRouter +from .handler_dispatcher import HandlerDispatcher +from .loader import ( + PluginEnvironmentManager, + PluginSpec, + discover_plugins, + load_plugin, + load_plugin_spec, +) +from .peer import Peer +from .transport import StdioTransport, WebSocketServerTransport + + +def _install_signal_handlers(stop_event: asyncio.Event) -> None: + loop = asyncio.get_running_loop() + for sig in (signal.SIGTERM, signal.SIGINT): + try: + loop.add_signal_handler(sig, stop_event.set) + except NotImplementedError: + logger.debug("Signal handlers are not supported for {}", sig) + + +def _prepare_stdio_transport( + stdin: IO[str] | None, + stdout: IO[str] | None, +) -> tuple[IO[str], IO[str], IO[str] | None]: + if stdin is not None and stdout is not None: + return stdin, stdout, None + transport_stdin = stdin or sys.stdin + transport_stdout = stdout or sys.stdout + original_stdout = sys.stdout + sys.stdout = sys.stderr + return transport_stdin, transport_stdout, original_stdout + + +async def _wait_for_shutdown(peer: Peer, stop_event: asyncio.Event) -> None: + stop_waiter = asyncio.create_task(stop_event.wait()) + transport_waiter = asyncio.create_task(peer.wait_closed()) + done, pending = await asyncio.wait( + {stop_waiter, transport_waiter}, + return_when=asyncio.FIRST_COMPLETED, + ) + for task in pending: + task.cancel() + for task in done: + if not task.cancelled(): + task.result() + + +class WorkerSession: + def __init__( + self, + *, + plugin: PluginSpec, + repo_root: Path, + env_manager: PluginEnvironmentManager, + capability_router: CapabilityRouter, + ) -> None: + self.plugin = plugin + self.repo_root = repo_root.resolve() + self.env_manager = env_manager + self.capability_router = capability_router + self.peer: Peer | None = None + self.handlers = [] + + async def start(self) -> None: + python_path = self.env_manager.prepare_environment(self.plugin) + repo_src_dir = str(self.repo_root / "src-new") + env = os.environ.copy() + existing_pythonpath = env.get("PYTHONPATH") + env["PYTHONPATH"] = ( + f"{repo_src_dir}{os.pathsep}{existing_pythonpath}" + if existing_pythonpath + else repo_src_dir + ) + + transport = StdioTransport( + command=[ + str(python_path), + "-m", + "astrbot_sdk", + "worker", + "--plugin-dir", + str(self.plugin.plugin_dir), + ], + cwd=str(self.plugin.plugin_dir), + env=env, + ) + self.peer = Peer( + transport=transport, + peer_info=PeerInfo(name="astrbot-core", role="core", version="v4"), + ) + self.peer.set_initialize_handler(self._handle_initialize) + self.peer.set_invoke_handler(self._handle_capability_invoke) + try: + await self.peer.start() + await self.peer.wait_until_remote_initialized() + self.handlers = list(self.peer.remote_handlers) + except Exception: + await self.stop() + raise + + async def stop(self) -> None: + if self.peer is not None: + await self.peer.stop() + + async def invoke_handler( + self, + handler_id: str, + event_payload: dict[str, Any], + *, + request_id: str, + ) -> dict[str, Any]: + if self.peer is None: + raise RuntimeError("worker session is not running") + return await self.peer.invoke( + "handler.invoke", + { + "handler_id": handler_id, + "event": event_payload, + }, + request_id=request_id, + ) + + async def cancel(self, request_id: str) -> None: + if self.peer is None: + return + await self.peer.cancel(request_id) + + async def _handle_initialize(self, _message) -> InitializeOutput: + return InitializeOutput( + peer=PeerInfo(name="astrbot-supervisor", role="core", version="v4"), + capabilities=self.capability_router.descriptors(), + metadata={"plugin": self.plugin.name}, + ) + + async def _handle_capability_invoke(self, message, cancel_token): + return await self.capability_router.execute( + message.capability, + message.input, + stream=message.stream, + cancel_token=cancel_token, + ) + + +class SupervisorRuntime: + def __init__( + self, + *, + transport, + plugins_dir: Path, + env_manager: PluginEnvironmentManager | None = None, + ) -> None: + self.transport = transport + self.plugins_dir = plugins_dir.resolve() + self.repo_root = Path(__file__).resolve().parents[3] + self.env_manager = env_manager or PluginEnvironmentManager(self.repo_root) + self.capability_router = CapabilityRouter() + self.peer = Peer( + transport=self.transport, + peer_info=PeerInfo(name="astrbot-supervisor", role="plugin", version="v4"), + ) + self.peer.set_invoke_handler(self._handle_upstream_invoke) + self.peer.set_cancel_handler(self._handle_upstream_cancel) + self.worker_sessions: dict[str, WorkerSession] = {} + self.handler_to_worker: dict[str, WorkerSession] = {} + self.active_requests: dict[str, WorkerSession] = {} + self.loaded_plugins: list[str] = [] + self.skipped_plugins: dict[str, str] = {} + + async def start(self) -> None: + discovery = discover_plugins(self.plugins_dir) + self.skipped_plugins = dict(discovery.skipped_plugins) + try: + for plugin in discovery.plugins: + session = WorkerSession( + plugin=plugin, + repo_root=self.repo_root, + env_manager=self.env_manager, + capability_router=self.capability_router, + ) + try: + await session.start() + except Exception as exc: + self.skipped_plugins[plugin.name] = str(exc) + await session.stop() + continue + self.worker_sessions[plugin.name] = session + self.loaded_plugins.append(plugin.name) + for handler in session.handlers: + self.handler_to_worker[handler.id] = session + + aggregated_handlers = list(self.handler_to_worker.keys()) + logger.info("Loaded plugins: {}", ", ".join(sorted(self.loaded_plugins)) or "none") + + await self.peer.start() + await self.peer.initialize( + [handler for session in self.worker_sessions.values() for handler in session.handlers], + metadata={ + "plugins": sorted(self.loaded_plugins), + "skipped_plugins": self.skipped_plugins, + "aggregated_handler_ids": aggregated_handlers, + }, + ) + except Exception: + await self.stop() + raise + + async def stop(self) -> None: + for session in list(self.worker_sessions.values()): + await session.stop() + await self.peer.stop() + + async def _handle_upstream_invoke(self, message, _cancel_token): + if message.capability != "handler.invoke": + raise AstrBotError.capability_not_found(message.capability) + handler_id = str(message.input.get("handler_id", "")) + session = self.handler_to_worker.get(handler_id) + if session is None: + raise AstrBotError.invalid_input(f"handler not found: {handler_id}") + self.active_requests[message.id] = session + try: + return await session.invoke_handler( + handler_id, + message.input.get("event", {}), + request_id=message.id, + ) + finally: + self.active_requests.pop(message.id, None) + + async def _handle_upstream_cancel(self, request_id: str) -> None: + session = self.active_requests.get(request_id) + if session is not None: + await session.cancel(request_id) + + +class PluginWorkerRuntime: + def __init__(self, *, plugin_dir: Path, transport) -> None: + self.plugin = load_plugin_spec(plugin_dir) + self.transport = transport + self.loaded_plugin = load_plugin(self.plugin) + self.peer = Peer( + transport=self.transport, + peer_info=PeerInfo(name=self.plugin.name, role="plugin", version="v4"), + ) + self.dispatcher = HandlerDispatcher( + plugin_id=self.plugin.name, + peer=self.peer, + handlers=self.loaded_plugin.handlers, + ) + self.peer.set_invoke_handler(self._handle_invoke) + self.peer.set_cancel_handler(self.dispatcher.cancel) + + async def start(self) -> None: + await self.peer.start() + await self.peer.initialize( + [item.descriptor for item in self.loaded_plugin.handlers], + metadata={"plugin_id": self.plugin.name}, + ) + + async def stop(self) -> None: + await self.peer.stop() + + async def _handle_invoke(self, message, cancel_token): + if message.capability != "handler.invoke": + raise AstrBotError.capability_not_found(message.capability) + return await self.dispatcher.invoke(message, cancel_token) + + +async def run_supervisor( + *, + plugins_dir: Path = Path("plugins"), + stdin: IO[str] | None = None, + stdout: IO[str] | None = None, + env_manager: PluginEnvironmentManager | None = None, +) -> None: + transport_stdin, transport_stdout, original_stdout = _prepare_stdio_transport( + stdin, + stdout, + ) + transport = StdioTransport(stdin=transport_stdin, stdout=transport_stdout) + runtime = SupervisorRuntime( + transport=transport, + plugins_dir=plugins_dir, + env_manager=env_manager, + ) + + try: + await runtime.start() + stop_event = asyncio.Event() + _install_signal_handlers(stop_event) + await _wait_for_shutdown(runtime.peer, stop_event) + finally: + await runtime.stop() + if original_stdout is not None: + sys.stdout = original_stdout + + +async def run_plugin_worker( + *, + plugin_dir: Path, + stdin: IO[str] | None = None, + stdout: IO[str] | None = None, +) -> None: + transport_stdin, transport_stdout, original_stdout = _prepare_stdio_transport( + stdin, + stdout, + ) + transport = StdioTransport(stdin=transport_stdin, stdout=transport_stdout) + runtime = PluginWorkerRuntime(plugin_dir=plugin_dir, transport=transport) + try: + await runtime.start() + stop_event = asyncio.Event() + _install_signal_handlers(stop_event) + await _wait_for_shutdown(runtime.peer, stop_event) + finally: + await runtime.stop() + if original_stdout is not None: + sys.stdout = original_stdout + + +async def run_websocket_server( + *, + host: str = "127.0.0.1", + port: int = 8765, + path: str = "/", + plugin_dir: Path | None = None, +) -> None: + runtime = PluginWorkerRuntime( + plugin_dir=plugin_dir or Path.cwd(), + transport=WebSocketServerTransport(host=host, port=port, path=path), + ) + try: + await runtime.start() + stop_event = asyncio.Event() + _install_signal_handlers(stop_event) + await _wait_for_shutdown(runtime.peer, stop_event) + finally: + await runtime.stop() diff --git a/src-new/astrbot_sdk/runtime/capability_router.py b/src-new/astrbot_sdk/runtime/capability_router.py new file mode 100644 index 000000000..70782b766 --- /dev/null +++ b/src-new/astrbot_sdk/runtime/capability_router.py @@ -0,0 +1,330 @@ +from __future__ import annotations + +import asyncio +import json +from collections.abc import AsyncIterator, Awaitable, Callable +from dataclasses import dataclass +from typing import Any + +from ..errors import AstrBotError +from ..protocol.descriptors import CapabilityDescriptor + +CallHandler = Callable[[dict[str, Any], object], Awaitable[dict[str, Any]]] +StreamHandler = Callable[[dict[str, Any], object], AsyncIterator[dict[str, Any]]] +FinalizeHandler = Callable[[list[dict[str, Any]]], dict[str, Any]] + + +@dataclass(slots=True) +class StreamExecution: + iterator: AsyncIterator[dict[str, Any]] + finalize: FinalizeHandler + + +@dataclass(slots=True) +class _CapabilityRegistration: + descriptor: CapabilityDescriptor + call_handler: CallHandler | None = None + stream_handler: StreamHandler | None = None + finalize: FinalizeHandler | None = None + + +class CapabilityRouter: + def __init__(self) -> None: + self._registrations: dict[str, _CapabilityRegistration] = {} + self.db_store: dict[str, dict[str, Any]] = {} + self.memory_store: dict[str, dict[str, Any]] = {} + self.sent_messages: list[dict[str, Any]] = [] + self._register_builtin_capabilities() + + def descriptors(self) -> list[CapabilityDescriptor]: + return [entry.descriptor for entry in self._registrations.values()] + + def register( + self, + descriptor: CapabilityDescriptor, + *, + call_handler: CallHandler | None = None, + stream_handler: StreamHandler | None = None, + finalize: FinalizeHandler | None = None, + ) -> None: + self._registrations[descriptor.name] = _CapabilityRegistration( + descriptor=descriptor, + call_handler=call_handler, + stream_handler=stream_handler, + finalize=finalize, + ) + + async def execute( + self, + capability: str, + payload: dict[str, Any], + *, + stream: bool, + cancel_token, + ) -> dict[str, Any] | StreamExecution: + registration = self._registrations.get(capability) + if registration is None: + raise AstrBotError.capability_not_found(capability) + + self._validate_schema(registration.descriptor.input_schema, payload) + if stream: + if registration.stream_handler is None: + raise AstrBotError.invalid_input(f"{capability} 不支持 stream=true") + finalize = registration.finalize or (lambda chunks: {"items": chunks}) + return StreamExecution( + iterator=registration.stream_handler(payload, cancel_token), + finalize=finalize, + ) + + if registration.call_handler is None: + raise AstrBotError.invalid_input(f"{capability} 只能以 stream=true 调用") + output = await registration.call_handler(payload, cancel_token) + self._validate_schema(registration.descriptor.output_schema, output) + return output + + def _register_builtin_capabilities(self) -> None: + def obj_schema(required: list[str], **properties: Any) -> dict[str, Any]: + return { + "type": "object", + "properties": properties, + "required": required, + } + + async def llm_chat(payload: dict[str, Any], _token) -> dict[str, Any]: + prompt = str(payload.get("prompt", "")) + return {"text": f"Echo: {prompt}"} + + async def llm_chat_raw(payload: dict[str, Any], _token) -> dict[str, Any]: + prompt = str(payload.get("prompt", "")) + text = f"Echo: {prompt}" + return { + "text": text, + "usage": { + "input_tokens": len(prompt), + "output_tokens": len(text), + }, + "finish_reason": "stop", + "tool_calls": [], + } + + async def llm_stream( + payload: dict[str, Any], + token, + ) -> AsyncIterator[dict[str, Any]]: + text = f"Echo: {str(payload.get('prompt', ''))}" + for char in text: + token.raise_if_cancelled() + await asyncio.sleep(0) + yield {"text": char} + + async def memory_search(payload: dict[str, Any], _token) -> dict[str, Any]: + query = str(payload.get("query", "")) + items = [ + {"key": key, "value": value} + for key, value in self.memory_store.items() + if query in key or query in json.dumps(value, ensure_ascii=False) + ] + return {"items": items} + + async def memory_save(payload: dict[str, Any], _token) -> dict[str, Any]: + key = str(payload.get("key", "")) + value = payload.get("value") + if not isinstance(value, dict): + raise AstrBotError.invalid_input("memory.save 的 value 必须是 object") + self.memory_store[key] = value + return {} + + async def memory_delete(payload: dict[str, Any], _token) -> dict[str, Any]: + self.memory_store.pop(str(payload.get("key", "")), None) + return {} + + async def db_get(payload: dict[str, Any], _token) -> dict[str, Any]: + return {"value": self.db_store.get(str(payload.get("key", "")))} + + async def db_set(payload: dict[str, Any], _token) -> dict[str, Any]: + key = str(payload.get("key", "")) + value = payload.get("value") + if not isinstance(value, dict): + raise AstrBotError.invalid_input("db.set 的 value 必须是 object") + self.db_store[key] = value + return {} + + async def db_delete(payload: dict[str, Any], _token) -> dict[str, Any]: + self.db_store.pop(str(payload.get("key", "")), None) + return {} + + async def db_list(payload: dict[str, Any], _token) -> dict[str, Any]: + prefix = payload.get("prefix") + keys = sorted(self.db_store.keys()) + if isinstance(prefix, str): + keys = [item for item in keys if item.startswith(prefix)] + return {"keys": keys} + + async def platform_send(payload: dict[str, Any], _token) -> dict[str, Any]: + session = str(payload.get("session", "")) + text = str(payload.get("text", "")) + message_id = f"msg_{len(self.sent_messages) + 1}" + self.sent_messages.append( + { + "message_id": message_id, + "session": session, + "text": text, + } + ) + return {"message_id": message_id} + + async def platform_send_image(payload: dict[str, Any], _token) -> dict[str, Any]: + session = str(payload.get("session", "")) + image_url = str(payload.get("image_url", "")) + message_id = f"img_{len(self.sent_messages) + 1}" + self.sent_messages.append( + { + "message_id": message_id, + "session": session, + "image_url": image_url, + } + ) + return {"message_id": message_id} + + async def platform_get_members(payload: dict[str, Any], _token) -> dict[str, Any]: + session = str(payload.get("session", "")) + return { + "members": [ + {"user_id": f"{session}:member-1", "nickname": "Member 1"}, + {"user_id": f"{session}:member-2", "nickname": "Member 2"}, + ] + } + + self.register( + CapabilityDescriptor( + name="llm.chat", + description="发送对话请求,返回文本", + input_schema=obj_schema(["prompt"], prompt={"type": "string"}), + output_schema=obj_schema(["text"], text={"type": "string"}), + ), + call_handler=llm_chat, + ) + self.register( + CapabilityDescriptor( + name="llm.chat_raw", + description="发送对话请求,返回完整响应", + input_schema=obj_schema(["prompt"], prompt={"type": "string"}), + output_schema=obj_schema(["text"], text={"type": "string"}), + ), + call_handler=llm_chat_raw, + ) + self.register( + CapabilityDescriptor( + name="llm.stream_chat", + description="流式对话", + input_schema=obj_schema(["prompt"], prompt={"type": "string"}), + output_schema=obj_schema(["text"], text={"type": "string"}), + supports_stream=True, + cancelable=True, + ), + stream_handler=llm_stream, + finalize=lambda chunks: {"text": "".join(item.get("text", "") for item in chunks)}, + ) + self.register( + CapabilityDescriptor( + name="memory.search", + description="搜索记忆", + input_schema=obj_schema(["query"], query={"type": "string"}), + output_schema=obj_schema(["items"], items={"type": "array"}), + ), + call_handler=memory_search, + ) + self.register( + CapabilityDescriptor( + name="memory.save", + description="保存记忆", + input_schema=obj_schema(["key", "value"], key={"type": "string"}, value={"type": "object"}), + output_schema=obj_schema([]), + ), + call_handler=memory_save, + ) + self.register( + CapabilityDescriptor( + name="memory.delete", + description="删除记忆", + input_schema=obj_schema(["key"], key={"type": "string"}), + output_schema=obj_schema([]), + ), + call_handler=memory_delete, + ) + self.register( + CapabilityDescriptor( + name="db.get", + description="读取 KV", + input_schema=obj_schema(["key"], key={"type": "string"}), + output_schema=obj_schema([], value={"type": "object"}), + ), + call_handler=db_get, + ) + self.register( + CapabilityDescriptor( + name="db.set", + description="写入 KV", + input_schema=obj_schema(["key", "value"], key={"type": "string"}, value={"type": "object"}), + output_schema=obj_schema([]), + ), + call_handler=db_set, + ) + self.register( + CapabilityDescriptor( + name="db.delete", + description="删除 KV", + input_schema=obj_schema(["key"], key={"type": "string"}), + output_schema=obj_schema([]), + ), + call_handler=db_delete, + ) + self.register( + CapabilityDescriptor( + name="db.list", + description="列出 KV", + input_schema=obj_schema([], prefix={"type": "string"}), + output_schema=obj_schema(["keys"], keys={"type": "array"}), + ), + call_handler=db_list, + ) + self.register( + CapabilityDescriptor( + name="platform.send", + description="发送消息", + input_schema=obj_schema(["session", "text"], session={"type": "string"}, text={"type": "string"}), + output_schema=obj_schema(["message_id"], message_id={"type": "string"}), + ), + call_handler=platform_send, + ) + self.register( + CapabilityDescriptor( + name="platform.send_image", + description="发送图片", + input_schema=obj_schema(["session", "image_url"], session={"type": "string"}, image_url={"type": "string"}), + output_schema=obj_schema(["message_id"], message_id={"type": "string"}), + ), + call_handler=platform_send_image, + ) + self.register( + CapabilityDescriptor( + name="platform.get_members", + description="获取群成员", + input_schema=obj_schema(["session"], session={"type": "string"}), + output_schema=obj_schema(["members"], members={"type": "array"}), + ), + call_handler=platform_get_members, + ) + + def _validate_schema( + self, + schema: dict[str, Any] | None, + payload: dict[str, Any], + ) -> None: + if schema is None: + return + if schema.get("type") == "object" and not isinstance(payload, dict): + raise AstrBotError.invalid_input("输入必须是 object") + for field_name in schema.get("required", []): + if field_name not in payload or payload[field_name] is None: + raise AstrBotError.invalid_input(f"缺少必填字段:{field_name}") diff --git a/src-new/astrbot_sdk/runtime/handler_dispatcher.py b/src-new/astrbot_sdk/runtime/handler_dispatcher.py new file mode 100644 index 000000000..032cb7f85 --- /dev/null +++ b/src-new/astrbot_sdk/runtime/handler_dispatcher.py @@ -0,0 +1,102 @@ +from __future__ import annotations + +import asyncio +import inspect +from typing import Any + +from ..context import CancelToken, Context +from ..events import MessageEvent, PlainTextResult +from ..star import Star +from .loader import LoadedHandler + + +class HandlerDispatcher: + def __init__(self, *, plugin_id: str, peer, handlers: list[LoadedHandler]) -> None: + self._plugin_id = plugin_id + self._peer = peer + self._handlers = {item.descriptor.id: item for item in handlers} + self._active: dict[str, tuple[asyncio.Task[Any], CancelToken]] = {} + + async def invoke(self, message, cancel_token: CancelToken) -> dict[str, Any]: + handler_id = str(message.input.get("handler_id", "")) + loaded = self._handlers.get(handler_id) + if loaded is None: + raise LookupError(f"handler not found: {handler_id}") + + ctx = Context(peer=self._peer, plugin_id=self._plugin_id, cancel_token=cancel_token) + event = MessageEvent.from_payload(message.input.get("event", {}), context=ctx) + if loaded.legacy_context is not None: + loaded.legacy_context.bind_runtime_context(ctx) + + task = asyncio.create_task(self._run_handler(loaded, event, ctx)) + self._active[message.id] = (task, cancel_token) + try: + await task + return {} + finally: + self._active.pop(message.id, None) + + async def cancel(self, request_id: str) -> None: + active = self._active.get(request_id) + if active is None: + return + task, cancel_token = active + cancel_token.cancel() + task.cancel() + + async def _run_handler( + self, + loaded: LoadedHandler, + event: MessageEvent, + ctx: Context, + ) -> None: + try: + result = loaded.callable(*self._build_args(loaded.callable, event, ctx)) + if inspect.isasyncgen(result): + async for item in result: + await self._consume_legacy_result(item, event) + return + if inspect.isawaitable(result): + result = await result + if result is not None: + await self._consume_legacy_result(result, event) + except Exception as exc: + await self._handle_error(loaded.owner, exc, event, ctx) + raise + + def _build_args(self, handler, event: MessageEvent, ctx: Context) -> list[Any]: + signature = inspect.signature(handler) + args: list[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 == "event": + args.append(event) + elif parameter.name in {"ctx", "context"}: + args.append(ctx) + return args + + async def _consume_legacy_result(self, item: Any, event: MessageEvent) -> None: + if isinstance(item, PlainTextResult): + await event.reply(item.text) + return + if isinstance(item, str): + await event.reply(item) + return + if isinstance(item, dict) and "text" in item: + await event.reply(str(item["text"])) + + async def _handle_error( + self, + owner: Any, + exc: Exception, + event: MessageEvent, + ctx: Context, + ) -> None: + if hasattr(owner, "on_error") and callable(owner.on_error): + await owner.on_error(exc, event, ctx) + return + await Star().on_error(exc, event, ctx) diff --git a/src-new/astrbot_sdk/runtime/loader.py b/src-new/astrbot_sdk/runtime/loader.py new file mode 100644 index 000000000..270524a6c --- /dev/null +++ b/src-new/astrbot_sdk/runtime/loader.py @@ -0,0 +1,307 @@ +from __future__ import annotations + +import json +import os +import re +import shutil +import subprocess +import sys +from dataclasses import dataclass +from importlib import import_module +from pathlib import Path +from typing import Any + +import yaml + +from ..compat import LegacyContext +from ..decorators import get_handler_meta +from ..protocol.descriptors import HandlerDescriptor +from ..star import Star + +STATE_FILE_NAME = ".astrbot-worker-state.json" + + +def _venv_python_path(venv_dir: Path) -> Path: + if os.name == "nt": + return venv_dir / "Scripts" / "python.exe" + return venv_dir / "bin" / "python" + + +@dataclass(slots=True) +class PluginSpec: + name: str + plugin_dir: Path + manifest_path: Path + requirements_path: Path + python_version: str + manifest_data: dict[str, Any] + + +@dataclass(slots=True) +class PluginDiscoveryResult: + plugins: list[PluginSpec] + skipped_plugins: dict[str, str] + + +@dataclass(slots=True) +class LoadedHandler: + descriptor: HandlerDescriptor + callable: Any + owner: Any + legacy_context: LegacyContext | None = None + + +@dataclass(slots=True) +class LoadedPlugin: + plugin: PluginSpec + handlers: list[LoadedHandler] + instances: list[Any] + + +def load_plugin_spec(plugin_dir: Path) -> PluginSpec: + plugin_dir = plugin_dir.resolve() + manifest_path = plugin_dir / "plugin.yaml" + requirements_path = plugin_dir / "requirements.txt" + manifest_data = yaml.safe_load(manifest_path.read_text(encoding="utf-8")) or {} + runtime = manifest_data.get("runtime") or {} + python_version = runtime.get("python") or f"{sys.version_info.major}.{sys.version_info.minor}" + return PluginSpec( + name=str(manifest_data.get("name") or plugin_dir.name), + plugin_dir=plugin_dir, + manifest_path=manifest_path, + requirements_path=requirements_path, + python_version=str(python_version), + manifest_data=manifest_data, + ) + + +def discover_plugins(plugins_dir: Path) -> PluginDiscoveryResult: + plugins_root = plugins_dir.resolve() + skipped_plugins: dict[str, str] = {} + plugins: list[PluginSpec] = [] + seen_names: set[str] = set() + + if not plugins_root.exists(): + return PluginDiscoveryResult([], {}) + + for entry in sorted(plugins_root.iterdir()): + if not entry.is_dir() or entry.name.startswith("."): + continue + manifest_path = entry / "plugin.yaml" + requirements_path = entry / "requirements.txt" + if not manifest_path.exists(): + continue + if not requirements_path.exists(): + skipped_plugins[entry.name] = "missing requirements.txt" + continue + try: + manifest_data = yaml.safe_load(manifest_path.read_text(encoding="utf-8")) or {} + except Exception as exc: + skipped_plugins[entry.name] = f"failed to parse plugin.yaml: {exc}" + continue + plugin_name = manifest_data.get("name") + runtime = manifest_data.get("runtime") or {} + python_version = runtime.get("python") + components = manifest_data.get("components") + if not isinstance(plugin_name, str) or not plugin_name: + skipped_plugins[entry.name] = "plugin name is required" + continue + if plugin_name in seen_names: + skipped_plugins[plugin_name] = "duplicate plugin name" + continue + if not isinstance(components, list) or not components: + skipped_plugins[plugin_name] = "components must be a non-empty list" + continue + if not isinstance(python_version, str) or not python_version: + skipped_plugins[plugin_name] = "runtime.python is required" + continue + seen_names.add(plugin_name) + plugins.append( + PluginSpec( + name=plugin_name, + plugin_dir=entry.resolve(), + manifest_path=manifest_path.resolve(), + requirements_path=requirements_path.resolve(), + python_version=python_version, + manifest_data=manifest_data, + ) + ) + return PluginDiscoveryResult(plugins=plugins, skipped_plugins=skipped_plugins) + + +class PluginEnvironmentManager: + def __init__(self, repo_root: Path, uv_binary: str | None = None) -> None: + self.repo_root = repo_root.resolve() + self.uv_binary = uv_binary or shutil.which("uv") + self.cache_dir = self.repo_root / ".uv-cache" + + def prepare_environment(self, plugin: PluginSpec) -> Path: + if not self.uv_binary: + raise RuntimeError("uv executable not found") + state_path = plugin.plugin_dir / STATE_FILE_NAME + venv_dir = plugin.plugin_dir / ".venv" + python_path = _venv_python_path(venv_dir) + fingerprint = self._fingerprint(plugin) + state = self._load_state(state_path) + if ( + not python_path.exists() + or not self._matches_python_version(venv_dir, plugin.python_version) + or state.get("fingerprint") != fingerprint + ): + self._rebuild(plugin, venv_dir, python_path) + self._write_state(state_path, plugin, fingerprint) + return python_path + + def _rebuild(self, plugin: PluginSpec, venv_dir: Path, python_path: Path) -> None: + if venv_dir.exists(): + shutil.rmtree(venv_dir) + self._run_command( + [ + self.uv_binary, + "venv", + "--python", + plugin.python_version, + "--system-site-packages", + "--no-python-downloads", + "--no-managed-python", + str(venv_dir), + ], + cwd=self.repo_root, + command_name=f"create venv for {plugin.name}", + ) + requirements_text = plugin.requirements_path.read_text(encoding="utf-8").strip() + if not requirements_text: + return + self._run_command( + [ + self.uv_binary, + "pip", + "install", + "--python", + str(python_path), + "-r", + str(plugin.requirements_path), + ], + cwd=plugin.plugin_dir, + command_name=f"install requirements for {plugin.name}", + ) + + def _run_command( + self, + command: list[str], + *, + cwd: Path, + command_name: str, + ) -> None: + process = subprocess.run( + command, + cwd=str(cwd), + env={**os.environ, "UV_CACHE_DIR": str(self.cache_dir)}, + capture_output=True, + text=True, + check=False, + ) + if process.returncode != 0: + raise RuntimeError( + f"{command_name} failed with exit code {process.returncode}: " + f"{process.stderr.strip() or process.stdout.strip()}" + ) + + @staticmethod + def _fingerprint(plugin: PluginSpec) -> str: + requirements = plugin.requirements_path.read_text(encoding="utf-8") + payload = { + "python_version": plugin.python_version, + "requirements": requirements, + } + return json.dumps(payload, ensure_ascii=True, sort_keys=True) + + @staticmethod + def _load_state(state_path: Path) -> dict[str, Any]: + if not state_path.exists(): + return {} + try: + data = json.loads(state_path.read_text(encoding="utf-8")) + except Exception: + return {} + return data if isinstance(data, dict) else {} + + @staticmethod + def _write_state(state_path: Path, plugin: PluginSpec, fingerprint: str) -> None: + state_path.write_text( + json.dumps( + { + "plugin": plugin.name, + "python_version": plugin.python_version, + "fingerprint": fingerprint, + }, + ensure_ascii=True, + indent=2, + sort_keys=True, + ), + encoding="utf-8", + ) + + @staticmethod + def _matches_python_version(venv_dir: Path, version: str) -> bool: + pyvenv_cfg = venv_dir / "pyvenv.cfg" + if not pyvenv_cfg.exists(): + return False + content = pyvenv_cfg.read_text(encoding="utf-8") + match = re.search(r"version\s*=\s*(\d+\.\d+)\.\d+", content, re.IGNORECASE) + return match is not None and match.group(1) == version + + +def load_plugin(plugin: PluginSpec) -> LoadedPlugin: + plugin_path = str(plugin.plugin_dir) + if plugin_path not in sys.path: + sys.path.insert(0, plugin_path) + + instances: list[Any] = [] + handlers: list[LoadedHandler] = [] + for component in plugin.manifest_data.get("components", []): + class_path = component.get("class") + if not isinstance(class_path, str) or ":" not in class_path: + continue + component_cls = import_string(class_path) + legacy_context: LegacyContext | None = None + if isinstance(component_cls, type) and issubclass(component_cls, Star): + instance = component_cls() + else: + legacy_context = LegacyContext(plugin.name) + try: + instance = component_cls(legacy_context) + except TypeError: + instance = component_cls() + if getattr(instance, "context", None) is None: + setattr(instance, "context", legacy_context) + instances.append(instance) + for name in dir(instance): + bound = getattr(instance, name) + func = getattr(bound, "__func__", bound) + meta = get_handler_meta(func) + if meta is None or meta.trigger is None: + continue + handler_id = ( + f"{plugin.name}:{instance.__class__.__module__}.{instance.__class__.__name__}.{name}" + ) + handlers.append( + LoadedHandler( + descriptor=HandlerDescriptor( + id=handler_id, + trigger=meta.trigger, + priority=meta.priority, + permissions=meta.permissions.model_copy(deep=True), + ), + callable=bound, + owner=instance, + legacy_context=legacy_context, + ) + ) + return LoadedPlugin(plugin=plugin, handlers=handlers, instances=instances) + + +def import_string(path: str) -> Any: + module_name, attr = path.split(":", 1) + module = import_module(module_name) + return getattr(module, attr) diff --git a/src-new/astrbot_sdk/runtime/peer.py b/src-new/astrbot_sdk/runtime/peer.py new file mode 100644 index 000000000..aa6cc2d2d --- /dev/null +++ b/src-new/astrbot_sdk/runtime/peer.py @@ -0,0 +1,346 @@ +from __future__ import annotations + +import asyncio +from collections.abc import AsyncIterator, Awaitable, Callable +from typing import Any + +from ..context import CancelToken +from ..errors import AstrBotError +from ..protocol.messages import ( + CancelMessage, + ErrorPayload, + EventMessage, + InitializeMessage, + InitializeOutput, + InvokeMessage, + PeerInfo, + ResultMessage, + parse_message, +) +from .capability_router import StreamExecution + +InitializeHandler = Callable[[InitializeMessage], Awaitable[InitializeOutput]] +InvokeHandler = Callable[[InvokeMessage, CancelToken], Awaitable[dict[str, Any] | StreamExecution]] +CancelHandler = Callable[[str], Awaitable[None]] + + +class Peer: + def __init__( + self, + *, + transport, + peer_info: PeerInfo, + protocol_version: str = "1.0", + ) -> None: + self.transport = transport + self.peer_info = peer_info + self.protocol_version = protocol_version + self.remote_peer: PeerInfo | None = None + self.remote_handlers = [] + self.remote_capabilities = [] + self.remote_capability_map: dict[str, Any] = {} + self.remote_metadata: dict[str, Any] = {} + + self._initialize_handler: InitializeHandler | None = None + self._invoke_handler: InvokeHandler | None = None + self._cancel_handler: CancelHandler | None = None + self._counter = 0 + self._closed = False + self._unusable = False + self._pending_results: dict[str, asyncio.Future[ResultMessage]] = {} + self._pending_streams: dict[str, asyncio.Queue[Any]] = {} + self._inbound_tasks: dict[str, tuple[asyncio.Task[None], CancelToken]] = {} + self._remote_initialized = asyncio.Event() + + def set_initialize_handler(self, handler: InitializeHandler) -> None: + self._initialize_handler = handler + + def set_invoke_handler(self, handler: InvokeHandler) -> None: + self._invoke_handler = handler + + def set_cancel_handler(self, handler: CancelHandler) -> None: + self._cancel_handler = handler + + async def start(self) -> None: + self.transport.set_message_handler(self._handle_raw_message) + await self.transport.start() + + async def stop(self) -> None: + self._closed = True + await self.transport.stop() + + async def wait_closed(self) -> None: + await self.transport.wait_closed() + + async def wait_until_remote_initialized(self, timeout: float | None = 30.0) -> None: + if timeout is None: + await self._remote_initialized.wait() + return + await asyncio.wait_for(self._remote_initialized.wait(), timeout=timeout) + + async def initialize( + self, + handlers, + *, + metadata: dict[str, Any] | None = None, + ) -> InitializeOutput: + self._ensure_usable() + request_id = self._next_id() + future: asyncio.Future[ResultMessage] = asyncio.get_running_loop().create_future() + self._pending_results[request_id] = future + await self._send( + InitializeMessage( + id=request_id, + protocol_version=self.protocol_version, + peer=self.peer_info, + handlers=list(handlers), + metadata=metadata or {}, + ) + ) + result = await future + if result.kind != "initialize_result": + raise AstrBotError.protocol_error("initialize 必须收到 initialize_result") + if not result.success: + self._unusable = True + raise AstrBotError.from_payload(result.error.model_dump() if result.error else {}) + output = InitializeOutput.model_validate(result.output) + self.remote_peer = output.peer + self.remote_capabilities = output.capabilities + self.remote_capability_map = {item.name: item for item in output.capabilities} + self.remote_metadata = output.metadata + return output + + async def invoke( + self, + capability: str, + payload: dict[str, Any], + *, + stream: bool = False, + request_id: str | None = None, + ) -> dict[str, Any]: + self._ensure_usable() + if stream: + raise ValueError("stream=True 请使用 invoke_stream()") + request_id = request_id or self._next_id() + future: asyncio.Future[ResultMessage] = asyncio.get_running_loop().create_future() + self._pending_results[request_id] = future + await self._send( + InvokeMessage( + id=request_id, + capability=capability, + input=payload, + stream=False, + ) + ) + result = await future + if not result.success: + raise AstrBotError.from_payload(result.error.model_dump() if result.error else {}) + return result.output + + async def invoke_stream( + self, + capability: str, + payload: dict[str, Any], + *, + request_id: str | None = None, + ) -> AsyncIterator[EventMessage]: + self._ensure_usable() + request_id = request_id or self._next_id() + queue: asyncio.Queue[Any] = asyncio.Queue() + self._pending_streams[request_id] = queue + await self._send( + InvokeMessage( + id=request_id, + capability=capability, + input=payload, + stream=True, + ) + ) + + async def iterator() -> AsyncIterator[EventMessage]: + try: + while True: + item = await queue.get() + if isinstance(item, Exception): + raise item + if not isinstance(item, EventMessage): + raise AstrBotError.protocol_error("流式调用收到非法事件") + if item.phase == "started": + continue + if item.phase == "delta": + yield item + continue + if item.phase == "completed": + break + if item.phase == "failed": + raise AstrBotError.from_payload(item.error.model_dump() if item.error else {}) + finally: + self._pending_streams.pop(request_id, None) + + return iterator() + + async def cancel(self, request_id: str, reason: str = "user_cancelled") -> None: + await self._send(CancelMessage(id=request_id, reason=reason)) + + def _next_id(self) -> str: + self._counter += 1 + return f"msg_{self._counter:04d}" + + def _ensure_usable(self) -> None: + if self._unusable: + raise AstrBotError.protocol_error("连接已进入不可用状态") + + async def _handle_raw_message(self, payload: str) -> None: + message = parse_message(payload) + if isinstance(message, ResultMessage): + await self._handle_result(message) + return + if isinstance(message, EventMessage): + await self._handle_event(message) + return + if isinstance(message, InitializeMessage): + await self._handle_initialize(message) + return + if isinstance(message, InvokeMessage): + task = asyncio.create_task(self._handle_invoke(message)) + token = CancelToken() + self._inbound_tasks[message.id] = (task, token) + task.add_done_callback(lambda _task, request_id=message.id: self._inbound_tasks.pop(request_id, None)) + return + if isinstance(message, CancelMessage): + await self._handle_cancel(message) + return + + async def _handle_initialize(self, message: InitializeMessage) -> None: + self.remote_peer = message.peer + self.remote_handlers = message.handlers + self.remote_metadata = message.metadata + if self._initialize_handler is None: + error = AstrBotError.protocol_error("对端不接受 initialize") + await self._send( + ResultMessage( + id=message.id, + kind="initialize_result", + success=False, + error=ErrorPayload.model_validate(error.to_payload()), + ) + ) + self._unusable = True + self._remote_initialized.set() + return + + if message.protocol_version != self.protocol_version: + error = AstrBotError.protocol_version_mismatch( + f"服务端支持协议版本 {self.protocol_version},客户端请求版本 {message.protocol_version}" + ) + await self._send( + ResultMessage( + id=message.id, + kind="initialize_result", + success=False, + error=ErrorPayload.model_validate(error.to_payload()), + ) + ) + self._unusable = True + self._remote_initialized.set() + return + + output = await self._initialize_handler(message) + await self._send( + ResultMessage( + id=message.id, + kind="initialize_result", + success=True, + output=output.model_dump(), + ) + ) + self._remote_initialized.set() + + async def _handle_invoke(self, message: InvokeMessage) -> None: + active = self._inbound_tasks.get(message.id) + token = active[1] if active is not None else CancelToken() + try: + if self._invoke_handler is None: + raise AstrBotError.capability_not_found(message.capability) + execution = await self._invoke_handler(message, token) + if message.stream: + if not isinstance(execution, StreamExecution): + raise AstrBotError.protocol_error("stream=true 必须返回 StreamExecution") + await self._send(EventMessage(id=message.id, phase="started")) + chunks: list[dict[str, Any]] = [] + async for chunk in execution.iterator: + chunks.append(chunk) + await self._send(EventMessage(id=message.id, phase="delta", data=chunk)) + await self._send( + EventMessage( + id=message.id, + phase="completed", + output=execution.finalize(chunks), + ) + ) + return + if isinstance(execution, StreamExecution): + raise AstrBotError.protocol_error("stream=false 不能返回流式执行对象") + await self._send(ResultMessage(id=message.id, success=True, output=execution)) + except asyncio.CancelledError: + await self._send_cancelled_termination(message) + except LookupError as exc: + error = AstrBotError.invalid_input(str(exc)) + await self._send_error_result(message, error) + except AstrBotError as exc: + await self._send_error_result(message, exc) + except Exception as exc: + await self._send_error_result(message, AstrBotError.internal_error(str(exc))) + + async def _handle_cancel(self, message: CancelMessage) -> None: + inbound = self._inbound_tasks.get(message.id) + if inbound is None: + return + task, token = inbound + token.cancel() + if self._cancel_handler is not None: + await self._cancel_handler(message.id) + task.cancel() + + async def _handle_result(self, message: ResultMessage) -> None: + future = self._pending_results.pop(message.id, None) + if future is None: + queue = self._pending_streams.get(message.id) + if queue is not None: + await queue.put(AstrBotError.protocol_error("stream=true 调用不应收到 result")) + return + future.set_result(message) + + async def _handle_event(self, message: EventMessage) -> None: + queue = self._pending_streams.get(message.id) + if queue is None: + future = self._pending_results.get(message.id) + if future is not None: + future.set_exception(AstrBotError.protocol_error("stream=false 调用不应收到 event")) + return + await queue.put(message) + + async def _send_error_result(self, message: InvokeMessage, error: AstrBotError) -> None: + if message.stream: + await self._send( + EventMessage( + id=message.id, + phase="failed", + error=ErrorPayload.model_validate(error.to_payload()), + ) + ) + return + await self._send( + ResultMessage( + id=message.id, + success=False, + error=ErrorPayload.model_validate(error.to_payload()), + ) + ) + + async def _send_cancelled_termination(self, message: InvokeMessage) -> None: + error = AstrBotError.cancelled() + await self._send_error_result(message, error) + + async def _send(self, message) -> None: + await self.transport.send(message.model_dump_json(exclude_none=True)) diff --git a/src-new/astrbot_sdk/runtime/transport.py b/src-new/astrbot_sdk/runtime/transport.py new file mode 100644 index 000000000..dc246d295 --- /dev/null +++ b/src-new/astrbot_sdk/runtime/transport.py @@ -0,0 +1,285 @@ +from __future__ import annotations + +import asyncio +import sys +from abc import ABC, abstractmethod +from collections.abc import Awaitable, Callable, Sequence +from typing import IO + +import aiohttp +from aiohttp import web +from loguru import logger + +MessageHandler = Callable[[str], Awaitable[None]] + + +class Transport(ABC): + def __init__(self) -> None: + self._handler: MessageHandler | None = None + self._closed = asyncio.Event() + + def set_message_handler(self, handler: MessageHandler) -> None: + self._handler = handler + + @abstractmethod + async def start(self) -> None: + raise NotImplementedError + + @abstractmethod + async def stop(self) -> None: + raise NotImplementedError + + @abstractmethod + async def send(self, payload: str) -> None: + raise NotImplementedError + + async def wait_closed(self) -> None: + await self._closed.wait() + + async def _dispatch(self, payload: str) -> None: + if self._handler is not None: + await self._handler(payload) + + +class StdioTransport(Transport): + def __init__( + self, + *, + stdin: IO[str] | None = None, + stdout: IO[str] | None = None, + command: Sequence[str] | None = None, + cwd: str | None = None, + env: dict[str, str] | None = None, + ) -> None: + super().__init__() + self._stdin = stdin + self._stdout = stdout + self._command = list(command) if command is not None else None + self._cwd = cwd + self._env = env + self._process: asyncio.subprocess.Process | None = None + self._reader_task: asyncio.Task[None] | None = None + + async def start(self) -> None: + self._closed.clear() + if self._command is not None: + self._process = await asyncio.create_subprocess_exec( + *self._command, + cwd=self._cwd, + env=self._env, + stdin=asyncio.subprocess.PIPE, + stdout=asyncio.subprocess.PIPE, + stderr=sys.stderr, + ) + self._reader_task = asyncio.create_task(self._read_process_loop()) + return + + self._stdin = self._stdin or sys.stdin + self._stdout = self._stdout or sys.stdout + self._reader_task = asyncio.create_task(self._read_file_loop()) + + async def stop(self) -> None: + if self._reader_task is not None: + self._reader_task.cancel() + try: + await self._reader_task + except asyncio.CancelledError: + pass + self._reader_task = None + + if self._process is not None: + if self._process.returncode is None: + self._process.terminate() + try: + await asyncio.wait_for(self._process.wait(), timeout=5) + except asyncio.TimeoutError: + self._process.kill() + await self._process.wait() + self._process = None + self._closed.set() + + async def send(self, payload: str) -> None: + line = payload if payload.endswith("\n") else f"{payload}\n" + if self._process is not None: + if self._process.stdin is None: + raise RuntimeError("STDIO subprocess stdin 不可用") + self._process.stdin.write(line.encode("utf-8")) + await self._process.stdin.drain() + return + + if self._stdout is None: + raise RuntimeError("STDIO stdout 不可用") + + def _write() -> None: + assert self._stdout is not None + self._stdout.write(line) + self._stdout.flush() + + await asyncio.to_thread(_write) + + async def _read_process_loop(self) -> None: + assert self._process is not None + assert self._process.stdout is not None + try: + while True: + raw = await self._process.stdout.readline() + if not raw: + break + await self._dispatch(raw.decode("utf-8").rstrip("\r\n")) + finally: + self._closed.set() + + async def _read_file_loop(self) -> None: + assert self._stdin is not None + try: + while True: + raw = await asyncio.to_thread(self._stdin.readline) + if not raw: + break + await self._dispatch(raw.rstrip("\r\n")) + finally: + self._closed.set() + + +class WebSocketServerTransport(Transport): + def __init__( + self, + *, + host: str = "127.0.0.1", + port: int = 8765, + path: str = "/", + heartbeat: float = 30.0, + ) -> None: + super().__init__() + self._host = host + self._port = port + self._actual_port: int | None = None + self._path = path + self._heartbeat = heartbeat + self._app: web.Application | None = None + self._runner: web.AppRunner | None = None + self._site: web.TCPSite | None = None + self._ws: web.WebSocketResponse | None = None + self._write_lock = asyncio.Lock() + + async def start(self) -> None: + self._closed.clear() + self._app = web.Application() + self._app.router.add_get(self._path, self._handle_socket) + self._runner = web.AppRunner(self._app) + await self._runner.setup() + self._site = web.TCPSite(self._runner, self._host, self._port) + await self._site.start() + if self._site._server and getattr(self._site._server, "sockets", None): + socket = self._site._server.sockets[0] + self._actual_port = socket.getsockname()[1] + + async def stop(self) -> None: + if self._ws is not None and not self._ws.closed: + await self._ws.close() + if self._site is not None: + await self._site.stop() + self._site = None + if self._runner is not None: + await self._runner.cleanup() + self._runner = None + self._closed.set() + + async def send(self, payload: str) -> None: + if self._ws is None or self._ws.closed: + raise RuntimeError("WebSocket 尚未连接") + async with self._write_lock: + await self._ws.send_str(payload) + + async def _handle_socket(self, request: web.Request) -> web.WebSocketResponse: + if self._ws is not None and not self._ws.closed: + ws = web.WebSocketResponse() + await ws.prepare(request) + await ws.close(code=1008, message=b"only one websocket connection allowed") + return ws + + ws = web.WebSocketResponse( + heartbeat=self._heartbeat if self._heartbeat > 0 else None + ) + await ws.prepare(request) + self._ws = ws + try: + async for msg in ws: + if msg.type == aiohttp.WSMsgType.TEXT: + await self._dispatch(msg.data) + elif msg.type == aiohttp.WSMsgType.BINARY: + await self._dispatch(msg.data.decode("utf-8")) + elif msg.type == aiohttp.WSMsgType.ERROR: + logger.error("websocket server error: {}", ws.exception()) + break + finally: + self._closed.set() + self._ws = None + return ws + + @property + def port(self) -> int: + return self._actual_port or self._port + + @property + def url(self) -> str: + return f"ws://{self._host}:{self.port}{self._path}" + + +class WebSocketClientTransport(Transport): + def __init__( + self, + *, + url: str, + heartbeat: float = 30.0, + ) -> None: + super().__init__() + self._url = url + self._heartbeat = heartbeat + self._session: aiohttp.ClientSession | None = None + self._ws: aiohttp.ClientWebSocketResponse | None = None + self._reader_task: asyncio.Task[None] | None = None + + async def start(self) -> None: + self._closed.clear() + self._session = aiohttp.ClientSession() + self._ws = await self._session.ws_connect( + self._url, + heartbeat=self._heartbeat if self._heartbeat > 0 else None, + ) + self._reader_task = asyncio.create_task(self._read_loop()) + + async def stop(self) -> None: + if self._reader_task is not None: + self._reader_task.cancel() + try: + await self._reader_task + except asyncio.CancelledError: + pass + self._reader_task = None + if self._ws is not None and not self._ws.closed: + await self._ws.close() + if self._session is not None: + await self._session.close() + self._ws = None + self._session = None + self._closed.set() + + async def send(self, payload: str) -> None: + if self._ws is None or self._ws.closed: + raise RuntimeError("WebSocket client 尚未连接") + await self._ws.send_str(payload) + + async def _read_loop(self) -> None: + assert self._ws is not None + try: + async for msg in self._ws: + if msg.type == aiohttp.WSMsgType.TEXT: + await self._dispatch(msg.data) + elif msg.type == aiohttp.WSMsgType.BINARY: + await self._dispatch(msg.data.decode("utf-8")) + elif msg.type == aiohttp.WSMsgType.ERROR: + logger.error("websocket client error: {}", self._ws.exception()) + break + finally: + self._closed.set() diff --git a/src-new/astrbot_sdk/star.py b/src-new/astrbot_sdk/star.py new file mode 100644 index 000000000..4cdc80506 --- /dev/null +++ b/src-new/astrbot_sdk/star.py @@ -0,0 +1,26 @@ +from __future__ import annotations + +import traceback +from typing import Any + +from loguru import logger + +from .errors import AstrBotError + + +class Star: + async def on_error(self, error: Exception, event, ctx) -> None: + if isinstance(error, AstrBotError): + if error.retryable: + await event.reply("请求失败,请稍后重试") + elif error.hint: + await event.reply(error.hint) + else: + await event.reply(error.message) + else: + await event.reply("出了点问题,请联系插件作者") + logger.error("handler 执行失败\n{}", traceback.format_exc()) + + @classmethod + def __astrbot_is_new_star__(cls) -> bool: + return True diff --git a/tests_v4/__init__.py b/tests_v4/__init__.py new file mode 100644 index 000000000..c9c2ef67b --- /dev/null +++ b/tests_v4/__init__.py @@ -0,0 +1 @@ +__all__: list[str] = [] diff --git a/tests_v4/helpers.py b/tests_v4/helpers.py new file mode 100644 index 000000000..528231f69 --- /dev/null +++ b/tests_v4/helpers.py @@ -0,0 +1,40 @@ +from __future__ import annotations + +import asyncio +from pathlib import Path + +from astrbot_sdk.runtime.transport import Transport + + +class MemoryTransport(Transport): + def __init__(self) -> None: + super().__init__() + self.partner: "MemoryTransport | None" = None + + async def start(self) -> None: + self._closed.clear() + + async def stop(self) -> None: + self._closed.set() + + async def send(self, payload: str) -> None: + if self.partner is None: + raise RuntimeError("MemoryTransport 未连接 partner") + await self.partner._dispatch(payload) + + +def make_transport_pair() -> tuple[MemoryTransport, MemoryTransport]: + left = MemoryTransport() + right = MemoryTransport() + left.partner = right + right.partner = left + return left, right + + +class FakeEnvManager: + def prepare_environment(self, _plugin) -> Path: + return Path(__import__("sys").executable) + + +async def drain_loop() -> None: + await asyncio.sleep(0.05) diff --git a/tests_v4/test_entrypoints.py b/tests_v4/test_entrypoints.py new file mode 100644 index 000000000..46f7470d4 --- /dev/null +++ b/tests_v4/test_entrypoints.py @@ -0,0 +1,37 @@ +from __future__ import annotations + +import subprocess +import sys +import unittest + + +class EntryPointTest(unittest.TestCase): + def test_import_package(self) -> None: + process = subprocess.run( + [sys.executable, "-c", "import astrbot_sdk; print(astrbot_sdk.__name__)"], + capture_output=True, + text=True, + check=False, + ) + self.assertEqual(process.returncode, 0, process.stderr) + self.assertIn("astrbot_sdk", process.stdout) + + def test_module_help(self) -> None: + process = subprocess.run( + [sys.executable, "-m", "astrbot_sdk", "--help"], + capture_output=True, + text=True, + check=False, + ) + self.assertEqual(process.returncode, 0, process.stderr) + self.assertIn("Usage", process.stdout) + + def test_run_help(self) -> None: + process = subprocess.run( + [sys.executable, "-m", "astrbot_sdk", "run", "--help"], + capture_output=True, + text=True, + check=False, + ) + self.assertEqual(process.returncode, 0, process.stderr) + self.assertIn("--plugins-dir", process.stdout) diff --git a/tests_v4/test_peer.py b/tests_v4/test_peer.py new file mode 100644 index 000000000..e307c8e1b --- /dev/null +++ b/tests_v4/test_peer.py @@ -0,0 +1,189 @@ +from __future__ import annotations + +import asyncio +import unittest + +from astrbot_sdk.context import CancelToken +from astrbot_sdk.errors import AstrBotError +from astrbot_sdk.protocol.descriptors import CapabilityDescriptor +from astrbot_sdk.protocol.messages import EventMessage, InitializeOutput, PeerInfo, ResultMessage +from astrbot_sdk.runtime.capability_router import CapabilityRouter, StreamExecution +from astrbot_sdk.runtime.peer import Peer +from astrbot_sdk.runtime.transport import WebSocketClientTransport, WebSocketServerTransport + +from tests_v4.helpers import make_transport_pair + + +class PeerRuntimeTest(unittest.IsolatedAsyncioTestCase): + async def asyncSetUp(self) -> None: + self.left, self.right = make_transport_pair() + + async def test_initialize_and_call_builtin_capabilities(self) -> None: + router = CapabilityRouter() + core = Peer( + transport=self.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, + ) + ) + + plugin = Peer( + transport=self.right, + peer_info=PeerInfo(name="plugin", role="plugin", version="v4"), + ) + + await core.start() + await plugin.start() + await plugin.initialize([]) + + result = await plugin.invoke("llm.chat", {"prompt": "hello"}) + self.assertEqual(result["text"], "Echo: hello") + + stream = await plugin.invoke_stream("llm.stream_chat", {"prompt": "hi"}) + chunks = [event.data["text"] async for event in stream] + self.assertEqual("".join(chunks), "Echo: hi") + + await plugin.stop() + await core.stop() + + async def test_stream_false_receiving_event_is_protocol_error(self) -> None: + plugin = Peer( + transport=self.right, + peer_info=PeerInfo(name="plugin", role="plugin", version="v4"), + ) + await self.left.start() + await plugin.start() + + task = asyncio.create_task( + plugin.invoke("llm.chat", {"prompt": "bad"}, request_id="req-1") + ) + await asyncio.sleep(0) + await self.left.send(EventMessage(id="req-1", phase="started").model_dump_json()) + + with self.assertRaises(AstrBotError) as raised: + await task + self.assertEqual(raised.exception.code, "protocol_error") + await plugin.stop() + await self.left.stop() + + async def test_stream_true_receiving_result_is_protocol_error(self) -> None: + plugin = Peer( + transport=self.right, + peer_info=PeerInfo(name="plugin", role="plugin", version="v4"), + ) + await self.left.start() + await plugin.start() + + stream = await plugin.invoke_stream("llm.stream_chat", {"prompt": "bad"}, request_id="stream-1") + await self.left.send(ResultMessage(id="stream-1", success=True, output={}).model_dump_json()) + + with self.assertRaises(AstrBotError) as raised: + async for _ in stream: + pass + self.assertEqual(raised.exception.code, "protocol_error") + await plugin.stop() + await self.left.stop() + + async def test_cancel_waits_for_failed_terminal_event(self) -> None: + descriptor = CapabilityDescriptor( + name="slow.stream", + description="slow stream", + input_schema={"type": "object", "properties": {}, "required": []}, + output_schema={"type": "object", "properties": {"count": {"type": "number"}}, "required": ["count"]}, + supports_stream=True, + cancelable=True, + ) + + async def init_handler(_message): + return InitializeOutput( + peer=PeerInfo(name="core", role="core", version="v4"), + capabilities=[descriptor], + metadata={}, + ) + + async def invoke_handler(message, token: CancelToken): + async def iterator(): + while True: + token.raise_if_cancelled() + await asyncio.sleep(0.01) + yield {"text": "x"} + + return StreamExecution( + iterator=iterator(), + finalize=lambda chunks: {"count": len(chunks)}, + ) + + core = Peer( + transport=self.left, + peer_info=PeerInfo(name="core", role="core", version="v4"), + ) + core.set_initialize_handler(init_handler) + core.set_invoke_handler(invoke_handler) + plugin = Peer( + transport=self.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.stream", {}, request_id="cancel-1") + first = await anext(stream) + self.assertEqual(first.data["text"], "x") + await plugin.cancel("cancel-1") + + with self.assertRaises(AstrBotError) as raised: + await anext(stream) + self.assertEqual(raised.exception.code, "cancelled") + await plugin.stop() + await core.stop() + + async def test_websocket_transport_smoke(self) -> None: + router = CapabilityRouter() + server_transport = WebSocketServerTransport(port=0) + core = Peer( + transport=server_transport, + 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, + ) + ) + + await core.start() + client_transport = WebSocketClientTransport(url=server_transport.url) + plugin = Peer( + transport=client_transport, + peer_info=PeerInfo(name="plugin", role="plugin", version="v4"), + ) + await plugin.start() + await plugin.initialize([]) + result = await plugin.invoke("llm.chat", {"prompt": "ws"}) + self.assertEqual(result["text"], "Echo: ws") + await plugin.stop() + await core.stop() diff --git a/tests_v4/test_protocol.py b/tests_v4/test_protocol.py new file mode 100644 index 000000000..8d828df71 --- /dev/null +++ b/tests_v4/test_protocol.py @@ -0,0 +1,53 @@ +from __future__ import annotations + +import unittest + +from astrbot_sdk.protocol.descriptors import ( + CommandTrigger, + HandlerDescriptor, + ScheduleTrigger, +) +from astrbot_sdk.protocol.messages import ( + CancelMessage, + EventMessage, + InitializeMessage, + InvokeMessage, + PeerInfo, + ResultMessage, + parse_message, +) + + +class ProtocolModelsTest(unittest.TestCase): + def test_parse_message_roundtrip(self) -> None: + message = InitializeMessage( + id="msg_001", + protocol_version="1.0", + peer=PeerInfo(name="plugin", role="plugin", version="1.0.0"), + handlers=[ + HandlerDescriptor( + id="handler_1", + trigger=CommandTrigger(command="hello"), + ) + ], + metadata={"a": 1}, + ) + parsed = parse_message(message.model_dump_json()) + self.assertEqual(parsed.id, "msg_001") + self.assertEqual(parsed.peer.name, "plugin") + + for sample in [ + InvokeMessage(id="msg_002", capability="llm.chat", input={"prompt": "hi"}), + ResultMessage(id="msg_002", success=True, output={"text": "ok"}), + EventMessage(id="msg_003", phase="started"), + CancelMessage(id="msg_003"), + ]: + self.assertEqual(parse_message(sample.model_dump()).type, sample.type) + + def test_schedule_trigger_requires_exactly_one_strategy(self) -> None: + with self.assertRaises(ValueError): + ScheduleTrigger() + with self.assertRaises(ValueError): + ScheduleTrigger(cron="* * * * *", interval_seconds=10) + trigger = ScheduleTrigger(interval_seconds=30) + self.assertEqual(trigger.interval_seconds, 30) diff --git a/tests_v4/test_runtime.py b/tests_v4/test_runtime.py new file mode 100644 index 000000000..1ac536d62 --- /dev/null +++ b/tests_v4/test_runtime.py @@ -0,0 +1,149 @@ +from __future__ import annotations + +import asyncio +import shutil +import sys +import tempfile +import textwrap +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, make_transport_pair + + +def write_new_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: v4_plugin + display_name: V4 Plugin + desc: 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): + reply = await ctx.llm.chat(event.text) + await event.reply(reply) + chunks = [] + async for chunk in ctx.llm.stream_chat("stream"): + chunks.append(chunk) + await event.reply("".join(chunks)) + """ + ), + encoding="utf-8", + ) + + +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() + + 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" + write_new_plugin(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.core.remote_handlers[0].id + + 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_compat_plugin(self) -> None: + with tempfile.TemporaryDirectory() as temp_dir: + plugins_root = Path(temp_dir) / "plugins" + plugin_root = plugins_root / "compat_plugin" + shutil.copytree(Path.cwd() / "test_plugin", 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.core.remote_handlers[0].id + + 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), 4) + self.assertIn("Created conversation ID", texts[0]) + finally: + await runtime.stop()