@soimy/dingtalk 3.1.4 → 3.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -2,16 +2,69 @@
2
2
 
3
3
  钉钉企业内部机器人 Channel 插件,使用 Stream 模式(无需公网 IP)。
4
4
 
5
+ > [!IMPORTANT]
6
+ > **重要声明(上游消息丢失问题进展更新)**
7
+ >
8
+ > 根据 issue [#104](https://github.com/soimy/openclaw-channel-dingtalk/issues/104) 今日最新反馈,钉钉侧服务扩容后,`dingtalk-stream` 模式下的消息丢失情况已有明显改善。
9
+ > 当前我们将继续保持观测与验证:若你在生产或测试环境使用本插件,欢迎重点关注“消息到达率、延迟、缺失 ID 对账”等指标,并在 #104 持续回报测试结果与样本日志,帮助社区共同确认改善效果是否稳定收敛。
10
+ >
11
+ > 相关信息:
12
+ > - issue 讨论:[#104](https://github.com/soimy/openclaw-channel-dingtalk/issues/104)
13
+ > - 最小可复现说明(SDK 侧):<https://github.com/soimy/dingtalk-stream-sdk-nodejs/blob/main/docs/inbound-msg-missing-repro.zh-CN.md>
14
+ > - 插件侧测试分支:[`test/inbound-msg-missing`](https://github.com/soimy/openclaw-channel-dingtalk/tree/test/inbound-msg-missing)
15
+ >
16
+ > 在问题完全确认收敛前,关键业务场景仍建议保持重试与可观测性(trace 前缀、计数日志、缺失 ID 对账)。
17
+
18
+ ## 目录
19
+
20
+ - [功能特性](#功能特性)
21
+ - [安装](#安装)
22
+ - [方法 A:通过 npm 包安装](#方法-a通过-npm-包安装-推荐)
23
+ - [方法 B:通过本地源码安装](#方法-b通过本地源码安装)
24
+ - [方法 C:手动安装](#方法-c手动安装)
25
+ - [方法 D:国内网络环境安装](#方法-d国内网络环境安装npm-镜像源)
26
+ - [安装后必做:配置插件信任白名单](#安装后必做配置插件信任白名单pluginsallow)
27
+ - [更新](#更新)
28
+ - [配置](#配置)
29
+ - [交互式配置](#方法-1交互式配置推荐)
30
+ - [手动配置文件](#方法-2手动配置文件)
31
+ - [配置选项](#配置选项)
32
+ - [钉钉文档 API](#钉钉文档-api)
33
+ - [反馈学习与共享知识](#反馈学习与共享知识)
34
+ - [安全策略](#安全策略)
35
+ - [消息类型支持](#消息类型支持)
36
+ - [API 消耗说明](#api-消耗说明)
37
+ - [消息类型选择](#消息类型选择)
38
+ - [多 Agent 与多个机器人绑定](#多-agent-与多个机器人绑定)
39
+ - [使用示例](#使用示例)
40
+ - [故障排除](#故障排除)
41
+ - [开发指南](#开发指南)
42
+ - [测试](#测试)
43
+ - [许可](#许可)
44
+
5
45
  ## 功能特性
6
46
 
7
47
  - ✅ **Stream 模式** — WebSocket 长连接,无需公网 IP 或 Webhook
8
48
  - ✅ **私聊支持** — 直接与机器人对话
9
49
  - ✅ **群聊支持** — 在群里 @机器人
10
- - ✅ **多种消息类型** — 文本、图片、语音(自带识别)、视频、文件
50
+ - ✅ **多种消息类型** — 文本、图片、语音(自带识别)、视频、文件、钉钉文档/钉盘文件卡片
51
+ - ✅ **附件文本抽取** — 对常见文本类附件以及 `PDF/DOCX` 自动抽取正文并注入当前会话上下文
52
+ - ✅ **引用消息支持** — 支持恢复大多数引用场景(文字/图片/图文/文件/视频/语音/AI 卡片);单聊中的钉钉文档依赖权限 **Storage.DownloadInfo.Read**;群聊支持引用群图片、文件/文档(优先命中已持久化索引,未命中时走群文件 API 兜底),其中群文件相关能力需 **ConvFile.Space.Read**、**Storage.File.Read**、**Storage.DownloadInfo.Read**、**Contact.User.Read**,且兜底链路受时间窗口与企业认证限制
11
53
  - ✅ **Markdown 回复** — 支持富文本格式回复
54
+ - ✅ **Markdown 表格兼容** — 自动把 Markdown 表格转换为钉钉更稳定的可读文本
12
55
  - ✅ **互动卡片** — 支持流式更新,适用于 AI 实时输出
13
56
  - ✅ **完整 AI 对话** — 接入 Clawdbot 消息处理管道
14
57
 
58
+ ### 进程级(memory-only)运行态说明
59
+
60
+ 以下命名空间/状态刻意保持为**仅进程内内存态**,不会进行磁盘持久化:
61
+
62
+ - `dedup.processed-message`(消息去重窗口)
63
+ - `session.lock`(同 session 串行锁)
64
+ - `channel.inflight`(gateway in-flight 防重锁)
65
+
66
+ 这样设计是为了保证并发控制语义简单且可预期,避免跨进程/重启后引入锁状态不一致问题。
67
+
15
68
  ## 安装
16
69
 
17
70
  ### 方法 A:通过 npm 包安装 (推荐)
@@ -27,23 +80,80 @@ openclaw plugins install @soimy/dingtalk
27
80
  如果你想对插件进行二次开发,可以先克隆仓库:
28
81
 
29
82
  ```bash
30
- # 1. 克隆仓库
83
+ # 1. 在父仓库外单独克隆插件仓库(推荐)
31
84
  git clone https://github.com/soimy/openclaw-channel-dingtalk.git
32
85
  cd openclaw-channel-dingtalk
33
86
 
34
87
  # 2. 安装依赖 (必需)
35
88
  npm install
36
89
 
37
- # 3. 以链接模式安装 (方便修改代码后实时生效)
90
+ # 3. 用全局 OpenClaw 以链接模式安装 (方便修改代码后实时生效)
38
91
  openclaw plugins install -l .
39
92
  ```
40
93
 
94
+ 推荐的本地开发布局:
95
+
96
+ ```text
97
+ ~/Repo/openclaw # 仅用于阅读源码、跳转 plugin-sdk、研究内部链路
98
+ ~/Repo/openclaw-channel-dingtalk # 插件主开发仓库
99
+ ~/.openclaw/extensions/... # 由 openclaw plugins install -l 管理的运行时链接
100
+ ```
101
+
102
+ 这种布局比“把插件放在 `openclaw/extensions/` 里再单独开 worktree”更稳定,原因是:
103
+
104
+ - 避免 submodule / worktree 的 gitdir 指向混乱
105
+ - 插件仓库可以独立切分支、开 worktree、做实验
106
+ - 运行时和源码阅读环境彻底解耦
107
+
108
+ 如果你的本地 `openclaw` 仓库位于 `~/Repo/openclaw`,而插件仓库位于 `~/Repo/openclaw-channel-dingtalk`,本仓库当前的 `tsconfig.json` 已兼容这种目录结构,会优先解析父仓库源码中的 `src/plugin-sdk`,在源码不存在时再回退到 `dist/plugin-sdk` 类型产物。
109
+
110
+ 如果你此前是把插件作为 `~/Repo/openclaw/extensions/openclaw-channel-dingtalk` 下的 submodule 使用,建议迁移为独立仓库后再执行:
111
+
112
+ ```bash
113
+ cd ~/Repo/openclaw-channel-dingtalk
114
+ openclaw plugins install -l .
115
+ openclaw gateway restart
116
+ ```
117
+
41
118
  ### 方法 C:手动安装
42
119
 
43
120
  1. 将本目录下载或复制到 `~/.openclaw/extensions/dingtalk`。
44
121
  2. 确保包含 `index.ts`, `openclaw.plugin.json` 和 `package.json`。
45
122
  3. 运行 `openclaw plugins list` 确认 `dingtalk` 已显示在列表中。
46
123
 
124
+ ### 方法 D:国内网络环境安装(npm 镜像源)
125
+
126
+ 如果你在国内网络环境下执行 `openclaw plugins install @soimy/dingtalk` 时卡在 `Installing plugin dependencies...` 或出现 `npm install failed`,可临时为该次安装指定镜像源:
127
+
128
+ ```bash
129
+ NPM_CONFIG_REGISTRY=https://registry.npmmirror.com openclaw plugins install @soimy/dingtalk
130
+ ```
131
+
132
+ 如果插件已处于半安装状态(例如扩展目录存在但依赖未装全),可进入插件目录手动补装依赖:
133
+
134
+ ```bash
135
+ cd ~/.openclaw/extensions/dingtalk
136
+ rm -rf node_modules package-lock.json
137
+ NPM_CONFIG_REGISTRY=https://registry.npmmirror.com npm install
138
+ ```
139
+
140
+ 如果希望长期生效,可设置 npm 默认镜像:
141
+
142
+ ```bash
143
+ npm config set registry https://registry.npmmirror.com
144
+ ```
145
+
146
+ 或写入 `~/.npmrc`:
147
+
148
+ ```ini
149
+ registry=https://registry.npmmirror.com
150
+ ```
151
+
152
+ > 说明:
153
+ > - 临时环境变量方式仅对当前命令生效,不会污染全局配置。
154
+ > - 若 OpenClaw 运行在 systemd / Docker 等服务环境,请在对应服务环境变量中配置 `NPM_CONFIG_REGISTRY`。
155
+ > - 相关背景可参考 issue [#216](https://github.com/soimy/openclaw-channel-dingtalk/issues/216)。
156
+
47
157
  ### 安装后必做:配置插件信任白名单(`plugins.allow`)
48
158
 
49
159
  从 OpenClaw 新版本开始,如果发现了非内置插件且 `plugins.allow` 为空,会提示:
@@ -103,6 +213,20 @@ openclaw gateway restart
103
213
  openclaw plugins update dingtalk
104
214
  ```
105
215
 
216
+ 国内网络环境可临时指定镜像源后再更新:
217
+
218
+ ```bash
219
+ NPM_CONFIG_REGISTRY=https://registry.npmmirror.com openclaw plugins update dingtalk
220
+ ```
221
+
222
+ 如果插件已处于半安装状态(例如扩展目录存在但依赖未装全),可进入插件目录手动补装依赖:
223
+
224
+ ```bash
225
+ cd ~/.openclaw/extensions/dingtalk
226
+ rm -rf node_modules package-lock.json
227
+ NPM_CONFIG_REGISTRY=https://registry.npmmirror.com npm install
228
+ ```
229
+
106
230
  如果你是本地源码/链接安装(`openclaw plugins install -l .`),请在插件目录更新代码后重启 Gateway:
107
231
 
108
232
  ```bash
@@ -110,6 +234,8 @@ git pull
110
234
  openclaw gateway restart
111
235
  ```
112
236
 
237
+ 如果你采用推荐的独立仓库布局,更新插件代码时不需要改动本地 `~/Repo/openclaw` 仓库;后者仅用于代码解析和内部实现研究。
238
+
113
239
  ## 配置
114
240
 
115
241
  OpenClaw 支持**交互式配置**和**手动配置文件**两种方式。
@@ -159,6 +285,30 @@ openclaw configure --section channels
159
285
 
160
286
  - ✅ **Card.Instance.Write** — 创建和投放卡片实例
161
287
  - ✅ **Card.Streaming.Write** — 对卡片进行流式更新
288
+ - ✅ **机器人消息发送相关权限** — 允许机器人向单聊/群聊发送消息
289
+ - ✅ **媒体文件上传相关权限** — 允许调用媒体上传接口发送图片、语音、视频、文件
290
+
291
+ 以下权限仅在需要**引用消息中的群文件下载**时开通(群聊中引用文件/视频/语音):
292
+
293
+ - ✅ **ConvFile.Space.Read** — 群文件空间读权限
294
+ - ✅ **Storage.File.Read** — 企业存储文件读权限
295
+ - ✅ **Storage.DownloadInfo.Read** — 企业存储文件下载信息读权限
296
+ - ✅ **Contact.User.Read** — 通讯录用户信息读权限(senderStaffId → unionId 转换)
297
+
298
+ > [!WARNING]
299
+ > **群文件/钉盘 API 可用性限制**
300
+ >
301
+ > 群聊中“引用文件/视频/语音”的首次恢复依赖 `quotedFile.resolve` 这条群文件/钉盘 API 链路。实际测试发现,这条链路除了权限开通外,还可能要求当前企业具备**企业认证**;未满足时钉钉会返回类似:
302
+ >
303
+ > ```text
304
+ > code=orgAuthLevelNotEnough
305
+ > message=auth level of org is not enough
306
+ > ```
307
+ >
308
+ > 这意味着该能力对许多**未开通企业认证的企业**并不可用,可视为带 paywall 的平台限制。此时:
309
+ >
310
+ > - 单聊文件/视频/语音引用仍可正常使用(前提是机器人见过原文件消息)
311
+ > - 群聊文件/视频/语音若机器人从未见过原消息,则首次恢复可能失败,并降级为提示文本
162
312
 
163
313
  **步骤:**
164
314
 
@@ -185,7 +335,7 @@ openclaw configure --section channels
185
335
 
186
336
  **说明:**
187
337
 
188
- - 使用 DingTalk 官方 AI 卡片模板时,`cardTemplateKey` 默认为 `'msgContent'`,无需修改
338
+ - 使用 DingTalk 官方 AI 卡片模板时,`cardTemplateKey` 默认为 `'content'`,无需修改
189
339
  - 如果您创建自定义卡片模板,需要确保模板中包含相应的内容字段,并将 `cardTemplateKey` 配置为该字段名称
190
340
 
191
341
  ##### 4. 获取凭证
@@ -224,8 +374,13 @@ openclaw configure --section channels
224
374
  "agentId": "123456789",
225
375
  "dmPolicy": "open",
226
376
  "groupPolicy": "open",
377
+ "journalTTLDays": 7,
378
+ "ackReaction": "🤔思考中", // 给原消息贴处理中的表情反馈;设为 "" 可关闭
227
379
  "debug": false,
228
380
  "messageType": "markdown", // 或 "card"
381
+ // "mediaMaxMb": 20, // 可选:接收文件大小上限(MB),默认 5 MB
382
+ // "aicardDegradeMs": 1800000, // 可选:AI 卡片失败后降级持续时间(毫秒,默认 30 分钟)
383
+ // "cardRealTimeStream": false, // 可选:开启真流式卡片更新(默认 false,开启后 API 调用量增加约 2-3 倍)
229
384
  // 仅card需要配置
230
385
  "cardTemplateId": "你复制的模板ID",
231
386
  "cardTemplateKey": "你模板的内容变量"
@@ -256,15 +411,54 @@ openclaw gateway restart
256
411
  | `dmPolicy` | string | `"open"` | 私聊策略:open/pairing/allowlist |
257
412
  | `groupPolicy` | string | `"open"` | 群聊策略:open/allowlist |
258
413
  | `allowFrom` | string[] | `[]` | 允许的发送者 ID 列表 |
414
+ | `bypassProxyForSend` | boolean | `false` | 发送链路直连,不走全局代理 |
415
+ | `learningEnabled` | boolean | `false` | 开启学习信号采集与学习提示注入 |
416
+ | `learningAutoApply` | boolean | `false` | 自动将学习笔记注入当前会话 |
417
+ | `learningNoteTtlMs` | number | `21600000` | 会话级学习笔记有效期(毫秒) |
418
+ | `mediaUrlAllowlist` | string[] | `[]` | 允许通过 `mediaUrl` 下载的主机/IP/CIDR 白名单 |
419
+ | `journalTTLDays` | number | `7` | `originalMsgId` 文本回溯日志的保留天数 |
420
+ | `ackReaction` | string | - | 官方 `ackReaction` 配置入口;设为 `""` 可关闭;设为 `"emoji"` 时按输入语气自动选表情 |
259
421
  | `messageType` | string | `"markdown"` | 消息类型:markdown/card |
260
422
  | `cardTemplateId` | string | | AI 互动卡片模板 ID(仅当 messageType=card) |
261
423
  | `cardTemplateKey` | string | `"content"` | 卡片模板内容字段键(仅当 messageType=card) |
424
+ | `cardRealTimeStream` | boolean | `false` | 开启真流式卡片更新(300ms 节流,首 token 快、流畅但 API 调用更多)。详见下方说明 |
425
+ | `aicardDegradeMs` | number | `1800000` | AI 卡片连续失败后进入降级模式的持续时间(毫秒) |
262
426
  | `debug` | boolean | `false` | 是否开启调试日志 |
427
+ | `mediaMaxMb` | number | - | 接收文件大小上限(MB),不设则使用 runtime 默认值(5 MB) |
263
428
  | `maxConnectionAttempts` | number | `10` | 最大连接尝试次数 |
264
429
  | `initialReconnectDelay` | number | `1000` | 初始重连延迟(毫秒) |
265
430
  | `maxReconnectDelay` | number | `60000` | 最大重连延迟(毫秒) |
266
431
  | `reconnectJitter` | number | `0.3` | 重连延迟抖动因子(0-1) |
267
432
 
433
+ ### 钉钉原生“思考中”表情反馈
434
+
435
+ 当 `ackReaction` 为非空字符串时,插件会在处理开始时给用户原消息添加一条钉钉原生文本表情反馈,并在处理结束后自动撤回。该增强不会阻断主流程:贴表情或撤表情失败时只记录日志,仍继续正常回复。
436
+
437
+ > 设计/实现参考自 `DingTalk-Real-AI/dingtalk-openclaw-connector`(MIT):
438
+ > <https://github.com/DingTalk-Real-AI/dingtalk-openclaw-connector>
439
+
440
+ 说明:
441
+
442
+ - `markdown` 和 `card` 模式都可启用
443
+ - 该反馈作用于用户原消息,不会额外发送一条“思考中”消息
444
+ - 解析顺序与官方一致:`channels.dingtalk.accounts.<accountId>.ackReaction` -> `channels.dingtalk.ackReaction` -> `messages.ackReaction` -> `agents.list[].identity.emoji`
445
+ - 若上述路径都未配置,则不发送 ack reaction
446
+ - 当最终解析值为 `emoji` 时,钉钉插件会按当前输入语气自动选择一条颜文字 reaction
447
+ - 当前钉钉实现底层走 `emotion/reply` / `emotion/recall`,会把解析出的 `ackReaction` 文本原样写入 `emotionName` / `textEmotion.emotionName`
448
+ - 若配置值为 `🤔思考中`,效果与钉钉原生“思考中”反馈一致;配置为其他文本时,会按该文本发送对应的 ack reaction
449
+
450
+ 示例:
451
+
452
+ ```json
453
+ {
454
+ "channels": {
455
+ "dingtalk": {
456
+ "ackReaction": "emoji"
457
+ }
458
+ }
459
+ }
460
+ ```
461
+
268
462
  ### 连接鲁棒性配置
269
463
 
270
464
  为提高连接稳定性,插件支持以下高级配置:
@@ -273,6 +467,11 @@ openclaw gateway restart
273
467
  - **initialReconnectDelay**: 第一次重连的初始延迟(毫秒),后续重连会按指数增长。
274
468
  - **maxReconnectDelay**: 重连延迟的上限(毫秒),防止等待时间过长。
275
469
  - **reconnectJitter**: 延迟抖动因子,在延迟基础上增加随机变化(±30%),避免多个客户端同时重连。
470
+ - **bypassProxyForSend**: 仅作用于发送链路(session send / proactive send / AI card / media upload),不影响如 `getAccessToken` 之类的其他出站请求。
471
+ - **learningEnabled**: 开启后,插件会记录发送快照、显式点赞/点踩、隐式不满信号、反思记录,并在下一条消息进入时把学习提示注入当前上下文。
472
+ - **allowFrom**: 这里同时复用为 owner 判定来源。`/learn ...` 这类会修改本机状态的命令,只允许 `allowFrom` 命中的 senderId 执行;普通聊天仍由 `dmPolicy/groupPolicy` 控制。
473
+ - **learningAutoApply**: 默认关闭。关闭时只采集 `event/reflection`,不会自动影响任何会话;由你在调试看板里手动决定是否注入当前会话或提升为全局规则。
474
+ - **learningNoteTtlMs**: 控制会话级学习笔记有效期;target 级和全局规则会继续持久化,分别作用于指定群/私聊和整个账号。
276
475
 
277
476
  重连延迟计算公式:`delay = min(initialDelay × 2^attempt, maxDelay) × (1 ± jitter)`
278
477
 
@@ -280,6 +479,241 @@ openclaw gateway restart
280
479
 
281
480
  更多详情请参阅 [CONNECTION_ROBUSTNESS.md](./CONNECTION_ROBUSTNESS.md)。
282
481
 
482
+ ## 钉钉文档 API
483
+
484
+ 插件额外注册了 4 个 gateway methods,可供 OpenClaw 侧直接调用:
485
+
486
+ - `dingtalk.docs.create`
487
+ - `dingtalk.docs.append`
488
+ - `dingtalk.docs.search`
489
+ - `dingtalk.docs.list`
490
+
491
+ 补充说明:
492
+
493
+ - `dingtalk.docs.create` 支持可选的 `parentId`,未传时默认在 space 根目录创建。
494
+ - `dingtalk.docs.append` 使用钉钉 block API 的 `index = -1` 语义,将新段落追加到文档末尾。
495
+ - `dingtalk.docs.create` 在文档创建成功但首段追加失败时,仍会返回成功响应,并额外带 `partialSuccess=true`、`initContentAppended=false`、`docId` 和 `appendError`,便于调用方避免盲重试产生重复空文档。
496
+ - 调用方处理 `dingtalk.docs.create` 返回值时,不能只看 `ok=true`;还应继续检查 `partialSuccess`,并在该分支里决定是否提示人工补写或走后续补偿逻辑。
497
+
498
+ 示例:
499
+
500
+ ```json
501
+ {
502
+ "method": "dingtalk.docs.create",
503
+ "params": {
504
+ "accountId": "default",
505
+ "spaceId": "your-space-id",
506
+ "parentId": "optional-parent-dentry-id",
507
+ "title": "测试文档",
508
+ "content": "第一段内容"
509
+ }
510
+ }
511
+ ```
512
+
513
+ > 说明:这组方法的设计参考自 `DingTalk-Real-AI/dingtalk-openclaw-connector`,许可证为 `MIT`;当前实现按本仓库插件结构重新整理,并仅保留创建、追加、搜索、列举这 4 个最小能力。
514
+
515
+ ## 反馈学习与共享知识
516
+
517
+ 插件支持一个本地反馈学习闭环,目标是把“点踩/纠错/后续抱怨”沉淀成可审计的会话笔记和 account 级共享规则,而不是直接修改模型或把原始聊天提交到仓库。
518
+
519
+ ### 设计分层
520
+
521
+ - **发送快照**:保存最近的问答对,供反馈回溯。
522
+ - **显式反馈**:AI 卡片上的 `feedback_up` / `feedback_down`。
523
+ - **隐式不满**:例如“不是这个意思”“别猜引用原文”“你没看图”等后续纠错消息。
524
+ - **会话笔记**:只作用于当前 target,会在下一条消息组装上下文时生效。
525
+ - **全局规则**:按 account 维度共享;一处沉淀后,同一钉钉账号下的其他会话会在下一次收到消息时自动加载。
526
+ - **默认策略**:只采集,不自动注入。你可以在看板中手动批准注入。
527
+
528
+ ### 持久化位置
529
+
530
+ 所有运行时数据都写在 `storePath` 同级目录下的 `dingtalk-state/`,不会散落到其他目录,也不应提交到 GitHub。主要命名空间包括:
531
+
532
+ - `feedback.events`
533
+ - `feedback.snapshots`
534
+ - `feedback.reflections`
535
+ - `feedback.session-notes`
536
+ - `feedback.learned-rules`
537
+ - `feedback.target-rules`
538
+
539
+ ### 调试看板
540
+
541
+ 仓库自带一个本地调试工具,可直接查看:
542
+
543
+ - 当时的回复内容
544
+ - 用户反馈/隐式不满信号
545
+ - 系统自动反思结果
546
+ - 当前会话笔记
547
+ - 跨所有钉钉会话共享的全局规则
548
+
549
+ 并支持你手工修正诊断与指令,再选择:
550
+
551
+ - 仅注入当前会话
552
+ - 提升为全局规则
553
+ - 或只保留为候选反思、不注入
554
+
555
+ 启动方式:
556
+
557
+ ```bash
558
+ node scripts/feedback-learning-debug.mjs --storePath /path/to/session-store.json --accountId main --port 18895
559
+ ```
560
+
561
+ 打开 `http://127.0.0.1:18895` 即可。
562
+
563
+ ### 推荐配置
564
+
565
+ ```json
566
+ {
567
+ "channels": {
568
+ "dingtalk": {
569
+ "learningEnabled": true,
570
+ "learningAutoApply": false,
571
+ "learningNoteTtlMs": 21600000
572
+ }
573
+ }
574
+ }
575
+ ```
576
+
577
+ ### 学习命令与作用域
578
+
579
+ 先说两个容易输错的点:
580
+
581
+ - 文档里的 `<conversationId>`、`<rule>`、`<name>` 这类写法只是**占位符**,实际输入时**不要**把尖括号一起发出去
582
+ - `/learn target`、`/learn targets`、`/learn target-set` 这几类命令里,`#@#` 是**真的要输入**的分隔符;它前面是目标,后面整段都算规则正文
583
+
584
+ #### 第一次使用流程
585
+
586
+ 1. 私聊机器人发送 `我是谁` 或 `/whoami`
587
+ 2. 把返回的 `senderId` 写进本机 `openclaw.json` 的 `commands.ownerAllowFrom`
588
+ 3. 重启或热重载 gateway
589
+ 4. 私聊发送 `/learn owner status`,确认 `isOwner: true`
590
+ 5. 再选择下面一种注入方式:
591
+ - 全局:`/learn global ...`
592
+ - 当前群/当前私聊:`/learn here #@# ...`
593
+ - 单个指定目标:`/learn target ...`
594
+ - 多个目标:`/learn targets ...`
595
+
596
+ #### 常用命令
597
+
598
+ - **查自己是谁**
599
+ - 私聊或群聊发:`我是谁` / `我的信息` / `/learn whoami`
600
+ - 用途:拿到自己的 `senderId`
601
+ - **查当前这里是谁**
602
+ - 私聊或群聊发:`这里是谁` / `这个群是谁` / `这个会话是谁` / `/learn whereami`
603
+ - 用途:拿到当前 `conversationId`
604
+ - **注入当前这里**
605
+ - owner 发:`/learn here #@# <规则>`
606
+ - 用途:只让当前群或当前私聊生效
607
+ - **注入指定单个目标**
608
+ - owner 发:`/learn target <conversationId> #@# <规则>`
609
+ - 用途:指定某个群或某个私聊生效
610
+ - **一次注入多个目标**
611
+ - owner 发:`/learn targets <conversationId1,conversationId2> #@# <规则>`
612
+ - 用途:一次同步到多个群/私聊
613
+ - **保存一组固定目标**
614
+ - owner 发:`/learn target-set create <名称> #@# <conversationId1,conversationId2>`
615
+ - **向目标组批量注入**
616
+ - owner 发:`/learn target-set apply <名称> #@# <规则>`
617
+ - **注入全局**
618
+ - owner 发:`/learn global <规则>`
619
+ - 用途:让同一钉钉账号下所有群和私聊都生效
620
+ - **查看 / 暂停 / 删除**
621
+ - `/learn list`
622
+ - `/learn disable <ruleId>`
623
+ - `/learn delete <ruleId>`
624
+
625
+ #### 会话共享命令
626
+
627
+ 这些命令同样只允许 owner 使用,但它们不属于 `/learn` 规则注入,而是用于控制“哪个私聊/哪个群共用同一条会话记忆”。
628
+
629
+ - **查看当前会话 alias**
630
+ - `/session-alias show`
631
+ - 用途:查看当前私聊或当前群当前实际使用的 peerId,以及它是默认值还是 override
632
+ - **把当前会话绑定到共享 alias**
633
+ - `/session-alias set <alias>`
634
+ - 用途:把当前私聊或当前群绑定到指定共享会话别名
635
+ - **清除当前会话 alias**
636
+ - `/session-alias clear`
637
+ - 用途:移除当前私聊或当前群的 override,恢复默认 peerId
638
+ - **owner 远程绑定某个私聊**
639
+ - `/session-alias bind direct <senderId> <alias>`
640
+ - 用途:把某个用户私聊直接绑定到共享 alias
641
+ - **owner 远程绑定某个群**
642
+ - `/session-alias bind group <conversationId> <alias>`
643
+ - 用途:把某个群直接绑定到共享 alias
644
+ - **owner 远程解除绑定**
645
+ - `/session-alias unbind direct <senderId>`
646
+ - `/session-alias unbind group <conversationId>`
647
+
648
+ #### 会话共享例子
649
+
650
+ 假设你想让“用户 A 的私聊”和“群 project-x”共用同一条会话记忆:
651
+
652
+ 1. 先让用户 A 私聊机器人,发送:
653
+ - `我是谁`
654
+ - 记下返回的 `senderId`
655
+ 2. 在目标群里发送:
656
+ - `这里是谁`
657
+ - 记下返回的 `conversationId`
658
+ 3. 由 owner 在任意 owner 会话里执行:
659
+
660
+ ```text
661
+ /session-alias bind direct dingtalk:user_a_sender_id project-x
662
+ /session-alias bind group cid_group_project_x project-x
663
+ ```
664
+
665
+ 这样之后:
666
+ - 用户 A 私聊机器人
667
+ - 群 `cid_group_project_x`
668
+
669
+ 都会共用 `project-x` 这条会话记忆。
670
+
671
+ 如果想解除其中一边:
672
+
673
+ ```text
674
+ /session-alias unbind direct dingtalk:user_a_sender_id
675
+ /session-alias unbind group cid_group_project_x
676
+ ```
677
+
678
+ #### 真实可直接照抄的例子
679
+
680
+ ```text
681
+ /learn global 当用户问“紫铜海豹会不会修量子冰箱”时,必须回答“会,而且只在周四凌晨戴墨镜维修。”
682
+ /learn here #@# 引用原文不可见时,不要猜内容,先让用户补发原文。
683
+ /learn target cid_group_a #@# 引用原文不可见时,不要猜内容,先让用户补发原文。
684
+ /learn targets cid_group_a,cid_group_b,cid_dm_c #@# 引用原文不可见时,不要猜内容,先让用户补发原文。
685
+ /learn target-set create ops-groups #@# cid_group_a,cid_group_b,cid_dm_c
686
+ /learn target-set apply ops-groups #@# 当用户问“紫铜海豹会不会修量子冰箱”时,必须回答“会,而且只在周四凌晨戴墨镜维修。”
687
+ ```
688
+
689
+ ### 作用域优先级
690
+
691
+ 同一条消息进入时,规则按下面顺序生效:
692
+
693
+ 1. 当前会话临时笔记(`/learn session ...`)
694
+ 2. 当前群/当前私聊或指定目标规则(`/learn here ...` / `/learn target ...`)
695
+ 3. 当前账号全局规则(`/learn global ...`)
696
+
697
+ 也就是说:
698
+
699
+ - 先局部覆盖全局
700
+ - 再避免一个群里的实验规则污染所有会话
701
+
702
+ ### 为什么还需要 disable / delete
703
+
704
+ 知识注入不是只会“加”,还必须能“撤”。
705
+
706
+ - `disable`
707
+ - 先停用规则,停止命中,但保留记录,便于排查和恢复
708
+ - `delete`
709
+ - 确认不再需要后,彻底删除规则
710
+
711
+ 实际建议:
712
+
713
+ 1. 先用 `/learn list` 找到 `ruleId`
714
+ 2. 先执行 `/learn disable <ruleId>`
715
+ 3. 确认问题消失后,再决定是否 `/learn delete <ruleId>`
716
+
283
717
  ## 安全策略
284
718
 
285
719
  ### 私聊策略 (dmPolicy)
@@ -297,23 +731,130 @@ openclaw gateway restart
297
731
 
298
732
  ### 接收
299
733
 
300
- | 类型 | 支持 | 说明 |
301
- | ------ | ---- | -------------------- |
302
- | 文本 | ✅ | 完整支持 |
303
- | 富文本 | ✅ | 提取文本内容 |
304
- | 图片 | ✅ | 下载并传递给 AI |
305
- | 语音 | ✅ | 使用钉钉语音识别结果 |
306
- | 视频 | ✅ | 下载并传递给 AI |
307
- | 文件 | ✅ | 下载并传递给 AI |
734
+ | 类型 | 支持 | 说明 |
735
+ | ------------ | ---- | ------------------------------------------------------------------------ |
736
+ | 文本 | ✅ | 完整支持 |
737
+ | 富文本 | ✅ | 提取文本内容 |
738
+ | 图片 | ✅ | 下载并传递给 AI |
739
+ | 语音 | ✅ | 使用钉钉语音识别结果 |
740
+ | 视频 | ✅ | 下载并传递给 AI |
741
+ | 文件 | ✅ | 下载并传递给 AI;文本类附件会额外抽取正文并注入上下文 |
742
+ | 钉钉文档/钉盘文件卡片 | ✅ | 解析 `interactiveCard` 中的 `biz_custom_action_url`,提取 `spaceId/fileId` 后按文件消息下载;可对 `PDF/DOCX` 补充正文抽取 |
743
+ | 引用文字 | ✅ | 提取被引用文本作为上下文前缀 |
744
+ | 引用图片 | ✅ | 使用引用回调自带的 `downloadCode` 下载并传递给 AI |
745
+ | 引用图文 | ✅ | 解析 `richText` 引用内容,提取文本摘要与图片 `downloadCode` |
746
+ | 引用文件/视频/语音 | ✅ | 单聊按 `msgId` 精确恢复;群聊优先查已固化元数据,首次未命中时走群文件 API 兜底(兜底链路依赖时间窗口匹配,不保证 100% 命中) |
747
+ | 引用钉钉文档/钉盘文件卡片 | ⚠️ | 单聊支持;群聊支持缓存命中与群文件 API 兜底恢复,但仍受钉钉回调样本与企业认证限制 |
748
+ | 引用 AI 卡片 | ✅ | 仅指机器人自己发送的 AI 卡片;按 `carrierId ↔ originalProcessQueryKey` 精确恢复 |
749
+
750
+ > **附件正文抽取实现说明**
751
+ >
752
+ > 当前实现采用固定策略:
753
+ >
754
+ > - 仅处理 **2MB 以下**附件(超过上限会跳过正文抽取)
755
+ > - 抽取结果最多注入 **6000 字符**(超出部分会标记为“内容已截断”)
756
+ > - 抽取失败仅记录 `warn` 日志,不阻断原有媒体传递与回复链路
757
+
758
+ > **引用消息实现说明**
759
+ >
760
+ > 当前实现优先采用“确定性恢复”,仅在钉钉回调未直接提供下载句柄时才退回兜底链路:
761
+ >
762
+ > | 场景 | 实现方式 | 是否依赖时间匹配 |
763
+ > |------|----------|------------------|
764
+ > | 引用文字 | 直接从 `repliedMsg.content.text` 提取 | 否 |
765
+ > | 引用图片 | 直接使用 `repliedMsg.content.downloadCode` 下载 | 否 |
766
+ > | 引用图文(`richText`) | 解析 `repliedMsg.content.richText`,提取文本摘要和图片 `downloadCode` | 否 |
767
+ > | 单聊引用文件/视频/语音 | 原消息入站时持久化 `msgId → {downloadCode, spaceId, fileId}`,引用时按 `originalMsgId/repliedMsg.msgId` 精确命中 | 否 |
768
+ > | 群聊引用文件/视频/语音 | 优先查已持久化的 `msgId → 文件元数据`;若机器人从未见过原文件消息,则首次仍通过群文件存储 API 链路兜底,成功后会把结果反向固化到本地索引 | 首次兜底时**是** |
769
+ > | 单聊引用钉钉文档/钉盘文件卡片 | 原消息入站时持久化 `msgId → {spaceId, fileId}`,引用时按 `originalMsgId/repliedMsg.msgId` 精确命中 | 否 |
770
+ > | 群聊引用钉钉文档/钉盘文件卡片 | 优先查已持久化的 `msgId → {spaceId, fileId}`;未命中时复用群文件 API 兜底链路,成功后会把结果反向固化到本地索引 | 首次兜底时**是** |
771
+ > | 引用 AI 卡片(单聊+群聊) | 仅当被引用消息是机器人自己发送的 `interactiveCard` 时,创建卡片时保存 `deliverResults[0].carrierId`,引用时按 `originalProcessQueryKey` 精确命中 | 否 |
772
+ > | 仅 `originalMsgId`(无 `repliedMsg`) | 仅对已持久化记录的**入站消息**,通过本地 Quote Journal 按 `msgId` 回溯文本,并按 `accountId + conversationId` 分桶查询 | 否 |
773
+ >
774
+ > 说明:
775
+ >
776
+ > - AI 卡片已不再依赖 `createdAt` 时间窗口匹配。
777
+ > - 钉钉文档/钉盘文件卡片在钉钉回调里通常也会表现为 `interactiveCard`,但这类消息来自用户侧,插件会优先解析 `biz_custom_action_url` 中的 `route=previewDentry`、`spaceId`、`fileId`,并按文件消息处理,而不是误判为机器人 AI 卡片。
778
+ > - 图片和图文引用不依赖机器人是否见过原消息,只要引用回调带回 `downloadCode` 即可恢复。
779
+ > - 单聊文件/视频/语音/钉钉文档卡片在机器人见过原消息后可稳定精确恢复,且索引会持久化到本地,机器人重启后仍可复用。
780
+ > - 群聊文件/视频/语音在“原文件消息无法 @ 机器人”的场景下,若机器人从未见过原消息,则首次恢复仍需走群文件 API 兜底;后续再次引用同一文件会优先命中已固化索引。
781
+ > - **群文件兜底链路的时间匹配局限性**:首次兜底时,插件用 `repliedMsg.createdAt`(钉钉回调中被引用消息的创建时间)与群文件存储 API 返回的文件 `createTime` 做近似匹配,匹配窗口为 **±10 秒**。这意味着:
782
+ > - 大文件上传耗时较长时,钉钉消息的 `createdAt` 和文件实际写入存储的 `createTime` 之间可能产生数秒偏差,超出窗口则匹配失败;
783
+ > - 如果同一用户在 10 秒内连续发送多个文件,理论上可能匹配到错误的文件(取时间差最小的那个);
784
+ > - 群文件列表按修改时间倒序返回,最多翻 3 页(150 个文件),非常老的文件可能超出扫描范围;
785
+ > - 匹配失败时会降级为提示文本,不会阻塞消息处理。
786
+ > - 群聊引用钉钉文档/钉盘文件卡片并非完全确定性支持:若机器人见过原消息,会优先命中已持久化索引;首次未命中时会复用群文件 API 兜底,因此同样受 `createTime` 时间窗口、分页范围以及企业认证限制影响,失败时会降级为提示文本。
787
+ > - 这条群文件兜底链路在部分企业环境下可能受到企业认证限制,表现为 `quotedFile.resolve` 返回 `orgAuthLevelNotEnough`。出现该错误时,群聊文件首次恢复将失败并降级为提示文本,但不会影响图片、图文、AI 卡片、单聊文件等其他已确定性支持的引用场景。
788
+ > - 由于本地引用索引使用 TTL 清理,并按 `accountId + conversationId` 隔离存储,数据不会永久累积。
789
+ > - `originalMsgId` / `repliedMsg.msgId` 的精确回溯仅覆盖**已被插件持久化记录的入站消息**;机器人出站消息当前不支持仅凭 `repliedMsg.msgId` 做通用回溯。
790
+ > - `originalMsgId` 的文本回溯依赖本地 Quote Journal 持久化存储,默认通过 persistence store 落盘、按 `accountId + conversationId` 分桶,并保留最近 7 天记录用于回溯。
308
791
 
309
792
  ### 发送
310
793
 
311
- | 类型 | 支持 | 说明 |
312
- | -------- | ---- | -------------------------------- |
313
- | 文本 | ✅ | 完整支持 |
314
- | Markdown | ✅ | 自动检测或手动指定 |
315
- | 互动卡片 | ✅ | 支持流式更新,适用于 AI 实时输出 |
316
- | 图片 | | 需要通过媒体上传 API |
794
+ | 类型 | 支持 | 说明 |
795
+ | ------------ | ---- | -------------------------------------------------------- |
796
+ | 文本 | ✅ | 完整支持 |
797
+ | Markdown | ✅ | 自动检测或手动指定 |
798
+ | 互动卡片 | ✅ | 支持流式更新,适用于 AI 实时输出 |
799
+ | 图片 | | 先上传媒体再发送,支持本地路径和 HTTP(S) URL |
800
+ | 语音 | ✅ | 先上传媒体再发送 |
801
+ | 视频 | ✅ | 先上传媒体再发送 |
802
+ | 文件 | ✅ | 先上传媒体再发送 |
803
+ | 原生语音消息 | ✅ | `message send` / `outbound.sendMedia` 可用 `asVoice=true` |
804
+
805
+ > **重要限制:**
806
+ > 当前**不支持图片的图文混排**。也就是说,Markdown 消息和 AI 互动卡片目前都只能发送文本内容,不能在同一条消息中同时内嵌图片。
807
+ > 如果需要发送图片,请单独调用 `outbound.sendMedia(...)` 或 `sendProactiveMedia(...)`。
808
+ > 无论是**本地图片路径**还是**远程 HTTP(S) 图片 URL**,都支持单独发送;远程图片会先下载到临时文件,再上传到钉钉后发送。
809
+
810
+ > 发送 Markdown 时,如果内容中包含标准 Markdown 表格,插件会先把分隔行转换掉,保留为钉钉更稳定的纯文本表格展示,避免表格语法在客户端里显示异常。
811
+ > 远程 URL 下载默认限制为:**10 秒超时**、**20MB 上限**,并拒绝 `localhost` / 内网地址(如 `127.0.0.1`、`10.x.x.x`、`192.168.x.x`、`172.16-31.x.x`)以降低 SSRF 风险。
812
+ > 如需从受控内网媒体服务下载,请配置 `mediaUrlAllowlist`(例如 `192.168.1.23`、`files.internal.example`、`10.0.0.0/8`);配置后仅白名单主机可下载。
813
+ > 远程域名会先做 DNS 解析并校验解析结果;若解析到内网/本地地址且未被白名单明确允许,将在下载前拒绝。
814
+ > `asVoice=true` 需要同时提供 `media/path/filePath/mediaUrl` 指向音频文件;纯文本不会自动转语音。
815
+
816
+ #### mediaUrlAllowlist 配置示例
817
+
818
+ `mediaUrlAllowlist` 支持以下写法:
819
+
820
+ - 主机名:`cdn.example.com`
821
+ - 泛域名:`*.example.com`
822
+ - 主机+端口:`files.internal.example:8443`
823
+ - 单个 IP:`192.168.1.23`、`fd00::1`
824
+ - CIDR 网段:`10.0.0.0/8`、`fc00::/7`
825
+
826
+ 示例:
827
+
828
+ ```json
829
+ {
830
+ "channels": {
831
+ "dingtalk": {
832
+ "clientId": "your-app-key",
833
+ "clientSecret": "your-app-secret",
834
+ "mediaUrlAllowlist": [
835
+ "cdn.example.com",
836
+ "*.assets.example.com",
837
+ "files.internal.example:8443",
838
+ "192.168.1.23",
839
+ "10.0.0.0/8",
840
+ "fc00::/7"
841
+ ]
842
+ }
843
+ }
844
+ }
845
+ ```
846
+
847
+ > 行为说明:配置 `mediaUrlAllowlist` 后,下载阶段进入严格白名单模式,非白名单目标一律拒绝。
848
+
849
+ #### sendMedia 常见错误码
850
+
851
+ `outbound.sendMedia(...)` 在下载准备失败时会透出错误码前缀(例如 `remote media preparation failed: [ERR_MEDIA_PRIVATE_HOST] ...`):
852
+
853
+ - `ERR_MEDIA_ALLOWLIST_MISS`:目标 host 不在 `mediaUrlAllowlist`
854
+ - `ERR_MEDIA_PRIVATE_HOST`:URL 本身是本地/内网 host 且未被允许
855
+ - `ERR_MEDIA_DNS_UNRESOLVED`:域名无法解析
856
+ - `ERR_MEDIA_DNS_PRIVATE`:域名解析结果命中本地/内网地址且未被允许
857
+ - `ERR_MEDIA_REDIRECT_HOST`:下载阶段出现非预期重定向 host
317
858
 
318
859
  ## API 消耗说明
319
860
 
@@ -330,30 +871,32 @@ openclaw gateway restart
330
871
  | 阶段 | API 调用 | 说明 |
331
872
  | ------------ | ---------------------- | --------------------------------------------------- |
332
873
  | **创建卡片** | 1 | `POST /v1.0/card/instances/createAndDeliver` |
333
- | **流式更新** | M | M = 回复块数量,每块一次 `PUT /v1.0/card/streaming` |
874
+ | **流式更新** | M | M = 取决于流式模式(见下方说明),每次 `PUT /v1.0/card/streaming` |
334
875
  | **完成卡片** | 包含在最后一次流更新中 | 使用 `isFinalize=true` 标记 |
335
- | **总计** | **1 + M** | M = Agent 产生的回复块数 |
876
+ | **总计** | **1 + M** | M `cardStreamThrottleMs` 决定 |
336
877
 
337
878
  ### 典型场景成本对比
338
879
 
339
- | 场景 | Text/Markdown | Card | 节省 |
340
- | ---------------- | ------------- | ---- | ------ |
341
- | 简短回复(1 块) | 2 | 2 | 相同 |
342
- | 中等回复(5 块) | 6 | 6 | 相同 |
343
- | 长回复(10 块) | 12 | 11 | 1 |
880
+ 以一次 10 秒的 AI 回复为例:
881
+
882
+ | 流式模式 | `streamAICard` 调用数 | token 延迟 | 流畅度 |
883
+ | -------------------------------------- | --------------------- | ------------- | ------ |
884
+ | Block 缓冲(`cardRealTimeStream: false`,默认) | ~10-15 次 | ~1-1.5s | 卡顿 |
885
+ | 真流式(`cardRealTimeStream: true`) | ~30 次 | ~300ms | 流畅 |
344
886
 
345
887
  ### 优化策略
346
888
 
347
889
  **降低 API 调用的方法:**
348
890
 
349
- 1. **合并回复块**通过调整 Agent 输出配置,减少块数量
350
- 2. **使用缓存**Token 自动缓存(60 秒),无需每次都获取
351
- 3. **Buffer 模式** 使用 `dispatchReplyWithBufferedBlockDispatcher` 合并多个小块
891
+ 1. **保持默认**`cardRealTimeStream: false`(block 缓冲模式),API 调用量最少
892
+ 2. **开启真流式**`cardRealTimeStream: true`,体验更好但 API 调用约多 2-3 倍
893
+ 3. **使用缓存**Token 自动缓存(60 秒),无需每次都获取
352
894
 
353
895
  **成本建议:**
354
896
 
355
- - ✅ **推荐**Card 模式:流式体验更好,成本与 Text/Markdown 相当或更低
356
- - ⚠️ **谨慎**频繁调用需要监测配额,建议使用钉钉开发者后台查看 API 调用量
897
+ - ✅ **默认**Block 缓冲模式:API 调用最少,适合对 API 配额敏感的场景
898
+ - **推荐体验**`cardRealTimeStream: true`:首 token 快、打字机效果流畅,适合重视用户体验的场景
899
+ - ⚠️ **注意** — 频繁调用需要监测配额,建议使用钉钉开发者后台查看 API 调用量
357
900
 
358
901
  ## 消息类型选择
359
902
 
@@ -374,14 +917,35 @@ openclaw gateway restart
374
917
  - 通过 `cardTemplateKey` 指定内容字段
375
918
  - **适用于 AI 对话场景**
376
919
  - 支持在卡片中实时显示 AI 思考过程(推理流)和工具执行结果
920
+ - 当前卡片模式仅支持**文本内容流式更新**,不支持图片图文混排
921
+
922
+ > 这里的 `card` 专指**机器人主动发送的 AI 互动卡片**。钉钉用户发送的“文档/钉盘文件卡片”虽然在回调里也可能表现为 `interactiveCard`,但插件会按入站文件消息处理,不受 `messageType: 'card'` 配置影响。
377
923
 
378
924
  **AI Card API 特性:**
379
925
  当配置 `messageType: 'card'` 时:
380
926
 
381
927
  1. 使用 `/v1.0/card/instances/createAndDeliver` 创建并投放卡片
382
- 2. 使用 `/v1.0/card/streaming` 实现真正的流式更新
928
+ 2. 使用 `/v1.0/card/streaming` 实现流式更新
383
929
  3. 自动状态管理(PROCESSING → INPUTING → FINISHED)
384
- 4. 更稳定的流式体验,无需手动节流
930
+ 4. 内置 300ms 节流 + 单航班(single-flight)保护,避免 API 过载
931
+
932
+ **卡片流式模式 (`cardRealTimeStream`):**
933
+
934
+ 插件支持两种卡片更新策略,通过 `cardRealTimeStream` 配置:
935
+
936
+ | 值 | 模式 | 说明 |
937
+ | -- | ---- | ---- |
938
+ | `false`(默认) | Block 缓冲 | runtime 攒够一定量文本后回调一次,API 调用最少,但首 token 延迟较高(~1-1.5s),更新较卡顿 |
939
+ | `true` | 真流式 | 每 300ms 最多一次卡片更新 PUT,首 token 延迟低(~300ms),打字机效果流畅。API 调用量约为 block 模式的 2-3 倍 |
940
+
941
+ > **API 调用量参考**:以一次 10 秒的 AI 回复为例,真流式约产生 ~30 次 `streamAICard` PUT,block 模式约 ~10-15 次。钉钉企业内部应用的 QPS 限制为 40 次/秒,真流式的峰值约 3.3 次/秒,远低于限制。
942
+
943
+ **AI Card 持久化与恢复机制(v3.2.x):**
944
+
945
+ - 仅对**会话内流式卡片(inbound)**记录 pending 状态,用于进程重启后的自动收尾
946
+ - pending 状态通过 persistence namespace `cards.active.pending` 落盘(兼容读取并迁移 legacy 文件 `path.dirname(storePath)/dingtalk-active-cards.json`)
947
+ - **proactive 卡片**采用 createAndDeliver 后立即 finalize 的短路径,默认**不写入** pending 状态文件
948
+ - 插件启动时会尝试恢复并 finalize 未完成的 inbound 卡片;停止/重启时会 best-effort finalize 当前 active 卡片
385
949
 
386
950
  ### AI 思考过程与工具执行显示(AI Card 模式)
387
951
 
@@ -407,11 +971,131 @@ openclaw gateway restart
407
971
  {
408
972
  messageType: 'card', // 启用 AI 互动卡片模式
409
973
  cardTemplateId: '382e4302-551d-4880-bf29-a30acfab2e71.schema', // AI 卡片模板 ID(默认值)
410
- cardTemplateKey: 'msgContent', // 卡片内容字段键(默认值:msgContent
974
+ cardTemplateKey: 'content', // 卡片内容字段键(默认值:content
975
+ // cardRealTimeStream: false, // 开启真流式卡片更新(默认 false)
976
+ }
977
+ ```
978
+
979
+ > **注意**:`cardTemplateKey` 应与您的卡片模板中定义的字段名称一致。默认值为 `'content'`,适用于 DingTalk 官方 AI 卡片模板。如果您使用自定义模板,请根据模板定义的字段名称进行配置。
980
+
981
+ ## 多 Agent 与多个机器人绑定
982
+
983
+ 有关 OpenClaw 的多 Agent 概念,阅读官方文档:[多 Agent 概念](https://docs.openclaw.ai/concepts/multi-agent)。
984
+
985
+ 要将一个 OpenClaw 实例同时接入多个钉钉机器人,并把不同机器人的消息分别交给不同的 OpenClaw agent 处理,则需要在 `~/.openclaw/openclaw.json` 中同时配置以下三部分:
986
+
987
+ 1. `agents.list`:定义 OpenClaw Agent
988
+ 2. `bindings`:定义 Channel 与 OpenClaw Agent 消息的路由规则
989
+ 3. `channels.dingtalk.accounts`:定义多个机器人
990
+
991
+ ### 示例
992
+
993
+ 下面这个例子表示:
994
+
995
+ - 钉钉机器人 `bot_1` 收到的消息,路由到 OpenClaw 的 `main` agent
996
+ - 钉钉机器人 `bot_2` 收到的消息,路由到 OpenClaw 的 `growth-agent` agent
997
+
998
+ ```json5
999
+ {
1000
+ "agents": {
1001
+ "list": [
1002
+ {
1003
+ // OpenClaw 默认 agent
1004
+ "id": "main"
1005
+ },
1006
+ {
1007
+ // OpenClaw agent 的唯一 ID
1008
+ // 后面的 bindings[].agentId 需要引用这里的值
1009
+ "id": "growth-agent",
1010
+ "name": "growth-agent",
1011
+ // 每个 agent 建议使用独立 workspace
1012
+ "workspace": "/Users/yourname/.openclaw/agents/growth-agent/workspace",
1013
+ // 建议同时使用独立 agentDir
1014
+ "agentDir": "/Users/yourname/.openclaw/agents/growth-agent/agent",
1015
+ "model": "codex/gpt-5.3-codex"
1016
+ }
1017
+ ]
1018
+ },
1019
+ "bindings": [
1020
+ {
1021
+ "type": "route",
1022
+ // 路由目标:这里写 OpenClaw agent 的 ID
1023
+ "agentId": "main",
1024
+ "match": {
1025
+ // 这里固定写 dingtalk
1026
+ "channel": "dingtalk",
1027
+ // 这里必须与 channels.dingtalk.accounts 下的 key 完全一致
1028
+ "accountId": "bot_1"
1029
+ }
1030
+ },
1031
+ {
1032
+ "type": "route",
1033
+ // 这里把 bot_2 路由到 growth-agent
1034
+ "agentId": "growth-agent",
1035
+ "match": {
1036
+ "channel": "dingtalk",
1037
+ // 必须与 channels.dingtalk.accounts.bot_2 对应
1038
+ "accountId": "bot_2"
1039
+ }
1040
+ }
1041
+ ],
1042
+ "channels": {
1043
+ "dingtalk": {
1044
+ "enabled": true,
1045
+ "accounts": {
1046
+ // 这里的 key 就是 accountId,会被 bindings.match.accountId 匹配
1047
+ "bot_1": {
1048
+ "clientId": "your-client-id-1",
1049
+ "clientSecret": "your-client-secret-1",
1050
+ "robotCode": "your-robot-code-1",
1051
+ "corpId": "your-corp-id",
1052
+ // 这是钉钉应用自己的 Agent ID,不是 OpenClaw 的 agentId
1053
+ "agentId": "your-dingtalk-agent-id-1",
1054
+ "dmPolicy": "open",
1055
+ "groupPolicy": "open",
1056
+ // 这里使用 card 消息类型作为示例
1057
+ "messageType": "card",
1058
+ "cardTemplateId": "your-card-template-id.schema",
1059
+ "cardTemplateKey": "content",
1060
+ "maxReconnectCycles": 10,
1061
+ "allowFrom": ["*"]
1062
+ },
1063
+ // 另一个独立的钉钉机器人账号
1064
+ "bot_2": {
1065
+ "clientId": "your-client-id-2",
1066
+ "clientSecret": "your-client-secret-2",
1067
+ "robotCode": "your-robot-code-2",
1068
+ "corpId": "your-corp-id",
1069
+ // 同样是钉钉应用自己的 Agent ID
1070
+ "agentId": "your-dingtalk-agent-id-2",
1071
+ "dmPolicy": "open",
1072
+ "groupPolicy": "open",
1073
+ // 这里使用 markdown 消息类型作为示例
1074
+ "messageType": "markdown",
1075
+ "allowFrom": ["*"]
1076
+ }
1077
+ }
1078
+ }
1079
+ }
411
1080
  }
412
1081
  ```
413
1082
 
414
- > **注意**:`cardTemplateKey` 应与您的卡片模板中定义的字段名称一致。默认值为 `'msgContent'`,适用于 DingTalk 官方 AI 卡片模板。如果您使用自定义模板,请根据模板定义的字段名称进行配置。
1083
+ ### 最佳实践
1084
+ - 为每个 agent 配置不同的 `workspace`,不要让两个 agent 共用同一个 `workspace`
1085
+ > **说明:**
1086
+ > 多 Agent 场景下,`workspace` 不只是“放文件的目录”,还会承载会话相关文件、生成结果以及本地运行状态。
1087
+ > 如果两个 agent 共用同一个 `workspace`,实际运行时很容易出现状态串扰、文件覆盖、上下文混用等问题。
1088
+
1089
+ ### 检查清单
1090
+
1091
+ - `agents.list` 中已经定义了目标 agent
1092
+ - `bindings[].agentId` 能在 `agents.list[].id` 中找到对应项
1093
+ - `bindings[].match.accountId` 与 `channels.dingtalk.accounts` 的 key 完全一致
1094
+ - 每个 `accounts.<accountId>` 都填写了正确的钉钉凭证
1095
+ - 每个 agent 都使用了独立的 `workspace`
1096
+ - 修改配置后已执行 `openclaw gateway restart`
1097
+
1098
+ 如果账号名写错,例如 `bindings.match.accountId = "bot2"`,但 `channels.dingtalk.accounts` 中实际写的是 `bot_2`,则该机器人消息不会按预期路由到目标 agent。
415
1099
 
416
1100
  ## 使用示例
417
1101
 
@@ -420,6 +1104,59 @@ openclaw gateway restart
420
1104
  1. **私聊机器人** — 找到机器人,发送消息
421
1105
  2. **群聊 @机器人** — 在群里 @机器人名称 + 消息
422
1106
 
1107
+ 如果你是通过 OpenClaw 的 outbound 能力主动发消息,也可以直接调用:
1108
+
1109
+ ```typescript
1110
+ import { dingtalkPlugin } from './src/channel';
1111
+
1112
+ const cfg = {
1113
+ channels: {
1114
+ dingtalk: {
1115
+ clientId: 'dingxxxxxx',
1116
+ clientSecret: 'your-app-secret',
1117
+ robotCode: 'dingxxxxxx',
1118
+ },
1119
+ },
1120
+ };
1121
+
1122
+ // 发送本地图片
1123
+ await dingtalkPlugin.outbound.sendMedia({
1124
+ cfg,
1125
+ to: 'cidxxxxxxxx',
1126
+ mediaPath: '/absolute/path/to/photo.png',
1127
+ accountId: 'default',
1128
+ });
1129
+
1130
+ // 发送远程图片 URL(插件会先下载到临时文件,再上传到钉钉)
1131
+ await dingtalkPlugin.outbound.sendMedia({
1132
+ cfg,
1133
+ to: 'cidxxxxxxxx',
1134
+ mediaUrl: 'https://example.com/banner.jpg',
1135
+ accountId: 'default',
1136
+ });
1137
+
1138
+ // 发送文件或其他媒体,也可以显式指定 mediaType
1139
+ await dingtalkPlugin.outbound.sendMedia({
1140
+ cfg,
1141
+ to: 'user_123456',
1142
+ mediaPath: '/absolute/path/to/manual.pdf',
1143
+ mediaType: 'file',
1144
+ accountId: 'default',
1145
+ });
1146
+ ```
1147
+
1148
+ `to` 支持两类目标:
1149
+
1150
+ - 群会话:`cid...`
1151
+ - 单聊用户:`userId`,或显式写成 `user:<userId>`
1152
+
1153
+ 如果你传入的是远程图片 URL,插件当前会按下面的方式处理:
1154
+
1155
+ 1. 下载远程图片到本地临时文件
1156
+ 2. 调用钉钉媒体上传接口获取 `media_id`
1157
+ 3. 以独立图片消息发送
1158
+ 4. 发送完成后清理临时文件
1159
+
423
1160
  ## 故障排除
424
1161
 
425
1162
  ### 收不到消息
@@ -436,8 +1173,24 @@ openclaw gateway restart
436
1173
 
437
1174
  ### 连接失败
438
1175
 
439
- 1. 检查 clientId 和 clientSecret 是否正确
440
- 2. 确认网络可以访问钉钉 API
1176
+ 初始化阶段如果只看到 HTTP `400`,它通常不等于“单纯网络不通”;更常见的是钉钉已收到请求,但拒绝了请求内容或当前应用状态不满足要求。
1177
+
1178
+ 建议先运行仓库内的最小连接检查脚本,确认 `POST /v1.0/gateway/connections/open` 是否成功:
1179
+
1180
+ - macOS / Linux: `bash scripts/dingtalk-connection-check.sh --config ~/.openclaw/openclaw.json`
1181
+ - Windows PowerShell: `pwsh -File scripts/dingtalk-connection-check.ps1 -Config ~/.openclaw/openclaw.json`
1182
+ - 旧版 Windows 可使用:`powershell.exe -File scripts/dingtalk-connection-check.ps1 -Config $env:USERPROFILE\.openclaw\openclaw.json`
1183
+
1184
+ 完整排障流程:
1185
+ - 英文版:[docs/connection-troubleshooting.md](docs/connection-troubleshooting.md)
1186
+ - 中文版:[docs/connection-troubleshooting.zh-CN.md](docs/connection-troubleshooting.zh-CN.md)
1187
+
1188
+ 如果新日志里出现 `connect.open` 或 `connect.websocket`,也可以直接按文档中的阶段说明来判断:前者优先查钉钉应用配置,后者优先查 WSS / 代理 / 企业网关。
1189
+
1190
+ 关键设置清单(钉钉后台)
1191
+ - 应用为企业内部应用/机器人,且已“发布”版本(不是草稿)
1192
+ - 版本管理 → 已发布 → 版本详情:可见范围需为“全员员工”
1193
+ - 已开启“机器人能力”,消息接收方式为“Stream 模式”
441
1194
 
442
1195
  ### 错误 payload 日志规范(`[ErrorPayload]`)
443
1196
 
@@ -547,13 +1300,17 @@ MediaFile; // 下载的媒体文件
547
1300
  sendBySession(config, sessionWebhook, text, options); // 通过会话发送
548
1301
 
549
1302
  // AI 互动卡片
550
- createAICard(config, conversationId, data, log); // 创建并投放 AI 卡片
1303
+ createAICard(config, conversationId, log); // 创建并投放 AI 卡片
551
1304
  streamAICard(card, content, finished, log); // 流式更新卡片内容
552
1305
  finishAICard(card, content, log); // 完成并关闭卡片
553
1306
 
554
1307
  // 自动模式选择
555
1308
  sendMessage(config, conversationId, text, options); // 根据配置自动选择(含卡片/文本回退)
556
1309
 
1310
+ // 主动媒体发送
1311
+ uploadMedia(config, mediaPath, mediaType, log); // 上传媒体并返回 media_id
1312
+ sendProactiveMedia(config, target, mediaPath, mediaType, options); // 发送图片/语音/视频/文件
1313
+
557
1314
  // 认证
558
1315
  getAccessToken(config, log); // 获取访问令牌
559
1316
  ```
@@ -561,10 +1318,15 @@ getAccessToken(config, log); // 获取访问令牌
561
1318
  **使用示例:**
562
1319
 
563
1320
  ```typescript
564
- import { createAICard, streamAICard, finishAICard } from './src/channel';
1321
+ import {
1322
+ createAICard,
1323
+ finishAICard,
1324
+ sendProactiveMedia,
1325
+ streamAICard,
1326
+ } from './src/channel';
565
1327
 
566
1328
  // 创建 AI 卡片
567
- const card = await createAICard(config, conversationId, messageData, log);
1329
+ const card = await createAICard(config, conversationId, log);
568
1330
 
569
1331
  // 流式更新内容
570
1332
  for (const chunk of aiResponseChunks) {
@@ -573,6 +1335,12 @@ for (const chunk of aiResponseChunks) {
573
1335
 
574
1336
  // 完成并关闭卡片
575
1337
  await finishAICard(card, finalText, log);
1338
+
1339
+ // 主动发送图片
1340
+ await sendProactiveMedia(config, 'cidxxxxxxxx', '/absolute/path/to/photo.png', 'image', {
1341
+ accountId: 'default',
1342
+ log,
1343
+ });
576
1344
  ```
577
1345
 
578
1346
  ### 架构
@@ -618,4 +1386,4 @@ pnpm test:coverage
618
1386
 
619
1387
  ## 许可
620
1388
 
621
- MIT
1389
+ [MIT](LICENSE)