feat: add stable proactive delivery

This commit is contained in:
xmanrui 2026-08-30 11:24:53 +08:00
parent 410cb822d7
commit 31792d7bc8
88 changed files with 6427 additions and 488 deletions

View file

@ -1,8 +1,8 @@
# Issue #65 / #84:基于 `botId + targetId` 的九渠道稳定主动投递方案
日期:2026-08-30。代码基线:v4.0.1 / `92ec91b`。状态:待实施。
日期:2026-08-30。代码基线:v4.0.1 / `92ec91b`。状态:主动投递已实现并通过九渠道真实环境验收;已聊候选选择已通过九渠道自动化测试和最新版 DSH 真实页面验证。
需求来源:[Issue #65](https://github.com/xmanrui/dsh-im/issues/65)、[Issue #84](https://github.com/xmanrui/dsh-im/issues/84)。本文是实现设计和验收依据,不代表功能已经上线。
需求来源:[Issue #65](https://github.com/xmanrui/dsh-im/issues/65)、[Issue #84](https://github.com/xmanrui/dsh-im/issues/84)。本文记录实现设计与已完成的验收结果。
## 1. 最终决定
@ -76,15 +76,16 @@
3. 目标配置在 Host 重启后仍然存在;机器人删除时一并清理。
4. #65 和 #84 的发送请求经过同一个核心服务和同一个渠道适配器。
5. 机器人卡片右上角增加设置齿轮;卡片已有身份、状态、工作区、Agent Preset、上下文增强、连接检查和移除入口保持不变。
6. 设置页展示可复制的真实 `botId`,并提供目标的新建、编辑、删除、复制调用参数和发送测试消息。
6. 设置页展示可复制的真实 `botId`,并提供目标的新建、编辑、删除和复制调用参数;每个已保存的 `targetId` 都有自己的测试按钮。
7. 目标配置不依赖机器人在线;实际发送和测试要求机器人当前已连接。
8. 用户负责从对应平台取得原生用户、群、频道、话题或线程标识;页面提供字段名和格式提示。
8. 设置页优先列出该机器人已持久化的聊天会话候选;用户选择后自动填入渠道原生路由,再确认稳定的 `targetId`。无候选或需要其他地址时,仍可手动填写并查看字段格式提示。
9. 严格校验 RPC 端点、字段和渠道路由,不把平台原始错误、凭据或临时回复上下文返回给调用方。
10. 保持所有现有入站回复、连接检查和文件发送行为不变。
### 3.2 本期明确不做
- 不自动扫描最近聊天,不提供 `listChats`,不从连接检查记忆或会话映射自动导入目标。
- 不调用平台 API 扫描联系人、群或频道全量目录,不提供公共 `listChats/chatRef` 发送协议;候选仅来自 dsh-im 已持久化的 conversation key。
- 不返回聊天正文、Harness `sessionId`、消息 ID 或临时回复对象,也不承诺候选具有会话名称、最后活跃时间或平台全量覆盖。
- 不提供绑定码、认领流程、用户目录同步或跨渠道身份合并。
- 不接受 `sessionId`、`chatRef`、`sessionWebhook`、消息 ID 或 `replyToMessageId` 作为公共发送参数。
- 不支持图片、文件、卡片、Markdown 类型选择;首期公共能力只有文字,渠道内部继续复用现有文字分段逻辑。
@ -100,11 +101,14 @@
```text
Bot(现有机器人)
└── DeliveryTarget(投递目标,0..n)
├── targetId:对调用方稳定的键
├── name:可选显示名称
├── kind:渠道内的目标类型
└── route:可更新的渠道原生路由
├── DeliveryTarget(投递目标,0..n)
│ ├── targetId:对调用方稳定的键
│ ├── name:可选显示名称
│ ├── kind:渠道内的目标类型
│ └── route:可更新的渠道原生路由
└── TargetSuggestion(临时候选,0..n)
├── kind / route:从持久化 conversation key 解析
└── 不含 targetId、sessionId 或聊天正文
```
投递目标的唯一键是 `(botId, targetId)`,不是全局 `targetId`。两个机器人可以各自拥有名为 `daily-report` 的目标,互不影响。
@ -119,6 +123,7 @@ Bot(现有机器人)
- 更新目标时完整替换 `name + kind + route`,不做深层 PATCH,避免残留旧类型字段。
- 发送开始时读取一份目标快照。并发更新可能使正在发送的那一次使用旧路由,但更新完成后的下一次发送必须使用新路由。
- 删除目标不取消已经交给渠道的发送;删除完成后的新请求返回 `unknown-target`。
- 候选不是已保存目标,不占用 `targetId`;只有用户选择候选、确认表单并保存后,才创建 `DeliveryTarget`。
## 5. 总体架构
@ -126,22 +131,23 @@ Bot(现有机器人)
flowchart LR
A[同 Host Cordis 插件<br/>Issue #65] -->|ctx.dshIm.send| C[DeliveryService]
B[进程外调用方<br/>Issue #84] -->|Connection RPC| R[Delivery RPC]
U[机器人设置页] -->|目标管理 / 测试| R
U[机器人设置页] -->|候选 / 目标管理 / 测试| R
R --> C
C -->|按 botId 选择| G[九渠道 Adapter Registry]
G --> T[BotWorkspaceStore<br/>目标配置]
G --> K[各渠道持久会话状态<br/>conversation keys]
G --> S[未包装 coreController<br/>现有 Runtime / BotClient]
S --> P[IM 平台]
```
| 组件 | 职责 | 不负责 |
| --- | --- | --- |
| `DeliveryService` | 参数校验、按 `botId` 选择适配器、目标 CRUD、统一发送和错误码 | 不理解九渠道原生字段,不保存消息 |
| 渠道适配器 | 判断是否拥有机器人、校验本渠道 `kind/route`、调用对应核心 controller | 不暴露 RPC,不管理调用方业务 |
| `BotWorkspaceStore` | 持久化机器人下的目标,复用现有原子写入、机器人队列和删除清理 | 不发送消息,不发现聊天 |
| `DeliveryService` | 参数校验、按 `botId` 选择适配器、目标 CRUD、候选列表、统一发送和错误码 | 不理解九渠道 conversation key 或原生字段,不保存消息 |
| 渠道适配器 | 判断是否拥有机器人、把本渠道持久化 conversation key 解析为候选、校验 `kind/route`、调用对应核心 controller | 不暴露 RPC,不管理调用方业务 |
| `BotWorkspaceStore` | 持久化机器人下的已保存目标,复用现有原子写入、机器人队列和删除清理 | 不发送消息,不把候选自动保存为目标 |
| Cordis 服务 | 把 #65 调用转发给 `DeliveryService` | 不复制渠道选择逻辑 |
| Delivery RPC | 把 #84 和设置页请求转发给 `DeliveryService`,转换安全错误包络 | 不直接调用 Runtime |
| 设置页 | 展示 `botId`、编辑渠道路由、调用测试 | 不推断或抓取目标 ID |
| 设置页 | 展示 `botId`、让用户从已聊候选选择或手动编辑渠道路由、调用测试 | 不解析 conversation key,不读取聊天正文,不查询平台全量目录 |
### 5.1 为什么使用单独 RPC 通道
@ -217,6 +223,7 @@ deleteDeliveryTarget(botId, targetId)
```js
service.registerAdapter(adapter) // 返回 unregister
service.listTargets(botId)
service.listSuggestions(botId)
service.createTarget(botId, target)
service.updateTarget(botId, targetId, replacement)
service.deleteTarget(botId, targetId)
@ -239,6 +246,7 @@ service.send(botId, targetId, text, { signal } = {})
channel: 'feishu',
ownsBot(botId),
listTargets(botId),
listSuggestions(botId),
createTarget(botId, target),
updateTarget(botId, targetId, replacement),
deleteTarget(botId, targetId),
@ -247,6 +255,7 @@ service.send(botId, targetId, text, { signal } = {})
```
- `ownsBot()` 使用同一生产组合中的 `workspaces.has(botId)`,删除中的机器人不会继续接受新发送。
- `listSuggestions()` 只读该机器人已持久化的 conversation keys,输出经本渠道严格校验的 `kind/route`;它不要求 Runtime 在线,也不调用平台 API。
- 公共服务不从 `botId` 前缀猜渠道;前缀只是现有实现细节。
- 每个生产组合把自身 `workspaces` 和未包装 `coreController` 闭包进适配器。
- 适配器只在对应渠道成功启动后注册,关闭渠道时注销。
@ -303,7 +312,7 @@ await ctx.dshIm.send(
- 服务名沿用 #65 建议的 `dshIm`,方法名保持最小,只暴露 `send` 和 `listTargets`。
- 目标 CRUD 由设置页/管理 RPC 完成,避免 Host 插件顺便承担配置 UI。
- `listTargets()` 返回 `{ targetId, name, kind, route }[]`,不再提供会话发现语义的 `listChats()`。
- `listTargets()` 返回已保存的 `{ targetId, name, kind, route }[]`。Cordis 公共服务不暴露 `listChats/chatRef`;设置页候选使用第 9 节的专用 RPC 端点。
- Cordis 服务和 RPC 持有的是同一个 `DeliveryService` 实例;测试必须证明两者不是两套 registry。
- Host/渠道关闭时用现有 `ctx.effect()` 注销服务、RPC 和渠道适配器,不留下失效 Runtime 引用。
@ -321,12 +330,15 @@ export const DELIVERY_RPC_CHANNEL = '/dsh-im-delivery';
| --- | --- | --- |
| `message.send` | `{ botId, targetId, text }` | `{ sent: true }` |
| `target.list` | `{ botId }` | `{ botId, channel, targets }` |
| `target.suggestion.list` | `{ botId }` | `{ botId, channel, suggestions: [{ kind, route }] }` |
| `target.create` | `{ botId, target: { targetId, name?, kind, route } }` | 新目标完整记录 |
| `target.update` | `{ botId, targetId, target: { name?, kind, route } }` | 更新后的完整记录 |
| `target.delete` | `{ botId, targetId }` | `{ deleted: true }` |
| `target.test` | `{ botId, targetId }` | `{ sent: true }` |
| `target.test` | 已保存目标:`{ botId, targetId }`;表单草稿:`{ botId, target: { kind, route } }` | `{ sent: true }` |
`target.test` 调用同一个 `DeliveryService.send()`,内容使用固定本地化文案“DSH-IM 主动投递测试成功。”。它与现有“检查连接”是两个功能:检查连接验证机器人连接和默认测试目标,目标测试验证用户配置的指定路由。
`target.test` 使用固定本地化文案“DSH-IM 主动投递测试成功。”。已保存目标行传 `{ botId, targetId }`,由同一个 `DeliveryService.send()` 解析已保存路由;新建或编辑表单传 `{ botId, target: { kind, route } }`,直接校验并测试当前表单路由。两种 payload 严格二选一,表单测试不携带 `targetId/name`,也不创建、更新或落盘目标。它与现有“检查连接”是两个功能:检查连接验证机器人连接,目标测试验证指定渠道路由能否真正发送消息。
`target.suggestion.list` 是设置页专用的候选查询:渠道适配器从该机器人的持久化 conversation keys 解析出可主动投递的 `kind/route`。每个 suggestion 严格只含这两个字段,不含 `targetId`、`name`、时间、`sessionId`、聊天正文、消息 ID 或回复对象。选择 suggestion 只是预填新建表单,不会自动创建目标。
### 9.2 调用示例
@ -357,6 +369,7 @@ const result = await connection.rpc.call(
- 顶层已有 `rpcAuthority: 'trusted-host'` 配置时沿用现有解析机制;不新增第二个同义配置。
- `target.*` 和 `message.send` 使用相同可达性边界。本期不进一步区分管理员和发送者权限。
- 每个端点只接受表中列出的键;缺字段、额外字段、数组冒充对象或已取消请求均拒绝。
- `target.suggestion.list` 不要求机器人当前在线;未知机器人仍返回 `unknown-bot`,无候选时成功返回空数组。
- 该 RPC 是 #84 的对外程序接口;不再另建 Express 路由、REST Server 或 Webhook 接收器。
## 10. 九渠道适配
@ -375,9 +388,29 @@ const result = await connection.rpc.call(
| Discord | `channel` | `{ channelId }`;私信、频道和 Thread 都使用可发消息的 Channel ID | `DiscordBotClient.sendText()`,不带回复消息 ID/notice |
| WhatsApp | `user` / `group` | `{ jid }`,分别接受用户 JID 或群 JID | `WhatsappBotClient.sendText()`,不带 `quoted` |
注意:表中的 `route` 是内部持久化结构,#65/#84 的发送调用都不传这些字段。
注意:表中的 `route` 是内部持久化结构,#65/#84 的发送调用都不传这些字段。设置页候选只用同一份 `kind/route` 预填表单。
### 10.2 各渠道的实现要点
### 10.2 已聊候选的来源
候选不是平台聊天列表。它仅读取每个机器人状态文件中已持久化的 `sessions` conversation keys,按渠道规则转换并去重:
| 渠道 | 持久化 conversation key | suggestion `kind/route` |
| --- | --- | --- |
| 微信 | `p2p:<userId>` | `user / { toUserId }` |
| 飞书 | `p2p:<openId>`、`group:<chatId>` | `user / { openId }`、`group / { chatId }` |
| 钉钉 | `p2p:<staffId>`、`group:<conversationId>` | `user / { userId }`、`group / { openConversationId }` |
| 企业微信 | `direct:<userId>`、`group:<chatId>` | `user / { chatId }`、`group / { chatId }` |
| QQ | `c2c:<userOpenId>`、`group:<groupOpenId>` | `user / { userOpenId }`、`group / { groupOpenId }` |
| Slack | `direct:<channelId>`、`group:<channelId>:<threadTs>` | `conversation / { channelId }`、`thread / { channelId, threadTs }` |
| Telegram | `direct:<chatId>`、`group:<chatId>[:<messageThreadId>]` | `chat / { chatId }`、`topic / { chatId, messageThreadId }` |
| Discord | `direct|group:<channelId>` | `channel / { channelId }` |
| WhatsApp | `direct:<userJid>`、`group:<groupJid>` | `user / { jid }`、`group / { jid }` |
conversation key 只证明 dsh-im 曾经为该聊天建立持久会话映射。它不带最后活跃时间或可靠的平台会话名称,且可能因工作区切换、会话清理或从未建立 Harness Session 而不完整。因此产品文案使用“已聊过的会话”而不是“平台最近聊天”,手动填写始终作为高级兜底。
飞书私聊候选只接受能明确识别为 `open_id` 的 `ou_...` key;旧事件若仅提供 `user_id`,由于历史 key 没有记录 ID 类型,将安全忽略而不生成不可投递候选。WhatsApp 群候选同时接受当前数字群 JID 和带连字符的旧式群 JID。
### 10.3 各渠道的实现要点
1. **微信**:只支持用户目标。复用现有 API 的无上下文文字发送能力;不把入站 `contextToken` 或当前 `runId` 保存进目标。
2. **飞书**:`kind` 决定 `receive_id_type` 为 `open_id` 或 `chat_id`。把连接检查中的局部发送函数提取为 Runtime 的稳定文字发送方法,继续使用同一 SDK Client。
@ -389,12 +422,13 @@ const result = await connection.rpc.call(
8. **Discord**:DM 也要求已经存在且机器人可访问的 Channel ID;Thread 本身同样是 Channel ID,因此无需额外线程字段。
9. **WhatsApp**:只接受当前 SDK 支持的用户或群 JID,拒绝 Status/Broadcast 等特殊 JID。发送目标只有 `jid`,不保存引用消息对象或 `selfChat` 状态。
### 10.3 共用已有代码,但不复用临时目标
### 10.4 共用已有代码,但不复用临时目标
- Slack、Telegram、Discord 已共用 `TextHarnessBridge`。为它增加一个只委托 `#bot.sendText()` 的公共方法,三个 Runtime 再统一暴露 `sendProactiveText()`,不复制分段代码。
- Telegram 和 Discord 继续通过 `TokenBotController` 统一委托 Runtime;其他 controller 各增加同名薄方法。
- 渠道的 `sendText()` 继续负责自身长度切分和平台错误分类;`DeliveryService` 不做统一切段。
- 现有 `sendConnectionTest()` 可以复用底层发送函数,但它记住的测试目标不会自动成为投递目标。
- 候选仅使用持久化 conversation key 中可证明为稳定地址的部分;连接检查 WeakMap、消息正文和平台临时路由都不是候选来源。
- 所有主动路由明确拒绝 `sessionId`、`sessionWebhook`、`contextToken`、`runId`、`messageId`、`replyToMessageId`、`quoted` 等瞬时字段。
## 11. 机器人卡片与设置页
@ -431,12 +465,12 @@ Bot ID
bot_7f4c9d... [复制]
投递目标 [新建目标]
每日汇报群
targetId: daily-report 群聊 · chat_id oc_xxx
每日汇报群 [群聊]
targetId: daily-report
[复制调用参数] [测试] [编辑] [删除]
告警负责人
targetId: ops-oncall 私聊 · open_id ou_xxx
告警负责人 [私聊]
targetId: ops-oncall
[复制调用参数] [测试] [编辑] [删除]
```
@ -444,13 +478,24 @@ targetId: ops-oncall 私聊 · open_id ou_xxx
- Bot ID 显示 `status.bots[].botId` 的真实值,使用等宽字体,可单独复制;不显示卡片上经过遮罩的平台应用 ID 来冒充。
- “复制调用参数”复制 JSON:`{ "botId": "...", "targetId": "..." }`,不包含路由和消息文本。
- 空状态说明“尚未配置投递目标,请从对应平台取得用户、群、频道或话题标识后新建”。
- 目标行展示名称、`targetId`、目标类型和一行渠道路由摘要;原生 ID 过长时视觉省略,复制值必须完整。
- 点击“新建目标”后默认进入“从已聊过的会话选择”,并通过 `target.suggestion.list` 加载候选。候选使用一个原生下拉选择框展示,不平铺卡片;无候选时提示“先在对应平台与机器人聊一条消息,再刷新”,同时保留“手动填写(高级)”。
- 下拉选项展示可用的本地名称、目标类型和脱敏路由摘要;不将这些展示值说成平台会话名称或最后活跃时间。
- 已存在相同 `kind + route` 的选项显示“已添加”并禁用;去重不依赖 `targetId`。
- 目标行预览只展示名称、目标类型徽标和 `targetId`;渠道原生路由仅在新建或编辑表单中显示,避免列表泄露实现细节并保持紧凑。
- 每个已保存目标行都固定显示“测试”按钮,点击后只把该行的 `{ botId, targetId }` 发送给 `target.test`。所有新建和编辑表单也显示“测试”按钮,使用当前表单中的 `{ kind, route }` 发送 `{ botId, target: { kind, route } }`;这不会先保存目标,手动填写与会话候选预填的表单行为一致。
- 测试期间仅禁用当前按钮并显示“测试中…”;平台发送成功后在对应目标行或表单内显示“测试消息已发送,请到目标会话确认”,失败则在同一位置显示该次安全错误并允许重试。测试结果只保留在页面内,不落盘、不改变目标健康状态。
- 删除先在页面内确认,并提示“使用这个 targetId 的外部调用将返回 unknown-target”。
- 返回机器人列表后保留当前渠道;尽量保留此前滚动位置,不重新切换渠道。
### 11.3 新建和编辑表单
新建流程:
1. 默认先显示已聊候选;点选后打开新建表单,自动预填 `kind/route`、本地兜底显示名称和未占用的随机 `targetId`(例如 `tgt_7f3a91c8d2e64b10`)。随机值不包含渠道或目标类型,用户保存前仍可改为有业务含义的别名。
2. 预填不是保存;用户可继续修改名称、`targetId` 和路由,并必须点击“保存目标”才调用 `target.create`。
3. 点击“手动填写(高级)”进入新建表单并同样预填随机 `targetId`,用于尚未出现在 conversation keys 或需要输入其他稳定地址的场景。
4. 无论从候选还是手动进入,新建表单都可在保存前点击“测试”;请求只使用当前 `kind/route`,不会调用 `target.create`。
共享表单字段:
1. `targetId`:必填;编辑时只读。
@ -458,9 +503,11 @@ targetId: ops-oncall 私聊 · open_id ou_xxx
3. 目标类型:仅展示该渠道支持的 `kind`。
4. 渠道路由字段:按第 10 节表格动态显示。
编辑表单同样测试当前字段值,而不是已保存记录中的旧路由;测试不调用 `target.update`。路由必填字段完整且机器人在线时按钮才可用,反馈显示在当前表单内。
实现一个共享 `DeliveryTargetSettingsPage`,用一份九渠道字段定义驱动标签、占位说明、类型选项和路由摘要,不复制九个页面。渠道自己的 `SettingsTab` 只维护 `selectedBot` 并传入 `channel/account/deliveryRpcCall/onBack`。
本期不提供“从最近聊天选择”“使用检查连接目标”“自动读取群列表”等快捷入口。用户负责取得平台原生 ID;页面只负责清楚说明当前字段需要哪一种 ID。
候选不来自连接检查目标或平台群列表,页面也不会读取聊天正文。它只把服务端已归一化的 `kind/route` 作为建立稳定投递目标的输入。
## 12. 最小代码改动建议
@ -468,9 +515,10 @@ targetId: ops-oncall 私聊 · open_id ou_xxx
| 文件 | 内容 |
| --- | --- |
| `plugin-src/host/delivery-service.mjs` | Adapter registry、目标 CRUD、统一 `send()`、Cordis 服务对象 |
| `plugin-src/host/delivery-rpc.mjs` | `/dsh-im-delivery` 端点、严格 payload 校验、安全错误包络 |
| `plugin-src/client/delivery-settings.js` | 设置页、目标表单、九渠道字段定义和调用参数复制 |
| `plugin-src/host/delivery-service.mjs` | Adapter registry、目标 CRUD、候选列表、统一 `send()`、Cordis 服务对象 |
| `plugin-src/host/delivery-suggestions.mjs` | 九渠道 conversation key 到稳定 `{ kind, route }` 的纯解析、畸形过滤和去重 |
| `plugin-src/host/delivery-rpc.mjs` | `/dsh-im-delivery` 端点(包括 `target.suggestion.list`)、严格 payload 校验、安全错误包络 |
| `plugin-src/client/delivery-settings.js` | 候选选择、设置页、目标表单、九渠道字段定义和调用参数复制 |
不需要新增第三方依赖。
@ -481,8 +529,9 @@ targetId: ops-oncall 私聊 · open_id ou_xxx
| `src/channels/shared/bot-workspace-store.mjs` | v2 文档、`deliveryTargets` CRUD、迁移和机器人删除清理 |
| `src/channels/shared/text-harness-bridge.mjs` | 为 Slack/Telegram/Discord 增加薄的文字发送委托 |
| `src/channels/shared/token-bot-controller.mjs` | 委托 Telegram/Discord Runtime 的主动文字发送 |
| `src/channels/wecom/state-store.mjs` | 补齐只读 `snapshot()`,使持久化 conversation keys 可与其他渠道一致提取候选 |
| `plugin-src/host/index.mjs` | 创建唯一服务、提供 `dshIm`、安装 Delivery RPC、向九渠道传注册回调 |
| `plugin-src/host/channels/shared/production.mjs` | 返回由 `coreController + workspaces` 构成的适配器 |
| `plugin-src/host/channels/shared/production.mjs` | 返回由 `coreController + workspaces + stateFor` 构成的适配器,候选可从磁盘状态离线读取 |
| `plugin-src/client/index.js` | 建立 `deliveryRpcCall` 并传给九渠道设置页 |
| `plugin-src/client/loopback-recovery.js` | 将 Delivery RPC 纳入现有 loopback 恢复包装 |
| `plugin-src/client/channel-card-meta.js` | 共享齿轮按钮 |
@ -491,7 +540,7 @@ targetId: ops-oncall 私聊 · open_id ou_xxx
### 12.3 渠道文件
- 共享 token 生产组合覆盖 Telegram、Discord;Slack 使用自己的生产组合。
- 飞书、微信、钉钉、企业微信、QQ、Slack、WhatsApp 的 production 返回同形适配器。
- 飞书、微信、钉钉、企业微信、QQ、Slack、WhatsApp 的 production 返回同形适配器,并向适配器注入对应的 `stateFor`。
- 九渠道现有 controller/runtime 各增加一个薄的 `sendProactiveText()`;底层继续调用现有 BotClient/API。
- 钉钉额外修改 `src/channels/dingtalk/dingtalk-api.mjs`,增加稳定用户/群文字发送方法。
- 九个客户端卡片把现有 `BotStatusMeta` 与共享齿轮放进右侧工具组;卡片其余 JSX 不搬迁、不重排。
@ -535,19 +584,36 @@ targetId: ops-oncall 私聊 · open_id ou_xxx
| C07 | 平台拒绝和网络失败 | 映射为安全公共错误,不泄露原始响应 |
| C08 | Cordis `send` 与 RPC `message.send` 使用同一 pair | 命中同一个 service spy、同一个适配器和同一返回语义 |
| C09 | Host/渠道关闭 | 适配器注销,旧 Runtime 不再可调用 |
| C10 | 发送成功 | 只返回 `{ sent: true }`,没有 handle、历史或幂等状态 |
| C10 | 列出已聊候选 | 按 `botId` 命中同一适配器,只返回 `{ kind, route }`;离线可读,无候选返回空数组 |
| C11 | 发送成功 | 只返回 `{ sent: true }`,没有 handle、历史或幂等状态 |
建议新增 `test/delivery-service.test.mjs`、`test/delivery-rpc.test.mjs`,并扩展 `test/host.test.mjs` 验证 `ctx.provide('dshIm')` 和清理生命周期。
### 13.3 RPC 契约
### 13.3 #65 Cordis 入口测试
- 六个端点分别覆盖成功、缺字段、额外字段、错误类型、未知端点和取消。
#65 只保留一个自动化入口用例;未知机器人、未知目标、离线和平台失败已经由第 13.2 节共享核心测试覆盖,不在 Cordis 入口重复一遍。真实环境则按第 13.7 节在九渠道各执行一次同样的最小发送。
**自动化用例:**在 Host 测试中提供 `dshIm`,让一个声明 `inject: ['dshIm']` 的假消费插件执行:
```js
await ctx.dshIm.send('bot_test', 'daily-report', '测试消息');
```
预置假目标和假渠道 sender,断言 sender 收到正确路由和文字一次、结果为 `{ sent: true }`,并断言 `connection.rpc.call` 为 0 次。这个用例证明 #65 使用 Cordis 服务而不是 #84 RPC。可直接放进现有 `test/host.test.mjs`,不新建复杂测试框架。
**真实冒烟用例:**每个渠道只选择一个当前可用机器人和一个可用目标,让同一 Host 内的最小测试插件调用一次 `ctx.dshIm.send()`。目标收到“#65 Cordis 主动投递测试”即通过,不要求把该渠道的私聊、群聊和线程类型全部测一遍。
### 13.4 RPC 契约
- 七个端点分别覆盖成功、缺字段、额外字段、错误类型、未知端点和取消。
- `target.suggestion.list` 只接受 `{ botId }`,并严格返回 `{ botId, channel, suggestions: [{ kind, route }] }`;混入 `sessionId`、正文、名称、时间或回复字段的适配器结果必须被拒绝或归一化掉。
- `target.test` 严格接受 `{ botId, targetId }` 或 `{ botId, target: { kind, route } }` 二者之一;前者测试已保存目标,后者测试未落盘的当前表单路由,且不得触发 `target.create/target.update`。
- `target.update` 不能提交新的 `targetId`;`target.create` 不能覆盖已有记录。
- 默认 authority 为 `loopback`,显式 `trusted-host` 走现有解析器;`/dsh-im` 更新 RPC 仍固定 loopback。
- `message.send` 只接受 `{ botId, targetId, text }`,显式验证 `sessionId/chatRef/idempotencyKey/route` 等额外字段会被拒绝。
- 错误包络只含允许的 code 和安全文案。
### 13.4 九渠道适配器契约
### 13.5 九渠道适配器契约
每个渠道至少有以下自动化用例:
@ -557,6 +623,7 @@ targetId: ops-oncall 私聊 · open_id ou_xxx
4. 长文本仍由该渠道现有分段函数处理,核心服务不重复切段。
5. 主动发送不携带回复消息、引用对象或最近会话状态。
6. 平台 sender 只调用一次业务发送入口;其内部分段次数按现有规则。
7. 从该渠道的持久化 conversation keys 解析候选,过滤畸形 key,对相同 `kind + route` 去重,且不读取 Harness `sessionId` 值。
重点断言:
@ -572,9 +639,11 @@ targetId: ops-oncall 私聊 · open_id ou_xxx
| Discord | 只传 `channelId + content`,没有 reply/notice |
| WhatsApp | 只传 `jid + text`,没有 `quoted` |
候选提取另用一张九渠道表驱动测试固定第 10.2 节的映射。每个渠道至少同时提供合法 key、重复 key 和畸形 key,断言输出项严格只有 `kind/route`,不含 `targetId/name/time/sessionId/text/messageId/replyTarget`。
这些测试加入各渠道现有 runtime/controller/production 测试文件,不为同一行为新建九套测试框架。
### 13.5 客户端测试
### 13.6 客户端测试
| 编号 | 用例 | 预期 |
| --- | --- | --- |
@ -585,42 +654,81 @@ targetId: ops-oncall 私聊 · open_id ou_xxx
| U05 | 编辑目标 | `targetId` 只读,名称/类型/路由可完整替换 |
| U06 | 删除目标 | 先确认,成功后移除;失败时保留并显示安全错误 |
| U07 | 复制调用参数 | JSON 只含正确 `botId + targetId` |
| U08 | 在线/离线测试 | 离线禁用测试但可编辑;在线测试调用 `target.test` |
| U08 | 每个目标行测试 | 每个已保存 `targetId` 都有测试按钮;点击哪一行就用该行 pair 调用 `target.test`,成功和失败反馈只显示在该行 |
| U09 | 窄屏和键盘操作 | 不横向溢出,焦点顺序、Tooltip、状态播报可用 |
| U10 | Delivery RPC 不可用 | 只影响目标设置,不破坏机器人列表和现有连接操作 |
| U11 | 候选列表 | 点击“新建目标”调用 `target.suggestion.list`,展示类型和脱敏路由摘要 |
| U12 | 选择候选 | 只预填 `kind/route`、本地兜底名称和未占用的随机 `targetId`;选择时不调用 `target.create` |
| U13 | 已添加候选 | 相同 `kind + route` 显示“已添加”并不可再选 |
| U14 | 候选空状态和高级兜底 | 提示先与机器人聊天再刷新;“手动填写(高级)”始终可用 |
| U15 | 新建/编辑表单测试 | 候选新建、手动新建和编辑表单均显示测试按钮;只提交当前 `{ kind, route }`,不调用 create/update,成功或失败反馈仅显示在当前表单 |
复用现有 `test/client-ui.test.mjs` 和九渠道 `client-ui.test.mjs` 的渲染方式;共享设置页可新增 `test/client-delivery-settings.test.mjs`。
### 13.6 回归和真实环境
### 13.7 回归和真实环境
自动化必须通过 `npm run check`,并特别回归:机器人接入/删除、断线重连、检查连接、工作区切换、Agent Preset、上下文增强、入站回复、Telegram 长轮询和 WhatsApp 回声过滤。
发布前为九渠道各配置至少一个真实目标并执行 `target.test`;支持群/线程的渠道再各验证一个非私聊目标。验收记录应保存渠道、目标类型、时间、结果和脱敏错误,不保存凭据或完整用户 ID。
本次按下表执行最小真实验收。每个渠道只选一个当前可用机器人和一个目标,并对同一组 `botId + targetId` 分别发送两条容易区分的消息:
- #65:同 Host 测试插件调用 `ctx.dshIm.send()`,消息带 `#65` 标识。
- #84:进程外测试调用 `/dsh-im-delivery` 的 `message.send`,消息带 `#84` 标识。
| 渠道 | 机器人和目标 | #65 | #84 | 目标测试 |
| --- | --- | --- | --- | --- |
| 微信 | 1 组已连接机器人和脱敏目标 | 通过 | 通过 | 通过 |
| 飞书 | 1 组已连接机器人和脱敏目标 | 通过 | 通过 | 通过 |
| 钉钉 | 1 组已连接机器人和脱敏目标 | 通过 | 通过 | 通过 |
| 企业微信 | 1 组已连接机器人和脱敏目标 | 通过 | 通过 | 通过 |
| QQ | 1 组已连接机器人和脱敏目标 | 通过 | 通过 | 通过 |
| Slack | 1 组已连接机器人和脱敏目标 | 通过 | 通过 | 通过 |
| Telegram | 1 组已连接机器人和脱敏目标 | 通过 | 通过 | 通过 |
| Discord | 1 组已连接机器人和脱敏目标 | 通过 | 通过 | 通过 |
| WhatsApp | 1 组已连接机器人和脱敏目标 | 通过 | 通过 | 通过 |
每个入口和目标测试各验证一次成功发送,不扩展到该渠道的所有目标类型,也不增加重启、并发或故障注入。验收记录只保存渠道、脱敏 `botId/targetId`、两个入口的时间和结果,不保存凭据或完整原生用户 ID。
### 13.8 当前实施验证记录(2026-08-30)
- `npm run check` 已通过:全部测试、构建和发布文件校验通过。
- 使用最新版 DSH 源码 `0.1.2-alpha.1-cd5ef81` 启动当前 `web` profile,最新版 Connection RPC 包络兼容验证通过。
- 真实宿主页面共加载 19 张现有机器人卡片,九个渠道的机器人卡片数量与齿轮按钮数量完全一致;九个设置页均能显示 Bot ID、新建表单和对应渠道的原生路由字段,浏览器控制台无错误。
- 已增加九渠道表驱动候选测试,固定 conversation key 到 `kind/route` 的映射、去重、畸形 key 过滤和临时字段隔离;RPC 和客户端测试另覆盖 `target.suggestion.list`、选择预填、已添加禁用及手动高级兜底。
- 在最新版 DSH 的真实设置页面逐渠道调用 `target.suggestion.list`,九个渠道均读取到至少一个已有会话候选;QQ 在选择存在历史会话的机器人后同样读取成功。点选候选可自动预填目标类型、原生路由和未占用的随机调用别名;验证过程未保存草稿、未发送消息,浏览器控制台无错误。
- 经使用者明确授权,从九渠道已有真实私聊/自聊的持久化入站路由中提取平台原生地址,为每个渠道保存一个机器人级 `self` 目标;只把平台地址写入 `route`,没有把 `sessionId` 当作投递地址。九份 `workspaces.json` 均由 v1 正常迁移为 v2,并可通过 `target.list` 回读。
- #84 进程外调用 `/dsh-im-delivery` 的 `message.send`,九渠道各真实发送一次,9/9 成功。
- 首次真实 #65 验证发现 `dshIm` 在最新版 DSH 的现代依赖注入组合中被提供在过窄作用域。实现已改为在 Host 插件根上下文提供服务,再把同一个 `DeliveryService` 传入延迟激活的渠道;现代 Cordis 注入回归测试已固定该行为。
- Host 重启后,一次性同 Host Cordis 插件仅注入 `dshIm` 并调用 `ctx.dshIm.send()`,九渠道各真实发送一次,9/9 成功;测试插件没有注入或调用 Connection RPC。
- 每个已保存目标又通过测试按钮所调用的 `target.test` 端点真实发送一次,九渠道 9/9 成功。WhatsApp 测试前发生一次平台连接离线,使用现有 `bot.reconnect` 恢复后,同一 `botId + targetId` 无需修改即测试成功。
- 验收记录只保留渠道和结果,不记录完整 `botId`、平台原生地址、凭据或会话 ID。
## 14. 验收标准
以下条件全部满足才能关闭 #65 和 #84:
1. 同 Host 插件可以注入 `dshIm`,使用 `send(botId, targetId, text)` 完成投递。
2. 进程外调用方可以通过 `/dsh-im-delivery` 的 `message.send` 使用同一组参数完成投递。
3. 自动化测试证明两个入口调用同一个 `DeliveryService`,不存在两套路由解析或渠道连接。
4. 九渠道均有生产适配器、自动化契约测试和一次真实环境成功记录。
5. Host 重启后同一 `botId + targetId` 仍可使用;编辑渠道路由后公共 pair 不变且下一次发送走新路由。
6. 公共发送接口没有 `sessionId`、`chatRef`、`sessionWebhook`、`deliveryHandle` 或 `idempotencyKey`。
7. 钉钉主动投递只使用稳定用户/群接口;临时 `sessionWebhook` 仅保留在原即时回复链。
8. 每种机器人卡片右上角都有齿轮,原卡片内容、顺序和连接检查功能没有退化。
9. 设置页可复制真实 Bot ID,并完整支持目标的新建、编辑、删除、复制 pair 和测试。
10. 一个机器人可配置多个目标;相同 `targetId` 在不同机器人下隔离。
11. 机器人离线时仍可管理目标;发送返回明确的 `bot-not-connected`,不建立隐式队列。
12. 删除机器人会清理其全部目标;删除失败回滚不会丢失目标。
13. dsh-im 不落主动发送历史、不自动重试、不管理幂等状态。
14. `npm run check` 全部通过,九渠道现有接入、回复和连接检查回归通过。
2. #65 自动化测试通过真实 Cordis `inject: ['dshIm']` 激活消费插件,成功发送期间 `connection.rpc.call` 为 0 次。
3. 进程外调用方可以通过 `/dsh-im-delivery` 的 `message.send` 使用同一组参数完成投递。
4. 自动化测试证明两个入口调用同一个 `DeliveryService`,不存在两套路由解析或渠道连接。
5. 九渠道各选择一个可用机器人和目标,#65 `ctx.dshIm.send()` 与 #84 `message.send` 均真实发送成功一次。
6. Host 重启后同一 `botId + targetId` 仍可使用;编辑渠道路由后公共 pair 不变且下一次发送走新路由。
7. 公共发送接口没有 `sessionId`、`chatRef`、`sessionWebhook`、`deliveryHandle` 或 `idempotencyKey`。
8. 钉钉主动投递只使用稳定用户/群接口;临时 `sessionWebhook` 仅保留在原即时回复链。
9. 每种机器人卡片右上角都有齿轮,原卡片内容、顺序和连接检查功能没有退化。
10. 设置页可从已聊过的会话候选预填新目标,候选仅含 `kind/route`;九渠道 conversation key 映射均有自动化测试,且手动填写高级兜底始终可用。
11. 设置页可复制真实 Bot ID,并完整支持目标的新建、编辑、删除和复制 pair;每个已保存 `targetId` 都有独立测试按钮,新建和编辑表单也可在不保存的情况下测试当前路由。
12. 一个机器人可配置多个目标;相同 `targetId` 在不同机器人下隔离。
13. 机器人离线时仍可管理目标并读取已持久化的候选;发送返回明确的 `bot-not-connected`,不建立隐式队列。
14. 删除机器人会清理其全部目标;删除失败回滚不会丢失目标。
15. dsh-im 不落主动发送历史、不自动重试、不管理幂等状态。
16. `npm run check` 全部通过,九渠道现有接入、回复和连接检查回归通过。
## 15. 风险与控制
| 风险 | 控制方式 |
| --- | --- |
| 用户填错平台 ID | 渠道严格校验、字段级提示和目标测试;不尝试从字符串猜测 |
| 用户填错平台 ID | 默认从已聊候选选择;手动输入作为高级兜底时,仍使用渠道严格校验、字段级提示和目标测试 |
| 候选被误解为平台全量最近聊天 | 文案明确仅来自持久化 conversation keys,不显示伪造的名称/时间,缺少时使用手动兜底 |
| 平台目标以后失效 | 保持 `targetId` 不变,用户只更新内部 route |
| 配置文件升级损坏原设置 | v1→v2 迁移、原子写入、失败回滚和迁移测试 |
| #65 与 #84 行为逐渐分叉 | 两个入口只做协议转换,测试直接断言同一 service 实例 |