37 KiB
Issue #65 / #84:基于 botId + targetId 的九渠道稳定主动投递方案
日期:2026-08-30。代码基线:v4.0.1 / 92ec91b。状态:待实施。
需求来源:Issue #65、Issue #84。本文是实现设计和验收依据,不代表功能已经上线。
1. 最终决定
两项需求一起实现,共用一套主动投递核心,只保留两个不同入口:
| 场景 | 调用入口 | 适用调用方 |
|---|---|---|
| #65 | Host 进程内 Cordis 服务 ctx.dshIm |
与 dsh-im 运行在同一 Host 的 cron、提醒、看板等插件 |
| #84 | Connection RPC 通道 /dsh-im-delivery |
Host 进程外的本机程序、编排器和管理页面 |
两个入口都只使用 botId + targetId 定位投递位置,再加本次要发送的 text。它们必须调用同一个 DeliveryService.send(),不能各自解析路由、维护目标或连接渠道。
路由地址 = botId + targetId
消息内容 = text
本方案不把 sessionId、chatRef、入站消息 ID、回复目标或临时 Webhook 当作主动投递地址,也不引入 deliveryHandle。dsh-im 只持久化用户明确配置的投递目标,不保存主动投递历史、业务任务、幂等键或重试队列。
2. 要解决的问题
2.1 两种使用场景
- #65 需要让同一个 Host 内的其他 Cordis 插件复用 dsh-im 已有的机器人凭据、连接和发送能力,而不是再接一套机器人。
- #84 需要让回复链之外的进程调用主动投递,例如长任务到达审批点后主动提醒用户。
- 两种场景的差异只是调用边界,目标选择、渠道适配、错误语义和实际发送都相同。
2.2 现有候选标识为什么不合适
| 标识 | 结论 | 原因 |
|---|---|---|
sessionId |
不使用 | 表示 Harness 会话,不一定绑定机器人或聊天;切换、重建后也不能代表投递地址 |
chatRef |
不作为公共协议 | 九渠道内容不统一,容易混入消息 ID、线程上下文或临时 Webhook;调用方还要理解渠道内部格式 |
入站 replyTarget |
不使用 | 依赖最近一条入站消息,只适合回复链,不能长期保存为公共地址 |
钉钉 sessionWebhook |
不使用 | 有效期和会话上下文有限,不适合长期主动投递 |
| 平台原生用户或群 ID | 只存入内部路由 | 各渠道字段数量和类型不同,部分目标还包含话题或线程字段,不适合作为统一接口结构 |
botId + targetId |
采用 | 两个值都由 dsh-im 展示和持久化;渠道路由变化时可保持公共调用参数不变 |
2.3 稳定性的边界
botId沿用现有机器人配置记录中的真实 ID。它在该机器人配置记录的生命周期内稳定,Host 重启、断线重连和凭据刷新不改变它。- 删除机器人再重新接入视为新机器人;此时不保证沿用旧
botId,旧目标也随旧机器人一起删除。 targetId是某个机器人下由用户填写的稳定、不透明标识,例如daily-report、ops-oncall。同一机器人内唯一、区分大小写,创建后不能改名。- 如果某个平台原生主 ID 本身适合作为稳定标识,用户也可以直接把它填写为
targetId;dsh-im 仍将它当作不透明键,不从字符串猜测渠道或目标类型。 - 平台原生地址保存在该目标的
route中。用户可以修改route而不改变外部系统使用的botId + targetId。
2.4 botId 直接复用现有身份
本期不重新发明 Bot ID。九渠道当前都已经把 botId 写入机器人配置并在状态响应中返回:
| 渠道 | 当前 botId 来源 |
本方案处理 |
|---|---|---|
| 飞书 | 首次接入生成 bot_... 并持久化 |
原样展示和使用 |
| 微信 | 由平台 accountId 稳定派生 |
原样展示和使用 |
| 钉钉 | 由应用 clientId 稳定派生 |
原样展示和使用 |
| 企业微信 | 由平台机器人 ID 稳定派生 | 原样展示和使用 |
由 appId 稳定派生 |
原样展示和使用 | |
| Slack | 由当前 Bot 的平台身份稳定派生 | 原样展示和使用 |
| Telegram | 由 Telegram Bot 平台 ID 稳定派生 | 原样展示和使用 |
| Discord | 由 Discord Bot 平台 ID 稳定派生 | 原样展示和使用 |
| 由已连接账号 JID 稳定派生 | 原样展示和使用 |
因此设置页只需把状态模型中已经存在的真实 botId 显示出来,不增加“获取 Bot ID”请求,也不把左上角当前展示的遮罩平台身份误当成 botId。
3. 需求范围
3.1 必须实现
- 九个 IM 渠道统一支持文字主动投递:微信、飞书、钉钉、企业微信、QQ、Slack、Telegram、Discord、WhatsApp。
- 一个机器人可以配置零个、一个或多个投递目标。
- 目标配置在 Host 重启后仍然存在;机器人删除时一并清理。
- #65 和 #84 的发送请求经过同一个核心服务和同一个渠道适配器。
- 机器人卡片右上角增加设置齿轮;卡片已有身份、状态、工作区、Agent Preset、上下文增强、连接检查和移除入口保持不变。
- 设置页展示可复制的真实
botId,并提供目标的新建、编辑、删除、复制调用参数和发送测试消息。 - 目标配置不依赖机器人在线;实际发送和测试要求机器人当前已连接。
- 用户负责从对应平台取得原生用户、群、频道、话题或线程标识;页面提供字段名和格式提示。
- 严格校验 RPC 端点、字段和渠道路由,不把平台原始错误、凭据或临时回复上下文返回给调用方。
- 保持所有现有入站回复、连接检查和文件发送行为不变。
3.2 本期明确不做
- 不自动扫描最近聊天,不提供
listChats,不从连接检查记忆或会话映射自动导入目标。 - 不提供绑定码、认领流程、用户目录同步或跨渠道身份合并。
- 不接受
sessionId、chatRef、sessionWebhook、消息 ID 或replyToMessageId作为公共发送参数。 - 不支持图片、文件、卡片、Markdown 类型选择;首期公共能力只有文字,渠道内部继续复用现有文字分段逻辑。
- 不实现定时任务、消息队列、离线补发、自动重试、回执查询、已读状态或发送历史。
- 不接收或生成
idempotencyKey;调用方重试造成的重复消息由调用方负责。 - 不新增 API Key、签名或用户权限模型。本期只沿用现有 Connection RPC 的
loopback/trusted-host可达性边界;该边界不是业务鉴权。 - 不为 AI Office 增加主动投递。本文“九渠道”不包含实验性的 AI Office。
- 不保证平台原生目标永久有效;被平台删除、机器人无权限或用户屏蔽后,发送应明确失败。
4. 领域模型与不变量
4.1 数据关系
Bot(现有机器人)
└── DeliveryTarget(投递目标,0..n)
├── targetId:对调用方稳定的键
├── name:可选显示名称
├── kind:渠道内的目标类型
└── route:可更新的渠道原生路由
投递目标的唯一键是 (botId, targetId),不是全局 targetId。两个机器人可以各自拥有名为 daily-report 的目标,互不影响。
4.2 不变量
botId必须对应一个仍然存在的机器人配置。targetId长度为 1~128,只允许 ASCII 字母、数字、.、_、:、@、-,不得有首尾空白;保存时不静默改写大小写。name可省略;填写时去除首尾空白后长度为 1~80。kind和route必须通过所属渠道的严格校验,未知字段一律拒绝。targetId创建后不可修改。改名操作等价于新建目标、切换调用方、再删除旧目标,避免外部调用方在不知情时失效。- 更新目标时完整替换
name + kind + route,不做深层 PATCH,避免残留旧类型字段。 - 发送开始时读取一份目标快照。并发更新可能使正在发送的那一次使用旧路由,但更新完成后的下一次发送必须使用新路由。
- 删除目标不取消已经交给渠道的发送;删除完成后的新请求返回
unknown-target。
5. 总体架构
flowchart LR
A[同 Host Cordis 插件<br/>Issue #65] -->|ctx.dshIm.send| C[DeliveryService]
B[进程外调用方<br/>Issue #84] -->|Connection RPC| R[Delivery RPC]
U[机器人设置页] -->|目标管理 / 测试| R
R --> C
C -->|按 botId 选择| G[九渠道 Adapter Registry]
G --> T[BotWorkspaceStore<br/>目标配置]
G --> S[未包装 coreController<br/>现有 Runtime / BotClient]
S --> P[IM 平台]
| 组件 | 职责 | 不负责 |
|---|---|---|
DeliveryService |
参数校验、按 botId 选择适配器、目标 CRUD、统一发送和错误码 |
不理解九渠道原生字段,不保存消息 |
| 渠道适配器 | 判断是否拥有机器人、校验本渠道 kind/route、调用对应核心 controller |
不暴露 RPC,不管理调用方业务 |
BotWorkspaceStore |
持久化机器人下的目标,复用现有原子写入、机器人队列和删除清理 | 不发送消息,不发现聊天 |
| Cordis 服务 | 把 #65 调用转发给 DeliveryService |
不复制渠道选择逻辑 |
| Delivery RPC | 把 #84 和设置页请求转发给 DeliveryService,转换安全错误包络 |
不直接调用 Runtime |
| 设置页 | 展示 botId、编辑渠道路由、调用测试 |
不推断或抓取目标 ID |
5.1 为什么使用单独 RPC 通道
现有 /dsh-im 通道只承载更新功能,并固定为 loopback。如果把主动投递直接塞进该通道,想让 #84 使用现有 trusted-host 配置时会同时改变更新接口的暴露边界。
因此新增 /dsh-im-delivery,但继续复用现有 ctx.connection.rpc.handle/call、统一结果包络、取消信号和 resolveRpcAuthority(),不新增 HTTP Server,也不改动 /dsh-im 更新接口。
6. 目标持久化
6.1 复用现有 workspaces.json
每个渠道已经有一份 workspaces.json,由 BotWorkspaceStore 保存工作区、Agent Preset 和上下文增强设置,并具备:
- 机器人粒度的写入队列;
- 临时文件加
rename的原子落盘; - 机器人删除事务和启动时
reconcile(); - 九渠道生产组合已经统一创建该 Store。
目标是机器人级设置,直接为这个文档增加 deliveryTargets,不再创建九份独立 Store、数据库或消息表。
{
"version": 2,
"workspaces": {
"bot_abc": "/Users/example/project"
},
"agentPresets": {},
"contextEnhancement": {},
"deliveryTargets": {
"bot_abc": {
"daily-report": {
"name": "每日汇报群",
"kind": "group",
"route": {
"chatId": "oc_xxx"
}
}
}
}
}
route 仅为示意;真实字段由渠道决定。targetId 已经是对象键,不在记录内重复保存。不增加创建时间、最后发送时间、状态、计数或幂等信息。
6.2 Store 最小扩展
在 src/channels/shared/bot-workspace-store.mjs 增加:
listDeliveryTargets(botId)
deliveryTargetFor(botId, targetId)
createDeliveryTarget(botId, target)
updateDeliveryTarget(botId, targetId, replacement)
deleteDeliveryTarget(botId, targetId)
实现规则:
- 文档 v1 读取为“没有投递目标”,第一次目标变更时按 v2 写回;现有工作区、Preset 和上下文增强原样保留。
- v2 严格校验目标对象的基本结构;渠道适配器在创建、更新和发送前再校验具体路由。
- CRUD 复用已有
#enqueue(botId, operation)和写入回滚模式,不增加第二把锁。 reconcile()的候选集合、#retireCurrentIncarnation()、#persist()和空文档删除判断同时包含deliveryTargets。- 机器人配置删除成功后,目标与工作区在同一删除事务中清理;配置删除回滚时目标也保留。
- 返回值使用结构化副本,调用方不能直接修改 Store 内部状态。
- 继续使用现有目录
0700、文件0600的落盘权限;路由中的用户或群标识不写日志、不进入发送历史。
7. 共用主动投递核心
7.1 对内服务形状
建议新增 plugin-src/host/delivery-service.mjs:
service.registerAdapter(adapter) // 返回 unregister
service.listTargets(botId)
service.createTarget(botId, target)
service.updateTarget(botId, targetId, replacement)
service.deleteTarget(botId, targetId)
service.send(botId, targetId, text, { signal } = {})
send() 的唯一流程:
- 校验
botId、targetId和非空text;空白文本拒绝,但发送时保留原文格式。 - 按现有九渠道启动顺序查找第一个
adapter.ownsBot(botId)为真的适配器。最多检查九个内存对象,不建立另一份机器人注册数据库。 - 从该适配器所属的
BotWorkspaceStore读取(botId, targetId)快照。 - 再次用渠道定义校验持久化的
kind/route,防止手工损坏文件后向错误地址发送。 - 调用未包装
coreController.sendProactiveText(botId, route, text, { signal })。 - 渠道接受发送后只返回
{ sent: true };不创建投递句柄、不落发送记录。
7.2 渠道适配器契约
{
channel: 'feishu',
ownsBot(botId),
listTargets(botId),
createTarget(botId, target),
updateTarget(botId, targetId, replacement),
deleteTarget(botId, targetId),
sendText(botId, target, text, { signal })
}
ownsBot()使用同一生产组合中的workspaces.has(botId),删除中的机器人不会继续接受新发送。- 公共服务不从
botId前缀猜渠道;前缀只是现有实现细节。 - 每个生产组合把自身
workspaces和未包装coreController闭包进适配器。 - 适配器只在对应渠道成功启动后注册,关闭渠道时注销。
7.3 必须绕过 Workspace RPC 装饰层
#65 已指出 createWorkspaceAwareController() 会装饰 controller 的所有返回值,数组方法可能因此改变形状。主动投递适配器必须使用生产组合中已有的未包装 coreController;渠道管理 RPC 继续使用包装后的 controller。
不在 createWorkspaceAwareController() 中加入主动投递特例,也不把目标列表塞进机器人状态响应,这样现有管理接口不会扩大或改变。
7.4 统一错误
| code | 含义 |
|---|---|
bad-request |
接口、字段、文本或基本 ID 格式不合法 |
unknown-bot |
没有已注册适配器拥有该 botId |
unknown-target |
机器人存在,但找不到该 targetId |
target-conflict |
同一机器人下创建了重复 targetId |
invalid-target |
kind/route 不符合该渠道约束,或持久化内容已损坏 |
bot-not-connected |
机器人存在,但 Runtime 当前不能发送 |
target-rejected |
平台明确拒绝该地址或机器人没有权限 |
delivery-failed |
网络、平台异常或其他不可安全细分的发送失败 |
cancelled |
调用在交给平台前被取消 |
进程内服务抛出带 code 的错误;RPC 转成 { ok: false, error: { code, message } }。RPC 不返回平台原始响应、Webhook、Token、堆栈或内部文件路径。
成功只表示平台发送接口已接受或当前 SDK 已成功返回,不承诺对方已读。dsh-im 不主动重试;调用方自行重试可能产生重复消息。
8. #65:Host 进程内 Cordis 服务
Host 启动时创建唯一的 DeliveryService,并通过现有 Cordis 机制提供:
ctx.provide('dshIm', Object.freeze({
send: (botId, targetId, text, options) =>
deliveryService.send(botId, targetId, text, options),
listTargets: (botId) => deliveryService.listTargets(botId),
}))
消费方示例:
export const inject = ['dshIm'];
await ctx.dshIm.send(
'bot_7f4c...',
'daily-report',
'今日构建已经完成。',
);
约束:
- 服务名沿用 #65 建议的
dshIm,方法名保持最小,只暴露send和listTargets。 - 目标 CRUD 由设置页/管理 RPC 完成,避免 Host 插件顺便承担配置 UI。
listTargets()返回{ targetId, name, kind, route }[],不再提供会话发现语义的listChats()。- Cordis 服务和 RPC 持有的是同一个
DeliveryService实例;测试必须证明两者不是两套 registry。 - Host/渠道关闭时用现有
ctx.effect()注销服务、RPC 和渠道适配器,不留下失效 Runtime 引用。
9. #84:进程外 Connection RPC
9.1 通道和端点
新增通道常量:
export const DELIVERY_RPC_CHANNEL = '/dsh-im-delivery';
| endpoint | payload | 成功 value |
|---|---|---|
message.send |
{ botId, targetId, text } |
{ sent: true } |
target.list |
{ botId } |
{ botId, channel, targets } |
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 调用同一个 DeliveryService.send(),内容使用固定本地化文案“DSH-IM 主动投递测试成功。”。它与现有“检查连接”是两个功能:检查连接验证机器人连接和默认测试目标,目标测试验证用户配置的指定路由。
9.2 调用示例
const result = await connection.rpc.call(
'/dsh-im-delivery',
'message.send',
{
botId: 'bot_7f4c...',
targetId: 'daily-report',
text: '流水线正在等待审批。',
},
signal,
);
结果继续使用仓库已有包络:
{ "ok": true, "value": { "sent": true } }
调用方只需要长期保存 botId 和 targetId。没有 deliveryHandle、sessionId、chatRef 或 idempotencyKey。
9.3 RPC 边界
- 默认
authority: 'loopback',满足同机进程外调用和管理页面。 - 顶层已有
rpcAuthority: 'trusted-host'配置时沿用现有解析机制;不新增第二个同义配置。 target.*和message.send使用相同可达性边界。本期不进一步区分管理员和发送者权限。- 每个端点只接受表中列出的键;缺字段、额外字段、数组冒充对象或已取消请求均拒绝。
- 该 RPC 是 #84 的对外程序接口;不再另建 Express 路由、REST Server 或 Webhook 接收器。
10. 九渠道适配
10.1 目标字段和现有发送链路
| 渠道 | kind |
设置页要求用户填写的 route |
复用的现有发送链路 |
|---|---|---|---|
| 微信 | user |
{ toUserId } |
createWeixinApi().sendText();不带 contextToken/runId |
| 飞书 | user / group |
用户 { openId };群 { chatId } |
FeishuRuntime 当前 im.v1.message.create 文字发送 |
| 钉钉 | user / group |
用户 { userId };群 { openConversationId } |
在 dingtalk-api.mjs 增加稳定机器人文字发送,复用现有 Access Token 和机器人主动文件消息端点 |
| 企业微信 | user / group |
{ chatId };私聊填用户 ID,群聊填群 chatid |
已连接客户端的 sendMessage(chatId, markdown) |
user / group |
用户 { userOpenId };群 { groupOpenId } |
QqRuntime 中的 QQBot.sendText(),适配为 SDK 的 { scope, targetId } |
|
| Slack | conversation / thread |
{ channelId };线程再加 { threadTs } |
SlackBotClient.sendText() |
| Telegram | chat / topic |
{ chatId };话题再加 { messageThreadId } |
TelegramBotClient.sendText(),不带回复消息 ID |
| Discord | channel |
{ channelId };私信、频道和 Thread 都使用可发消息的 Channel ID |
DiscordBotClient.sendText(),不带回复消息 ID/notice |
user / group |
{ jid },分别接受用户 JID 或群 JID |
WhatsappBotClient.sendText(),不带 quoted |
注意:表中的 route 是内部持久化结构,#65/#84 的发送调用都不传这些字段。
10.2 各渠道的实现要点
- 微信:只支持用户目标。复用现有 API 的无上下文文字发送能力;不把入站
contextToken或当前runId保存进目标。 - 飞书:
kind决定receive_id_type为open_id或chat_id。把连接检查中的局部发送函数提取为 Runtime 的稳定文字发送方法,继续使用同一 SDK Client。 - 钉钉:即时回复链仍可保留
sessionWebhook,但主动投递绝不能使用它。新增sendRobotText(),用户目标调用v1.0/robot/oToMessages/batchSend,群目标调用v1.0/robot/groupMessages/send;robotCode从当前机器人配置取得,不让用户重复填写。请求复用现有 Access Token Header,文字体为msgKey: 'sampleText'、msgParam: JSON.stringify({ content: text }),目标字段分别为userIds或openConversationId。为路径、Header、消息体和拒绝响应增加契约测试。 - 企业微信:直接复用 Runtime 当前连接检查使用的客户端;私聊的
chatId是用户 ID,群聊为平台chatid,类型只用于校验和页面说明。 - QQ:公共
targetId与 QQ SDK 内部字段targetId不是一回事。适配器将userOpenId/groupOpenId映射为{ scope: 'c2c'|'group', targetId: 原生 ID },避免调用方接触 SDK 结构。 - Slack:用户必须提供会话 Channel ID,不能只填 Member ID。线程目标额外保存稳定的根消息
threadTs。 - Telegram:
chatId以十进制字符串持久化,调用 API 前校验并转换;Topic 使用可选整数messageThreadId,不保存最近入站消息 ID。 - Discord:DM 也要求已经存在且机器人可访问的 Channel ID;Thread 本身同样是 Channel ID,因此无需额外线程字段。
- WhatsApp:只接受当前 SDK 支持的用户或群 JID,拒绝 Status/Broadcast 等特殊 JID。发送目标只有
jid,不保存引用消息对象或selfChat状态。
10.3 共用已有代码,但不复用临时目标
- Slack、Telegram、Discord 已共用
TextHarnessBridge。为它增加一个只委托#bot.sendText()的公共方法,三个 Runtime 再统一暴露sendProactiveText(),不复制分段代码。 - Telegram 和 Discord 继续通过
TokenBotController统一委托 Runtime;其他 controller 各增加同名薄方法。 - 渠道的
sendText()继续负责自身长度切分和平台错误分类;DeliveryService不做统一切段。 - 现有
sendConnectionTest()可以复用底层发送函数,但它记住的测试目标不会自动成为投递目标。 - 所有主动路由明确拒绝
sessionId、sessionWebhook、contextToken、runId、messageId、replyToMessageId、quoted等瞬时字段。
11. 机器人卡片与设置页
11.1 卡片入口
机器人卡片原有内容和顺序不变,仅在右上角状态区域增加齿轮:
┌────────────────────────────────────────────────────┐
│ [渠道图标] 机器人名称 ● 运行正常 [⚙] │
│ 现有平台身份 最近检查 10:30 │
│ │
│ 原有设置内容全部保持不变 │
│ [检查连接] [移除接入] │
└────────────────────────────────────────────────────┘
- 在
channel-card-meta.js新增共享BotSettingsButton,九种卡片都使用它。 - 右侧使用
dim-botCardTools包住现有BotStatusMeta和齿轮,不修改健康状态本身。 - 按钮为 32×32 px、16 px 齿轮、8 px 圆角;默认透明,Hover 使用现有浅灰交互底色,键盘焦点使用现有蓝色 focus ring。
aria-label和 Tooltip 都是“机器人设置”。按钮不显示botId或目标数量,避免继续挤占卡片。- 机器人离线时仍可进入设置;仅“发送测试”不可用。
11.2 独立设置页
点击齿轮后,在当前渠道右侧面板内从机器人列表切换到独立设置页,不使用弹窗,也不增加左侧导航项:
← 返回机器人列表 飞书机器人
调用标识
Bot ID
bot_7f4c9d... [复制]
投递目标 [新建目标]
每日汇报群
targetId: daily-report 群聊 · chat_id oc_xxx
[复制调用参数] [测试] [编辑] [删除]
告警负责人
targetId: ops-oncall 私聊 · open_id ou_xxx
[复制调用参数] [测试] [编辑] [删除]
页面规则:
- Bot ID 显示
status.bots[].botId的真实值,使用等宽字体,可单独复制;不显示卡片上经过遮罩的平台应用 ID 来冒充。 - “复制调用参数”复制 JSON:
{ "botId": "...", "targetId": "..." },不包含路由和消息文本。 - 空状态说明“尚未配置投递目标,请从对应平台取得用户、群、频道或话题标识后新建”。
- 目标行展示名称、
targetId、目标类型和一行渠道路由摘要;原生 ID 过长时视觉省略,复制值必须完整。 - 删除先在页面内确认,并提示“使用这个 targetId 的外部调用将返回 unknown-target”。
- 返回机器人列表后保留当前渠道;尽量保留此前滚动位置,不重新切换渠道。
11.3 新建和编辑表单
共享表单字段:
targetId:必填;编辑时只读。- 显示名称:可选。
- 目标类型:仅展示该渠道支持的
kind。 - 渠道路由字段:按第 10 节表格动态显示。
实现一个共享 DeliveryTargetSettingsPage,用一份九渠道字段定义驱动标签、占位说明、类型选项和路由摘要,不复制九个页面。渠道自己的 SettingsTab 只维护 selectedBot 并传入 channel/account/deliveryRpcCall/onBack。
本期不提供“从最近聊天选择”“使用检查连接目标”“自动读取群列表”等快捷入口。用户负责取得平台原生 ID;页面只负责清楚说明当前字段需要哪一种 ID。
12. 最小代码改动建议
12.1 新增共享文件
| 文件 | 内容 |
|---|---|
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 |
设置页、目标表单、九渠道字段定义和调用参数复制 |
不需要新增第三方依赖。
12.2 修改现有共享文件
| 文件 | 改动 |
|---|---|
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 的主动文字发送 |
plugin-src/host/index.mjs |
创建唯一服务、提供 dshIm、安装 Delivery RPC、向九渠道传注册回调 |
plugin-src/host/channels/shared/production.mjs |
返回由 coreController + workspaces 构成的适配器 |
plugin-src/client/index.js |
建立 deliveryRpcCall 并传给九渠道设置页 |
plugin-src/client/loopback-recovery.js |
将 Delivery RPC 纳入现有 loopback 恢复包装 |
plugin-src/client/channel-card-meta.js |
共享齿轮按钮 |
plugin-src/client/styles.js |
齿轮、设置页、目标列表和响应式样式 |
12.3 渠道文件
- 共享 token 生产组合覆盖 Telegram、Discord;Slack 使用自己的生产组合。
- 飞书、微信、钉钉、企业微信、QQ、Slack、WhatsApp 的 production 返回同形适配器。
- 九渠道现有 controller/runtime 各增加一个薄的
sendProactiveText();底层继续调用现有 BotClient/API。 - 钉钉额外修改
src/channels/dingtalk/dingtalk-api.mjs,增加稳定用户/群文字发送方法。 - 九个客户端卡片把现有
BotStatusMeta与共享齿轮放进右侧工具组;卡片其余 JSX 不搬迁、不重排。
12.4 推荐实施顺序
- 先扩展
BotWorkspaceStore和迁移测试。 - 实现
DeliveryService、适配器契约、Cordis 服务和 Delivery RPC,用假适配器打通双入口。 - 接入九渠道 Runtime/controller;先做现有共用程度最高的 Telegram/Discord/Slack,再完成其余渠道和钉钉稳定发送。
- 实现共享设置页和九张卡片的齿轮入口。
- 补充 README/README.en、CHANGELOG、接口示例和九渠道真实环境验收记录。
每一步都使用同一公共契约,不先合入只支持飞书的公共 API,避免一经发布就形成“接口声称九渠道、实际只有一个渠道”的兼容包袱。
13. 测试方案
13.1 Store 与生命周期
| 编号 | 用例 | 预期 |
|---|---|---|
| S01 | 读取现有 v1 workspaces.json |
工作区等设置不变,目标为空;首次目标变更写为 v2 |
| S02 | 新建两个目标后重建 Store | 完整恢复 targetId/name/kind/route |
| S03 | 两个机器人使用相同 targetId |
各自读取自己的目标,不冲突 |
| S04 | 同一机器人重复创建 targetId |
target-conflict,原记录不变 |
| S05 | 更新名称、类型和路由 | targetId 不变,下一次读取是完整新快照 |
| S06 | 落盘失败 | 内存回滚到提交前状态,不返回虚假成功 |
| S07 | 同一机器人并发增删改 | 经过已有机器人队列串行化,文件合法且不丢其他目标 |
| S08 | 删除机器人成功/失败回滚 | 成功时目标清理;配置删除失败时目标保留 |
| S09 | 启动 reconcile() 遇到已不存在机器人 |
清理该机器人的孤儿目标 |
13.2 共用核心与双入口
| 编号 | 用例 | 预期 |
|---|---|---|
| C01 | 两个适配器分别拥有不同 botId |
只调用拥有该机器人的适配器一次 |
| C02 | 未知机器人/目标 | 分别返回 unknown-bot / unknown-target,不尝试平台发送 |
| C03 | 目标路由损坏 | invalid-target,不把损坏数据交给 Runtime |
| C04 | 机器人离线 | bot-not-connected,不排队、不落消息记录 |
| C05 | 发送过程中更新目标 | 当前调用使用一个完整快照;后续调用使用新路由 |
| C06 | 调用取消 | 交给平台前返回 cancelled;已交给平台后不承诺撤回 |
| C07 | 平台拒绝和网络失败 | 映射为安全公共错误,不泄露原始响应 |
| C08 | Cordis send 与 RPC message.send 使用同一 pair |
命中同一个 service spy、同一个适配器和同一返回语义 |
| C09 | Host/渠道关闭 | 适配器注销,旧 Runtime 不再可调用 |
| C10 | 发送成功 | 只返回 { sent: true },没有 handle、历史或幂等状态 |
建议新增 test/delivery-service.test.mjs、test/delivery-rpc.test.mjs,并扩展 test/host.test.mjs 验证 ctx.provide('dshIm') 和清理生命周期。
13.3 RPC 契约
- 六个端点分别覆盖成功、缺字段、额外字段、错误类型、未知端点和取消。
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 九渠道适配器契约
每个渠道至少有以下自动化用例:
- 每种受支持
kind的合法路由准确映射到现有 sender。 - 缺字段、空字段、错误类型和未知字段均为
invalid-target。 - Runtime 离线时不调用 SDK。
- 长文本仍由该渠道现有分段函数处理,核心服务不重复切段。
- 主动发送不携带回复消息、引用对象或最近会话状态。
- 平台 sender 只调用一次业务发送入口;其内部分段次数按现有规则。
重点断言:
| 渠道 | 必须断言 |
|---|---|
| 微信 | 请求没有 contextToken、runId |
| 飞书 | user/group 分别使用 open_id/chat_id |
| 钉钉 | 使用稳定 OpenAPI 路径;任何主动发送都没有读取或调用 sessionWebhook |
| 企业微信 | user/group 的 chatId 原样传给当前 Client |
user/group 分别映射 c2c/group,不混淆公共 targetId |
|
| Slack | threadTs 仅在线程目标出现 |
| Telegram | Topic 映射 messageThreadId,始终没有 replyToMessageId |
| Discord | 只传 channelId + content,没有 reply/notice |
只传 jid + text,没有 quoted |
这些测试加入各渠道现有 runtime/controller/production 测试文件,不为同一行为新建九套测试框架。
13.5 客户端测试
| 编号 | 用例 | 预期 |
|---|---|---|
| U01 | 九种机器人卡片渲染 | 状态右侧均有可访问齿轮,原有内容和操作仍在 |
| U02 | 点击齿轮和返回 | 在当前面板切换设置页/机器人列表,不改变渠道 |
| U03 | Bot ID 复制 | 复制真实完整 botId,不是遮罩平台 ID |
| U04 | 目标空状态及新建 | 按渠道显示正确字段,保存后出现在列表 |
| U05 | 编辑目标 | targetId 只读,名称/类型/路由可完整替换 |
| U06 | 删除目标 | 先确认,成功后移除;失败时保留并显示安全错误 |
| U07 | 复制调用参数 | JSON 只含正确 botId + targetId |
| U08 | 在线/离线测试 | 离线禁用测试但可编辑;在线测试调用 target.test |
| U09 | 窄屏和键盘操作 | 不横向溢出,焦点顺序、Tooltip、状态播报可用 |
| U10 | Delivery RPC 不可用 | 只影响目标设置,不破坏机器人列表和现有连接操作 |
复用现有 test/client-ui.test.mjs 和九渠道 client-ui.test.mjs 的渲染方式;共享设置页可新增 test/client-delivery-settings.test.mjs。
13.6 回归和真实环境
自动化必须通过 npm run check,并特别回归:机器人接入/删除、断线重连、检查连接、工作区切换、Agent Preset、上下文增强、入站回复、Telegram 长轮询和 WhatsApp 回声过滤。
发布前为九渠道各配置至少一个真实目标并执行 target.test;支持群/线程的渠道再各验证一个非私聊目标。验收记录应保存渠道、目标类型、时间、结果和脱敏错误,不保存凭据或完整用户 ID。
14. 验收标准
以下条件全部满足才能关闭 #65 和 #84:
- 同 Host 插件可以注入
dshIm,使用send(botId, targetId, text)完成投递。 - 进程外调用方可以通过
/dsh-im-delivery的message.send使用同一组参数完成投递。 - 自动化测试证明两个入口调用同一个
DeliveryService,不存在两套路由解析或渠道连接。 - 九渠道均有生产适配器、自动化契约测试和一次真实环境成功记录。
- Host 重启后同一
botId + targetId仍可使用;编辑渠道路由后公共 pair 不变且下一次发送走新路由。 - 公共发送接口没有
sessionId、chatRef、sessionWebhook、deliveryHandle或idempotencyKey。 - 钉钉主动投递只使用稳定用户/群接口;临时
sessionWebhook仅保留在原即时回复链。 - 每种机器人卡片右上角都有齿轮,原卡片内容、顺序和连接检查功能没有退化。
- 设置页可复制真实 Bot ID,并完整支持目标的新建、编辑、删除、复制 pair 和测试。
- 一个机器人可配置多个目标;相同
targetId在不同机器人下隔离。 - 机器人离线时仍可管理目标;发送返回明确的
bot-not-connected,不建立隐式队列。 - 删除机器人会清理其全部目标;删除失败回滚不会丢失目标。
- dsh-im 不落主动发送历史、不自动重试、不管理幂等状态。
npm run check全部通过,九渠道现有接入、回复和连接检查回归通过。
15. 风险与控制
| 风险 | 控制方式 |
|---|---|
| 用户填错平台 ID | 渠道严格校验、字段级提示和目标测试;不尝试从字符串猜测 |
| 平台目标以后失效 | 保持 targetId 不变,用户只更新内部 route |
| 配置文件升级损坏原设置 | v1→v2 迁移、原子写入、失败回滚和迁移测试 |
| #65 与 #84 行为逐渐分叉 | 两个入口只做协议转换,测试直接断言同一 service 实例 |
| 九渠道复制实现 | 目标 Store、Service、RPC 和设置页共享;渠道层只保留路由校验和薄发送委托 |
| 钉钉误用临时 Webhook | 主动发送 API 不接受该字段,并以负向测试固定 |
| 对外 RPC 被误认为已有完整鉴权 | 文档明确本期只复用可达性边界;真正远程开放前另行设计鉴权 |
| 调用方超时后重试导致重复 | 不伪造“恰好一次”;文档明确调用方负责幂等,dsh-im 不落盘去重 |
16. 方案收口
本方案只有一个需要长期维护的公共概念:投递目标。外部调用方认 botId + targetId,dsh-im 在内部把它解析成当前渠道路由并复用已有发送连接。
后续即使增加富文本或文件,也应在 DeliveryService 上扩展消息内容类型,而不是再引入 sessionId、chatRef 或新的地址体系;本期不提前实现这些扩展点。