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 000000000..f9dc50ca2 Binary files /dev/null and b/src-new.rar differ 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()