v3.9.0 发布说明
本次 minor 版本带来一个新的对外扩展点:自定义卡片扩展接口。独立 OpenClaw 插件现在可以通过子入口 @soimy/dingtalk/card-extensions 复用 DingTalk Channel 已有的账号、Token、HTTP 与 Stream 连接,向指定企业成员投递机器人私聊卡片、更新模板变量,并接收按钮与表单回调——不再需要自建第二套钉钉凭据、第二张 transport 或第二条 Stream 连接。
Important
本版本没有破坏性变更,也没有新增或变更任何配置项,默认行为与 v3.8.1 完全一致;现有安装升级后无需改动 channels.dingtalk。
变化的只有发布产物:npm 包与 ClawHub 包中新增一个子入口 @soimy/dingtalk/card-extensions(dist/card-extensions.js 与对应类型)。
最低宿主版本保持 OpenClaw 2026.8.1 不变。
最新版本入口:latest.md
✨ 新特性
自定义卡片扩展接口(PR #630,by @RowanZhong)
此前,想用钉钉原生卡片模板(企业自建互动模板、复杂表单、原卡变量更新)的第三方插件只能自己持有钉钉凭据、自己建 Stream 连接、自己处理回调路由——即把 Channel 已经实现的那一层重做一遍。本版本把这一层开放为受限扩展点:
import { registerDingTalkCardExtension } from '@soimy/dingtalk/card-extensions';
register(api) {
const cards = registerDingTalkCardExtension(api, {
accountId: 'default',
namespace: 'settings-example',
async onAction(action) { /* 校验 + 业务处理 */ },
});
}- 能力边界写在文档里,而不是靠读者猜:OpenClaw 负责插件注册、运行时能力发现与交互去重;Channel 负责钉钉协议与传输;业务权限、输入校验、持久化、幂等与失败恢复全部由消费者负责。接口明确不承担通用 UI 框架、模型工具或审批框架的职责。
- 首版范围仅机器人私聊卡片:群卡片、模板设计、通用钉钉 HTTP API 都不在接口内。普通按钮/选择器仍应优先使用宿主
MessagePresentation。 - 不新增任何连接或调用成本:新路径只是宿主本地能力查询、路由解析与交互分派,不产生模型调用、子进程、HTTP 服务、轮询或 Stream 连接;只有真正发送/更新时才访问原有钉钉卡片 API。
- 消费者与 Channel 通常不共享
node_modules:子入口只引用狭窄的宿主公共 SDK,不导入 Channel 主入口、HTTP 客户端或 Stream SDK,openclaw与openclaw/plugin-sdk/*保持 external,由正在运行的宿主提供;不支持跨进程使用。
公开契约
| 方法或字段 | 说明 |
|---|---|
registerDingTalkCardExtension(api, { accountId, namespace, onAction })
| 显式绑定账号与命名空间;同账号同命名空间重复注册立即报错,不同账号相互独立 |
isReady(): boolean
| 本地同步查询当前账号能力与连接状态,无 HTTP 请求;仅作提示,每次发送前仍会重新检查 |
sendCard({ cardId, templateId, userId, variables })
| 创建并投递机器人私聊卡片;userId 为企业 staffId(不是 unionId/openDingTalkId),固定 STREAM 回调且禁止转发(supportForward: false)
|
updateCard({ cardId, variables })
| 更新同一卡片提供的变量键,失败不会创建新卡片 |
onAction({ cardId, userId, actionIds, form, callbackId? })
| 只提供业务卡片 ID、外层可信用户 ID、动作、表单与可选事件 ID |
variables
| 字符串映射,1–256 个键,键长 ≤128,序列化后 ≤262144 字符 |
不提供 getStatus() / dispose(),也不把内部路由编码 outTrackId 暴露为公共契约:消费者只持有稳定 cardId,不要自行构造、持久化或依赖 Channel 的路由格式。
路由与账号隔离
- 卡片路由编码为
ocx1.<accountId>.<namespace>.<cardId>,因此outTrackId天然带上账号维度;组合长度超限时直接返回invalid-argument,建议使用短 namespace 与 UUID 卡片 ID。 - 入站回调会核验实际接收事件的 Stream 账号与卡片路由一致,不匹配的保留前缀事件被消费后丢弃——不会转入内置停止、反馈、问卷或 Agent 流程,消费者也不会收到其他账号的回调。
- 账号来自 Stream 连接、用户 ID 仅取自外层
userId;actionIds与form一律视为不可信输入(form中{ value: ... }包装会解包),嵌套字段无法覆盖账号、身份或路由。可信用户 ID 本身不授予业务权限,鉴权仍需消费者自己做。
回调边界、去重与错误
- 处理器通过宿主公开的
registerPluginInteractiveHandler()注册(返回{ ok, error },命名空间冲突时立即失败,不会给出一个没有有效处理器的发送句柄),入站经宿主dispatchPluginInteractiveHandler()分派,复用宿主的进行中占位与近期完成去重。 - Channel 在调用业务处理器之前发送 ACK,语义是"事件已收到"而不是"业务已成功";重复、未匹配或格式错误的保留前缀事件同样被 ACK 并消费。
- 处理器抛错时宿主释放占位:同一事件以后再次到达可能重新执行,Channel 不主动重试业务或 HTTP,也不因此转交 Agent。去重不是持久化业务锁,不保证 exactly-once;已产生副作用的业务必须用业务 ID、事务或状态记录避免二次执行。
- 错误只暴露稳定
code与脱敏描述,不附带凭据、卡片正文或原始 HTTP 异常(axios 错误可能残留 token header 与服务端回显内容,已在边界处吞掉):
| 错误 code | 处理建议 |
|---|---|
unavailable
| 核对宿主版本、Channel 能力、账号配置与启用状态;账号未启动或 transport 已撤销时也会返回 |
not-connected
| transport 存在但 Stream 不可用,等待 Channel 恢复后再操作 |
namespace-conflict
| 保证同账号同 namespace 只在同步 register 阶段注册一次
|
invalid-argument
| 修正 ID、模板 ID、用户 ID、变量格式或长度 |
request-failed
| 检查平台权限、模板与网络;先核对实际结果再决定是否重发,创建超时可能已经投递 |
- 必须同步注册:注册函数只能在插件同步
register(api)阶段调用一次,不能放进异步初始化、定时器或服务start(),否则宿主无法在激活/卸载/重载时正确切换处理器。需要开机起后台服务的消费者应自行声明"activation": { "onStartup": true }。 - 创建与更新不新增自动重试,Token 获取沿用 Channel 缓存与有限重试。
已知边界
- 仅支持机器人私聊卡片;群卡片、模板设计、通用钉钉 HTTP API 不在范围内。
- 接口是受信任插件之间的扩展点,不是恶意插件的沙箱:宿主清理处理器不会取消已经执行中的业务或 HTTP 请求,同步阻塞的消费者会阻塞 Gateway 事件循环。
- 组合完成记录(宿主侧,OpenClaw 2026.8.1 当前为 5 分钟、进程共享最多 4096 条)的具体容量与淘汰策略由宿主管理,不是本接口的永久保证。
- 个人卡片入口发布的模板不一定可由企业机器人投递:请在企业开发者后台卡片平台选择当前企业及机器人所属应用可访问的已发布互动模板。
文档与示例
- 新增
docs/contributor/card-extensions.md:完整接口说明、能力边界、同步注册与业务生命周期、模板变量约定、去重/错误/恢复语义,以及一个含表单校验与服务的可运行示例(含isReady()就绪判定与未就绪提示)。
🧩 工程与 CI
- 构建产物从单入口变为双入口(
PR #630)scripts/build-runtime.mjs由entryPoints: ["index.ts"]+outfile改为{ index, "card-extensions" }+outdir: "dist",额外产出dist/card-extensions.js。scripts/verify-runtime-package.mjs的必需文件断言同步新增dist/card-extensions.js与dist/src/card/card-extension-api.d.ts:子入口漏进包时pnpm run pack:check会直接失败,而不是让消费者在import时才报错。package.json新增./card-extensions导出映射,主入口.的导出与行为不变。
resolveDingTalkAccount()修正显式accounts.default的继承行为:此前id === "default"会无条件走渠道级读取路径,忽略显式写出的accounts.default;现在只有在该账号确实未显式配置时才回落到渠道级,与getConfig()的覆盖/继承规则一致。未配置accounts.default的现有安装行为不变。- 卡片回调 ACK 幂等:卡片回调的
acknowledge()增加一次性保护,扩展回调路径不会对同一messageId重复 ACK。 - 测试文件规模治理(
PR #629,by @soimy)- 拆分两个超过仓库 800 行上限的测试文件:
reply-strategy-card.test.ts(1770 行 / 89 用例)→ 5 个文件,inbound-handler-card.test.ts(1270 行 / 18 用例)→ 3 个文件,共享的模块级 mock 提取到tests/unit/fixtures/。 - 107 个
it块经缩进感知提取器比对逐字节未变(仅空白归一化),属于纯拆分,不改变任何断言语义。
- 拆分两个超过仓库 800 行上限的测试文件:
- 发布流程文档修正(
PR #628,by @soimy)docs/contributor/npm-publish.md新增「发布步骤 7」:推 tag 不会自动建立 GitHub Release 页面(仓库没有对应 workflow),必须手动gh release create --notes-file docs/releases/<tag>.md --verify-tag,并核对 Release 正文与本文件逐字一致。- 同时记录发布说明必须使用绝对链接的规则:同一份文件会同时被 VitePress 与 GitHub Release 页面渲染,相对链接在后者会 302 回 Release 页面本身。
📋 升级检查清单
- 宿主版本 ≥
OpenClaw 2026.8.1(未变化) - 现有部署无需改动配置:本版本没有新增或变更配置项,默认行为与 v3.8.1 一致
- 如果你维护的独立插件需要用钉钉原生卡片模板:按
docs/contributor/card-extensions.md在同步register(api)中注册,并把openclaw/openclaw/plugin-sdk/*保持为 external - 只从
onAction收到的数据里取业务信息:actionIds与form是不可信输入,鉴权、输入校验、幂等与失败恢复都在消费者侧 - 不要把
isReady()当作"切换到另一发送后端"的判据,也不要在跨进程场景使用该接口 - 若要真机验证卡片模板:确认使用的是当前企业/机器人所属应用的已发布互动模板,并在客户端实际确认渲染、表单提交与原卡更新
🤝 鸣谢
- @RowanZhong(
PR #630)—— 设计并实现自定义卡片扩展接口:公开契约收敛、账号/命名空间路由与隔离、回调提前 ACK、宿主注册与去重复用、错误脱敏、双入口构建与打包校验,以及完整的消费者文档与示例。 - @soimy(
PR #629、PR #628)—— 测试文件规模治理(Issue #622的落地拆分),以及发布流程文档中 GitHub Release 步骤与绝对链接规则的补齐。
发布页面:https://github.com/soimy/openclaw-channel-dingtalk/releases/tag/v3.9.0
Full Changelog: v3.8.1...v3.9.0