@dingtalk-real-ai/dingtalk-connector 0.8.2 → 0.8.3-beta.1

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/CHANGELOG.md CHANGED
@@ -5,6 +5,37 @@ All notable changes to this project will be documented in this file.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
+ ## [0.8.3-beta.1] - 2026-03-24
9
+
10
+ ### 修复 / Fixes
11
+ - 🐛 **兼容旧版 OpenClaw Gateway(createPluginRuntimeStore 缺失)** - 修复在旧版 OpenClaw Gateway 上加载插件时报错 `TypeError: (0 , _pluginSdk.createPluginRuntimeStore) is not a function` 的问题。根因是 `src/runtime.ts` 直接从 `openclaw/plugin-sdk` 导入 `createPluginRuntimeStore`,而该函数在旧版 SDK 中并不存在。现已替换为内联实现的 `createRuntimeStore`,功能完全等价,兼容所有版本的 OpenClaw
12
+ **Compatible with older OpenClaw Gateway (missing createPluginRuntimeStore)** - Fixed `TypeError: (0 , _pluginSdk.createPluginRuntimeStore) is not a function` when loading the plugin on older OpenClaw Gateway versions. Root cause: `src/runtime.ts` imported `createPluginRuntimeStore` from `openclaw/plugin-sdk`, which doesn't exist in older SDK versions. Replaced with an inline `createRuntimeStore` implementation that is fully equivalent and compatible with all OpenClaw versions
13
+
14
+ - 🐛 **openclaw 依赖版本约束放宽** - 将 `package.json` 中的 `"openclaw": "^2026.3.0"` 改为 `"openclaw": "*"`,避免版本约束导致安装失败或与用户已安装版本冲突
15
+ **Relaxed openclaw dependency version constraint** - Changed `"openclaw": "^2026.3.0"` to `"openclaw": "*"` in `package.json` to avoid installation failures or conflicts with the user's installed version
16
+
17
+ ## [0.8.3] - 2026-03-24
18
+
19
+ ### 修复 / Fixes
20
+ - 🐛 **兼容 OpenClaw Gateway 新版本** - 修复在 OpenClaw Gateway 2026.3.22+ 版本下安装插件时报错 `ERR_PACKAGE_PATH_NOT_EXPORTED: Package subpath './plugin-sdk/compat' is not defined by "exports"` 的问题。根因是 `src/runtime.ts` 使用了已被新版 SDK 移除的 `openclaw/plugin-sdk/compat` 子路径,现已改为从 `openclaw/plugin-sdk` 主入口导入,对所有版本(2026.3.1+)均兼容
21
+ **Compatible with newer OpenClaw Gateway versions** - Fixed `ERR_PACKAGE_PATH_NOT_EXPORTED: Package subpath './plugin-sdk/compat' is not defined by "exports"` when installing under OpenClaw Gateway 2026.3.22+. Root cause: `src/runtime.ts` imported from the removed `openclaw/plugin-sdk/compat` sub-path; now imports from the `openclaw/plugin-sdk` main entry, compatible with all versions (2026.3.1+)
22
+
23
+ - ✅ **AI 卡片流式更新延迟优化** - 改动前 `onReplyStart` 串行等待 AI Card 创建(约 500ms~1s),期间 partial reply 全部丢弃,节流间隔 1000ms 也过于保守。改动后 AI Card 创建改为 fire-and-forget 与 AI 生成并行,节流间隔调整为 500ms,流式内容能更早更频繁地呈现
24
+ **AI card progressive update latency improvement** - Previously `onReplyStart` awaited AI Card creation serially (~500ms–1s), discarding all partial replies during that window, with a 1000ms throttle too conservative for short replies. AI Card creation now runs fire-and-forget in parallel with AI generation; throttle reduced to 500ms for earlier and more frequent streaming updates
25
+
26
+ - 🐛 **多 Agent 路由与 sharedMemoryAcrossConversations 冲突** - 修复配置 `sharedMemoryAcrossConversations: true` 时,多群分配不同 Agent 的 bindings 全部路由到同一 Agent 的问题。路由匹配现在使用专用的 `peerId`(真实 peer 标识,不受会话隔离配置影响),session 构建使用 `sessionPeerId`,两者职责严格分离
27
+ **Multi-Agent routing conflict with sharedMemoryAcrossConversations** - Fixed all bindings resolving to the same Agent when `sharedMemoryAcrossConversations: true`. Routing now uses dedicated `peerId` (real peer identifier, unaffected by session isolation config); session construction uses `sessionPeerId` with strict separation of responsibilities
28
+
29
+ - 🐛 **发送图片失败** - 修复发送图片时出现异常的问题 ([#316](https://github.com/DingTalk-Real-AI/dingtalk-openclaw-connector/issues/316))
30
+ **Image sending failure** - Fixed an issue where sending images would fail with an error ([#316](https://github.com/DingTalk-Real-AI/dingtalk-openclaw-connector/issues/316))
31
+
32
+ - 🐛 **发送人昵称与群名称未正确传递给 AI** - 修复会话上下文中 `SenderName` 字段错误传入用户 ID(而非昵称)、`GroupSubject` 字段错误传入群 ID(而非群名称)的问题,AI 现在能正确获取发送人的钉钉昵称和所在群的名称
33
+ **Sender nickname and group name not passed to AI** - Fixed `SenderName` being set to user ID instead of display name, and `GroupSubject` being set to group ID instead of group title; AI now correctly receives the sender's nickname and group name
34
+
35
+ ### 改进 / Improvements
36
+ - ✨ **消息队列繁忙时的即时排队反馈** - 当用户快速连续发送消息、上一条仍在处理中时,新消息现在会立即弹出一条 AI Card 气泡显示排队提示文案,同时贴上"思考中"表情。等轮到该消息处理时,气泡**原地更新**为最终回复内容,不会额外多发一条消息
37
+ **Instant queue acknowledgement when busy** - When a user sends messages in quick succession while the previous one is still processing, the new message now immediately shows an AI Card bubble with a queuing acknowledgement and a "thinking" emoji. When it's the message's turn, the same bubble is **updated in place** with the final reply — no extra message is sent
38
+
8
39
  ## [0.8.2] - 2026-03-22
9
40
 
10
41
  ### 修复 / Fixes
@@ -0,0 +1,335 @@
1
+ # Agent 路由与 SessionKey 规范
2
+
3
+ 本文档是 DingTalk OpenClaw Connector 中 **Agent 路由(bindings)** 和 **SessionKey 构建** 的完整开发规范,供新功能开发和代码审查参考。
4
+
5
+ 涉及文件:
6
+ - `src/utils/session.ts` — `buildSessionContext()` 函数,构建会话上下文
7
+ - `src/core/message-handler.ts` — bindings 路由匹配、sessionKey 构建、消息队列管理
8
+
9
+ ---
10
+
11
+ ## 一、SessionContext 字段总览
12
+
13
+ `buildSessionContext()` 返回的 `SessionContext` 包含以下字段:
14
+
15
+ | 字段 | 类型 | 说明 |
16
+ |------|------|------|
17
+ | `channel` | `'dingtalk-connector'` | 固定值,标识频道来源 |
18
+ | `accountId` | `string` | 当前钉钉账号 ID |
19
+ | `chatType` | `'direct' \| 'group'` | 会话类型,单聊或群聊 |
20
+ | `peerId` | `string` | **路由匹配专用**,真实 peer 标识(群聊为 `conversationId`,单聊为 `senderId`),**不受任何会话隔离配置影响** |
21
+ | `sessionPeerId` | `string` | **session/memory 隔离键**,用于构建 `sessionKey` 和消息队列 key,**受会话隔离配置影响,可能与 `peerId` 不同** |
22
+ | `conversationId` | `string?` | 群聊会话 ID,单聊时为 `undefined` |
23
+ | `senderName` | `string?` | 发送者昵称 |
24
+ | `groupSubject` | `string?` | 群名称,单聊时为 `undefined` |
25
+
26
+ 其中 `channel`、`accountId`、`chatType`、`conversationId`、`senderName`、`groupSubject` 都是直接从消息原始字段透传的,没有歧义。
27
+
28
+ **需要特别注意的是 `peerId` 和 `sessionPeerId` 这两个字段**,它们都是 peer 标识,但职责完全不同,**不能混用**:
29
+
30
+ - **路由匹配(去哪个 Agent)** → 使用 `peerId`
31
+ - **session 隔离(共享多大范围的上下文)** → 使用 `sessionPeerId`
32
+
33
+ ---
34
+
35
+ ## 二、buildSessionContext() 完整逻辑
36
+
37
+ `buildSessionContext()` 在 `src/utils/session.ts` 中定义,每条消息到达时调用一次,根据消息原始字段和账号配置,决定 `peerId` 和 `sessionPeerId` 的值。
38
+
39
+ ### 2.1 输入参数
40
+
41
+ | 参数 | 来源 | 说明 |
42
+ |------|------|------|
43
+ | `accountId` | 账号配置 key | 当前钉钉账号标识 |
44
+ | `senderId` | 消息原始字段 `data.senderStaffId` | 发送者 userId |
45
+ | `senderName` | 消息原始字段 `data.senderNick` | 发送者昵称 |
46
+ | `conversationType` | 消息原始字段 `data.conversationType` | `"1"` = 单聊,其他 = 群聊 |
47
+ | `conversationId` | 消息原始字段 `data.conversationId` | 群聊的会话 ID |
48
+ | `groupSubject` | 消息原始字段 `data.conversationTitle` | 群名称 |
49
+ | `separateSessionByConversation` | `config.separateSessionByConversation` | 是否按会话区分 session |
50
+ | `groupSessionScope` | `config.groupSessionScope` | 群聊 session 粒度 |
51
+ | `sharedMemoryAcrossConversations` | `config.sharedMemoryAcrossConversations` | 是否跨会话共享记忆 |
52
+
53
+ ### 2.2 peerId 的计算规则(固定,不受配置影响)
54
+
55
+ ```
56
+ peerId = isDirect ? senderId : (conversationId || senderId)
57
+ ```
58
+
59
+ - 单聊:`peerId = senderId`
60
+ - 群聊:`peerId = conversationId`(无 conversationId 时降级为 senderId)
61
+
62
+ ### 2.3 sessionPeerId 的决策树
63
+
64
+ 配置优先级从高到低,**命中第一条即返回**:
65
+
66
+ ```
67
+ sharedMemoryAcrossConversations === true
68
+ → sessionPeerId = accountId
69
+ (所有单聊+群聊共享同一记忆,以 accountId 为 session 键)
70
+
71
+ separateSessionByConversation === false
72
+ → sessionPeerId = senderId
73
+ (按用户维度隔离,不区分群/单聊,同一用户在不同群共享 session)
74
+
75
+ isDirect(单聊)
76
+ → sessionPeerId = senderId
77
+ (每个用户独立 session)
78
+
79
+ groupSessionScope === 'group_sender'(群聊)
80
+ → sessionPeerId = `${conversationId}:${senderId}`
81
+ (群内每个用户独立 session)
82
+
83
+ 默认(群聊)
84
+ → sessionPeerId = conversationId || senderId
85
+ (整个群共享一个 session)
86
+ ```
87
+
88
+ ### 2.4 各配置组合下的完整取值表
89
+
90
+ | 配置 | 场景 | `peerId` | `sessionPeerId` |
91
+ |------|------|----------|-----------------|
92
+ | `sharedMemoryAcrossConversations: true` | 单聊 | `senderId` | `accountId` |
93
+ | `sharedMemoryAcrossConversations: true` | 群聊 | `conversationId` | `accountId` |
94
+ | `separateSessionByConversation: false` | 单聊 | `senderId` | `senderId` |
95
+ | `separateSessionByConversation: false` | 群聊 | `conversationId` | `senderId` |
96
+ | `groupSessionScope: "group_sender"` | 群聊 | `conversationId` | `${conversationId}:${senderId}` |
97
+ | 默认 | 单聊 | `senderId` | `senderId` |
98
+ | 默认 | 群聊 | `conversationId` | `conversationId` |
99
+
100
+ > **注意**:`sharedMemoryAcrossConversations: true` 优先级最高,会覆盖其他所有配置。
101
+
102
+ ---
103
+
104
+ ## 三、Agent 路由规则(Bindings)
105
+
106
+ ### 3.1 路由流程
107
+
108
+ 每条钉钉消息到达后,connector 按以下顺序确定目标 Agent:
109
+
110
+ ```
111
+ 消息到达
112
+
113
+ buildSessionContext() ← 构建会话上下文(含 peerId / sessionPeerId)
114
+
115
+ 遍历 cfg.bindings[] ← 按顺序逐条匹配,使用 peerId 进行匹配
116
+ ↓ 命中第一条
117
+ matchedAgentId ← 使用该 agentId
118
+ ↓ 全部未命中
119
+ cfg.defaultAgent || 'main' ← 回退到默认 Agent
120
+ ```
121
+
122
+ ### 3.2 Binding 匹配字段
123
+
124
+ 每条 binding 的 `match` 字段支持以下维度,**所有指定的维度必须同时满足**(AND 关系):
125
+
126
+ | 字段 | 类型 | 说明 |
127
+ |------|------|------|
128
+ | `match.channel` | `string?` | 频道名,固定为 `"dingtalk-connector"`,省略则匹配所有频道 |
129
+ | `match.accountId` | `string?` | 钉钉账号 ID(对应 `accounts` 配置中的 key),省略则匹配所有账号 |
130
+ | `match.peer.kind` | `"direct" \| "group"?` | 会话类型,省略则匹配单聊和群聊 |
131
+ | `match.peer.id` | `string?` | Peer 标识,群聊为 `conversationId`,单聊为 `senderId`,`"*"` 表示通配所有 |
132
+
133
+ ### 3.3 匹配逻辑(源码)
134
+
135
+ ```typescript
136
+ // src/core/message-handler.ts
137
+ for (const binding of cfg.bindings) {
138
+ const match = binding.match;
139
+ if (match.channel && match.channel !== 'dingtalk-connector') continue;
140
+ if (match.accountId && match.accountId !== accountId) continue;
141
+ if (match.peer) {
142
+ if (match.peer.kind && match.peer.kind !== sessionContext.chatType) continue;
143
+ // 使用 peerId(真实 peer 标识),不受会话隔离配置影响
144
+ if (match.peer.id && match.peer.id !== '*' && match.peer.id !== sessionContext.peerId) continue;
145
+ }
146
+ matchedAgentId = binding.agentId;
147
+ break;
148
+ }
149
+ if (!matchedAgentId) {
150
+ matchedAgentId = cfg.defaultAgent || 'main';
151
+ }
152
+ ```
153
+
154
+ ### 3.4 优先级规则
155
+
156
+ - **顺序优先**:bindings 数组按顺序遍历,**第一条命中的规则生效**,后续规则不再检查
157
+ - **精确规则放前面**:将指定了 `peer.id` 的精确规则放在通配规则(`peer.id: "*"`)之前,避免通配规则提前拦截
158
+
159
+ ### 3.5 典型配置示例
160
+
161
+ **多群分配不同 Agent**:
162
+
163
+ ```json
164
+ {
165
+ "bindings": [
166
+ {
167
+ "agentId": "main",
168
+ "match": {
169
+ "channel": "dingtalk-connector",
170
+ "accountId": "groupbot",
171
+ "peer": { "kind": "group", "id": "cid3RKewszsVbXZYCYmbybVNw==" }
172
+ }
173
+ },
174
+ {
175
+ "agentId": "organizer",
176
+ "match": {
177
+ "channel": "dingtalk-connector",
178
+ "accountId": "groupbot",
179
+ "peer": { "kind": "group", "id": "cidqO7Ne7e+myoRu67AguW+HQ==" }
180
+ }
181
+ },
182
+ {
183
+ "agentId": "atlas",
184
+ "match": {
185
+ "channel": "dingtalk-connector",
186
+ "accountId": "groupbot",
187
+ "peer": { "kind": "group", "id": "cidekzhmRmaKaJ6vnQezRFZWA==" }
188
+ }
189
+ }
190
+ ]
191
+ }
192
+ ```
193
+
194
+ **单聊走一个 Agent,群聊走另一个**:
195
+
196
+ ```json
197
+ {
198
+ "bindings": [
199
+ {
200
+ "agentId": "personal-assistant",
201
+ "match": { "channel": "dingtalk-connector", "peer": { "kind": "direct" } }
202
+ },
203
+ {
204
+ "agentId": "group-bot",
205
+ "match": { "channel": "dingtalk-connector", "peer": { "kind": "group" } }
206
+ }
207
+ ]
208
+ }
209
+ ```
210
+
211
+ **精确路由 + 通配兜底**:
212
+
213
+ ```json
214
+ {
215
+ "bindings": [
216
+ {
217
+ "agentId": "vip-agent",
218
+ "match": { "channel": "dingtalk-connector", "peer": { "kind": "group", "id": "cidVIP..." } }
219
+ },
220
+ {
221
+ "agentId": "main",
222
+ "match": { "channel": "dingtalk-connector", "peer": { "kind": "group", "id": "*" } }
223
+ }
224
+ ]
225
+ }
226
+ ```
227
+
228
+ ---
229
+
230
+ ## 四、SessionKey 构建规范
231
+
232
+ ### 4.1 SessionKey 格式
233
+
234
+ SessionKey 由 SDK 的 `core.channel.routing.buildAgentSessionKey()` 生成,格式为:
235
+
236
+ ```
237
+ agent:{agentId}:{channel}:{peerKind}:{sessionPeerId}
238
+ ```
239
+
240
+ 示例:
241
+ - `agent:main:dingtalk-connector:direct:manager7195` — 默认单聊,按用户隔离
242
+ - `agent:main:dingtalk-connector:group:cid3RKewszsVbXZYCYmbybVNw==` — 默认群聊,按群隔离
243
+ - `agent:main:dingtalk-connector:direct:bot1` — `sharedMemoryAcrossConversations=true`,所有会话共享(sessionPeerId = accountId = "bot1")
244
+ - `agent:main:dingtalk-connector:group:bot1` — 同上,群聊也共享
245
+
246
+ ### 4.2 SessionKey 构建代码规范
247
+
248
+ ```typescript
249
+ // src/core/message-handler.ts
250
+ const dmScope = cfg.session?.dmScope || 'per-channel-peer';
251
+ const sessionKey = core.channel.routing.buildAgentSessionKey({
252
+ agentId: matchedAgentId,
253
+ channel: 'dingtalk-connector', // ✅ 固定值,不能写 'dingtalk'
254
+ accountId: accountId,
255
+ peer: {
256
+ kind: sessionContext.chatType, // 'direct' | 'group'
257
+ id: sessionContext.sessionPeerId, // ✅ 使用 sessionPeerId,不是 peerId
258
+ },
259
+ dmScope: dmScope, // ✅ 必须传递,影响 sessionKey 格式
260
+ });
261
+ ```
262
+
263
+ **禁止**:
264
+ - 用 `sessionContext.peerId` 构建 sessionKey(`peerId` 是路由匹配专用)
265
+ - 手动拼接 sessionKey 字符串(必须通过 SDK 的 `buildAgentSessionKey` 方法)
266
+ - `channel` 写成 `'dingtalk'`(必须是 `'dingtalk-connector'`)
267
+
268
+ ### 4.3 消息队列 Key 规范
269
+
270
+ 消息队列(`sessionQueues`)用于确保同一会话+Agent 的消息串行处理,避免并发冲突。队列 key 格式:
271
+
272
+ ```
273
+ {sessionPeerId}:{agentId}
274
+ ```
275
+
276
+ **必须与 sessionKey 使用相同的 `sessionPeerId`**,确保隔离策略一致:
277
+
278
+ ```typescript
279
+ // src/core/message-handler.ts
280
+ const baseSessionId = sessionContext.sessionPeerId;
281
+ const queueKey = `${baseSessionId}:${matchedAgentId}`;
282
+ ```
283
+
284
+ 这样不同 Agent 可以并发处理,同一 Agent 的同一会话串行处理。
285
+
286
+ ### 4.4 InboundContext 中的 From / To 字段
287
+
288
+ 构建 `ctxPayload` 时,`From` 和 `To` 字段规则如下:
289
+
290
+ | 字段 | 单聊 | 群聊 |
291
+ |------|------|------|
292
+ | `From` | `senderId` | `senderId` |
293
+ | `To` | `senderId` | `conversationId` |
294
+ | `OriginatingTo` | `senderId` | `conversationId` |
295
+
296
+ ```typescript
297
+ const toField = isDirect ? senderId : data.conversationId;
298
+ // From 始终是 senderId,To 单聊用 senderId,群聊用 conversationId
299
+ ```
300
+
301
+ ---
302
+
303
+ ## 五、配置参数速查
304
+
305
+ ### 5.1 会话隔离相关配置
306
+
307
+ | 配置字段 | 类型 | 默认值 | 说明 |
308
+ |---------|------|--------|------|
309
+ | `sharedMemoryAcrossConversations` | `boolean` | `false` | 所有会话(单聊+群聊)共享同一记忆,优先级最高 |
310
+ | `separateSessionByConversation` | `boolean` | `true` | 是否按会话(群/单聊)区分 session;`false` 时按用户维度 |
311
+ | `groupSessionScope` | `"group" \| "group_sender"` | `"group"` | 群聊 session 粒度;`group_sender` 时群内每人独立 |
312
+ | `session.dmScope` | `string` | `"per-channel-peer"` | 传递给 SDK 的 dmScope 参数,影响 sessionKey 格式 |
313
+
314
+ ### 5.2 路由相关配置
315
+
316
+ | 配置字段 | 类型 | 说明 |
317
+ |---------|------|------|
318
+ | `bindings` | `Binding[]` | Agent 路由规则列表,按顺序匹配 |
319
+ | `defaultAgent` | `string` | 未命中任何 binding 时的默认 Agent,默认为 `"main"` |
320
+
321
+ ---
322
+
323
+ ## 六、开发规范总结
324
+
325
+ 1. **路由匹配用 `peerId`**:`match.peer.id` 与 `sessionContext.peerId` 比较,`peerId` 始终是真实的 `conversationId`(群)或 `senderId`(单聊),不受任何会话隔离配置影响。
326
+
327
+ 2. **session 构建用 `sessionPeerId`**:`sessionKey` 和 `queueKey` 的构建均使用 `sessionContext.sessionPeerId`,受会话隔离配置影响,决定记忆/上下文的共享范围。
328
+
329
+ 3. **两者职责严格分离**:路由(去哪个 Agent)和记忆隔离(共享多大范围的上下文)是两个独立维度,代码中不能用同一个字段同时承担两种职责。
330
+
331
+ 4. **sessionKey 必须通过 SDK 构建**:使用 `core.channel.routing.buildAgentSessionKey()`,不要手动拼接字符串,`channel` 固定为 `'dingtalk-connector'`,`dmScope` 必须传递。
332
+
333
+ 5. **bindings 顺序即优先级**:精确规则(指定 `peer.id`)必须放在通配规则(`peer.id: "*"`)之前。
334
+
335
+ 6. **`sharedMemoryAcrossConversations` 优先级最高**:该配置开启后,`sessionPeerId` 被强制设为 `accountId`,覆盖其他所有会话隔离配置,但 `peerId` 不受影响,路由仍然正常工作。
@@ -1,16 +1,21 @@
1
1
  {
2
2
  "id": "dingtalk-connector",
3
3
  "name": "DingTalk Channel",
4
- "version": "0.8.2",
4
+ "version": "0.8.3-beta.1",
5
5
  "description": "DingTalk (钉钉) messaging channel via Stream mode with AI Card streaming",
6
6
  "author": "DingTalk Real Team",
7
7
  "main": "index.ts",
8
- "channels": ["dingtalk-connector"],
8
+ "channels": [
9
+ "dingtalk-connector"
10
+ ],
9
11
  "configSchema": {
10
12
  "type": "object",
11
13
  "additionalProperties": false,
12
14
  "properties": {
13
- "enabled": { "type": "boolean", "default": true }
15
+ "enabled": {
16
+ "type": "boolean",
17
+ "default": true
18
+ }
14
19
  }
15
20
  }
16
21
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dingtalk-real-ai/dingtalk-connector",
3
- "version": "0.8.2",
3
+ "version": "0.8.3-beta.1",
4
4
  "description": "DingTalk (钉钉) channel connector — Stream mode with AI Card streaming",
5
5
  "main": "index.ts",
6
6
  "type": "module",
@@ -51,7 +51,7 @@
51
51
  "fluent-ffmpeg": "^2.1.3",
52
52
  "form-data": "^4.0.0",
53
53
  "mammoth": "^1.8.0",
54
- "openclaw": "^2026.3.0",
54
+ "openclaw": "^2026.3.23-2",
55
55
  "pako": "^2.1.0",
56
56
  "pdf-parse": "^1.1.1",
57
57
  "zod": "^3.22.0"
@@ -15,8 +15,7 @@
15
15
  import type { ClawdbotConfig, RuntimeEnv } from "openclaw/plugin-sdk";
16
16
  import type { ResolvedDingtalkAccount } from "../types/index.ts";
17
17
  import {
18
- isMessageProcessed,
19
- markMessageProcessed,
18
+ checkAndMarkDingtalkMessage,
20
19
  } from "../utils/utils-legacy.ts";
21
20
  import { createLoggerFromConfig } from "../utils/logger.ts";
22
21
 
@@ -489,19 +488,16 @@ export async function monitorSingleAccount(
489
488
  logger.warn(`⚠️ 警告:消息没有 messageId`);
490
489
  }
491
490
 
492
- // 消息去重
493
- if (messageId && isMessageProcessed(messageId)) {
494
- processedCount++; // 修复:重复消息也要计入 processedCount
495
- logger.warn(`⚠️ 检测到重复消息,跳过处理:messageId=${messageId} (${processedCount}/${receivedCount})`);
491
+ // 协议层去重(headers.messageId):拦截同一次投递的重复回调
492
+ // 注意:业务层去重(data.msgId)在 JSON 解析后执行,两层合并在 checkAndMarkDingtalkMessage 中
493
+ // 此处仅做协议层的快速预检,避免不必要的 JSON 解析
494
+ if (messageId && checkAndMarkDingtalkMessage(messageId, undefined)) {
495
+ processedCount++;
496
+ logger.warn(`⚠️ 检测到重复消息(协议层),跳过处理:messageId=${messageId} (${processedCount}/${receivedCount})`);
496
497
  logger.info(`========== 消息处理结束(重复) ==========\n`);
497
498
  return;
498
499
  }
499
500
 
500
- if (messageId) {
501
- markMessageProcessed(messageId);
502
- logger.info(`标记消息为已处理:messageId=${messageId}`);
503
- }
504
-
505
501
  // 异步处理消息
506
502
  // ✅ 标记消息处理开始,防止长时间处理触发心跳超时
507
503
  markMessageProcessingStart();
@@ -544,6 +540,18 @@ export async function monitorSingleAccount(
544
540
  `RobotCode: ${data.robotCode || account.config?.clientId || "N/A"}`,
545
541
  );
546
542
 
543
+ // ===== 业务层去重:补充 data.msgId,防止钉钉服务端重发穿透 =====
544
+ // 协议层已标记了 headers.messageId,此处再补充标记 data.msgId。
545
+ // 钉钉重发时 headers.messageId 是新值,但 data.msgId 不变,
546
+ // checkAndMarkDingtalkMessage 会命中 data.msgId 并返回 true 拦截重发。
547
+ const businessMsgId = data.msgId;
548
+ if (checkAndMarkDingtalkMessage(undefined, businessMsgId)) {
549
+ processedCount++;
550
+ logger.warn(`⚠️ 检测到钉钉服务端重发消息,跳过处理:msgId=${businessMsgId} (${processedCount}/${receivedCount})`);
551
+ logger.info(`========== 消息处理结束(业务层去重) ==========\n`);
552
+ return;
553
+ }
554
+
547
555
  // 记录消息内容(简化版,避免过长)
548
556
  let contentPreview = "N/A";
549
557
  if (data.text?.content) {
@@ -18,8 +18,6 @@
18
18
  import type { ClawdbotConfig, RuntimeEnv, HistoryEntry } from "openclaw/plugin-sdk";
19
19
  import type { ResolvedDingtalkAccount, DingtalkConfig } from "../types/index.ts";
20
20
  import {
21
- isMessageProcessed,
22
- markMessageProcessed,
23
21
  buildSessionContext,
24
22
  getAccessToken,
25
23
  getOapiAccessToken,
@@ -41,6 +39,8 @@ import {
41
39
  AUDIO_MARKER_PATTERN
42
40
  } from "../services/media/index.ts";
43
41
  import { sendProactive, type AICardTarget } from "../services/messaging/index.ts";
42
+ import { createAICardForTarget, streamAICard, type AICardInstance } from "../services/messaging/card.ts";
43
+ import { QUEUE_BUSY_ACK_PHRASES } from "../utils/constants.ts";
44
44
  import { createDingtalkReplyDispatcher, normalizeSlashCommand } from "../reply-dispatcher.ts";
45
45
  import { getDingtalkRuntime } from "../runtime.ts";
46
46
  import { dingtalkHttp } from '../utils/http-client.ts';
@@ -417,8 +417,7 @@ export async function downloadFileToLocal(
417
417
  log?.info?.(`文件下载成功: ${fileName}, size=${buffer.length} bytes, path=${localPath}`);
418
418
  return localPath;
419
419
  } catch (err: any) {
420
- console.error(`[ERROR] downloadFileToLocal 异常: ${err.message}`);
421
- console.error(`[ERROR] 异常堆栈:\n${err.stack}`);
420
+ log?.error?.(`downloadFileToLocal 异常: ${err.message}\n${err.stack}`);
422
421
  return null;
423
422
  }
424
423
  }
@@ -532,15 +531,18 @@ interface HandleMessageParams {
532
531
  runtime?: RuntimeEnv;
533
532
  log?: any;
534
533
  cfg: ClawdbotConfig;
534
+ /** 队列繁忙时预先创建的 AI Card,处理时直接复用而非新建,实现"占位→更新"效果 */
535
+ preCreatedCard?: AICardInstance;
536
+ /** 队列繁忙时已在入队阶段提前贴上了思考中表情,内部处理时跳过重复贴表情 */
537
+ emotionAlreadyAdded?: boolean;
535
538
  }
536
539
 
537
540
  /**
538
541
  * 内部消息处理函数(实际执行消息处理逻辑)
539
542
  */
540
543
  export async function handleDingTalkMessageInternal(params: HandleMessageParams): Promise<void> {
541
- const { accountId, config, data, sessionWebhook, runtime, log: inputLog, cfg } = params;
544
+ const { accountId, config, data, sessionWebhook, runtime, cfg } = params;
542
545
 
543
- // 如果传入的 log 为空,则使用基于 config 的 logger
544
546
  const log = createLoggerFromConfig(config, `DingTalk:${accountId}`);
545
547
 
546
548
  const content = extractMessageContent(data);
@@ -695,8 +697,10 @@ export async function handleDingTalkMessageInternal(params: HandleMessageParams)
695
697
  sharedMemoryAcrossConversations: config.sharedMemoryAcrossConversations,
696
698
  });
697
699
 
698
- // ===== 解析 agentId 和工作空间路径(在 sessionContext 之后,确保 peerId 与会话隔离策略一致)=====
699
- // 使用 sessionContext.peerId 进行匹配,与后续 sessionKey 构建保持一致,避免两次匹配结果不一致
700
+ // ===== 解析 agentId 和工作空间路径(在 sessionContext 之后,确保 chatType 与会话隔离策略一致)=====
701
+ // 使用 sessionContext.peerId 进行匹配(真实的 conversationId/senderId,与 match.peer.id 语义一致)。
702
+ // 注意:不能使用 sessionContext.sessionPeerId,它受 sharedMemoryAcrossConversations 等配置影响,
703
+ // 可能被设为 accountId,导致不同群/用户的消息匹配到同一个 binding,路由错误。
700
704
  let matchedAgentId: string | null = null;
701
705
  if (cfg.bindings && cfg.bindings.length > 0) {
702
706
  for (const binding of cfg.bindings) {
@@ -892,9 +896,12 @@ export async function handleDingTalkMessageInternal(params: HandleMessageParams)
892
896
  if (!userContent && imageLocalPaths.length === 0) return;
893
897
 
894
898
  // ===== 贴处理中表情 =====
895
- addEmotionReply(config, data, log).catch(err => {
896
- log?.warn?.(`贴表情失败: ${err.message}`);
897
- });
899
+ // 若队列繁忙时已在入队阶段提前贴过表情,此处跳过,避免重复贴
900
+ if (!params.emotionAlreadyAdded) {
901
+ addEmotionReply(config, data, log).catch(err => {
902
+ log?.warn?.(`贴表情失败: ${err.message}`);
903
+ });
904
+ }
898
905
 
899
906
  // ===== 异步模式:立即回执 + 后台执行 + 主动推送结果 =====
900
907
  const asyncMode = config.asyncMode === true;
@@ -946,18 +953,18 @@ export async function handleDingTalkMessageInternal(params: HandleMessageParams)
946
953
  const matchedBy = matchedAgentId !== (cfg.defaultAgent || 'main') ? 'binding' : 'default';
947
954
 
948
955
  // ✅ 使用 SDK 标准方法构建 sessionKey,符合 OpenClaw 规范
949
- // 格式:agent:{agentId}:{channel}:{peerKind}:{peerId}
950
- // ✅ 修复:使用 sessionContext.peerId,确保会话隔离配置生效
956
+ // 格式:agent:{agentId}:{channel}:{peerKind}:{sessionPeerId}
957
+ // ✅ 使用 sessionContext.sessionPeerId 构建 sessionKey,确保会话隔离配置生效
951
958
  // ✅ 关键修复:传递 dmScope 参数,让 SDK 使用配置文件中的 session.dmScope 设置
952
959
  const dmScope = cfg.session?.dmScope || 'per-channel-peer';
953
- log?.info?.(`🔍 构建 sessionKey 前的参数: agentId=${matchedAgentId}, channel=dingtalk-connector, accountId=${accountId}, chatType=${sessionContext.chatType}, peerId=${sessionContext.peerId}, dmScope=${dmScope}`);
960
+ log?.info?.(`🔍 构建 sessionKey 前的参数: agentId=${matchedAgentId}, channel=dingtalk-connector, accountId=${accountId}, chatType=${sessionContext.chatType}, sessionPeerId=${sessionContext.sessionPeerId}, dmScope=${dmScope}`);
954
961
  const sessionKey = core.channel.routing.buildAgentSessionKey({
955
962
  agentId: matchedAgentId,
956
963
  channel: 'dingtalk-connector', // ✅ 使用 'dingtalk-connector' 而不是 'dingtalk'
957
964
  accountId: accountId,
958
965
  peer: {
959
- kind: sessionContext.chatType, // ✅ 使用 sessionContext.chatType
960
- id: sessionContext.peerId, // ✅ 使用 sessionContext.peerId(包含会话隔离逻辑)
966
+ kind: sessionContext.chatType, // ✅ 使用 sessionContext.chatType
967
+ id: sessionContext.sessionPeerId, // ✅ 使用 sessionContext.sessionPeerId(包含会话隔离逻辑)
961
968
  },
962
969
  dmScope: dmScope, // ✅ 传递 dmScope 参数,确保生成完整格式的 sessionKey
963
970
  });
@@ -980,15 +987,15 @@ export async function handleDingTalkMessageInternal(params: HandleMessageParams)
980
987
  SessionKey: sessionKey, // ✅ 使用手动匹配的 sessionKey
981
988
  AccountId: accountId,
982
989
  ChatType: sessionContext.chatType,
983
- GroupSubject: isDirect ? undefined : data.conversationId,
984
- SenderName: senderId,
990
+ GroupSubject: isDirect ? undefined : data.conversationTitle,
991
+ SenderName: senderName,
985
992
  SenderId: senderId,
986
- Provider: "dingtalk" as const,
987
- Surface: "dingtalk" as const,
993
+ Provider: "dingtalk-connector" as const,
994
+ Surface: "dingtalk-connector" as const,
988
995
  MessageSid: data.msgId,
989
996
  Timestamp: Date.now(),
990
997
  CommandAuthorized: true,
991
- OriginatingChannel: "dingtalk" as const,
998
+ OriginatingChannel: "dingtalk-connector" as const,
992
999
  OriginatingTo: toField, // ✅ 修复:应该使用 toField,而不是 accountId
993
1000
  });
994
1001
 
@@ -1004,6 +1011,7 @@ export async function handleDingTalkMessageInternal(params: HandleMessageParams)
1004
1011
  messageCreateTimeMs: Date.now(),
1005
1012
  sessionWebhook: data.sessionWebhook,
1006
1013
  asyncMode,
1014
+ preCreatedCard: params.preCreatedCard,
1007
1015
  });
1008
1016
 
1009
1017
  // 使用 SDK 的 dispatchReplyFromConfig
@@ -1180,15 +1188,17 @@ export async function handleDingTalkMessage(params: HandleMessageParams): Promis
1180
1188
  sharedMemoryAcrossConversations: config.sharedMemoryAcrossConversations,
1181
1189
  });
1182
1190
 
1183
- const baseSessionId = queueSessionContext.peerId;
1191
+ const baseSessionId = queueSessionContext.sessionPeerId;
1184
1192
 
1185
1193
  if (!baseSessionId) {
1186
1194
  log?.warn?.('无法构建会话标识,跳过队列管理');
1187
1195
  return handleDingTalkMessageInternal(params);
1188
1196
  }
1189
1197
 
1190
- // 解析 agentId:使用 sessionContext.peerId sessionContext.chatType 进行匹配
1191
- // 与 handleDingTalkMessageInternal 中的匹配逻辑保持一致
1198
+ // 解析 agentId:使用 queueSessionContext.peerId(真实 peer 标识)进行匹配
1199
+ // 与 handleDingTalkMessageInternal 中的匹配逻辑保持一致。
1200
+ // 必须使用 peerId 而非 sessionPeerId,原因:sharedMemoryAcrossConversations=true 时
1201
+ // sessionPeerId 被设为 accountId,导致不同群的消息匹配到同一个 binding。
1192
1202
  let matchedAgentId: string | null = null;
1193
1203
  if (cfg.bindings && cfg.bindings.length > 0) {
1194
1204
  for (const binding of cfg.bindings) {
@@ -1218,14 +1228,46 @@ export async function handleDingTalkMessage(params: HandleMessageParams): Promis
1218
1228
  // 更新会话活跃时间
1219
1229
  sessionLastActivity.set(queueKey, Date.now());
1220
1230
 
1231
+ // 检测队列是否繁忙(入队前检查,此时 previousTask 尚未被当前消息覆盖)
1232
+ const isQueueBusy = sessionQueues.has(queueKey);
1233
+
1221
1234
  // 获取该会话+agent的上一个处理任务
1222
1235
  const previousTask = sessionQueues.get(queueKey) || Promise.resolve();
1223
1236
 
1237
+ // 队列繁忙时:立即创建一个 AI Card 显示排队 ACK 文案,并将 Card 实例传入处理任务
1238
+ // 处理完成后 reply-dispatcher 会复用此 Card 更新为最终结果,用户看到的是同一条消息的内容变化
1239
+ let preCreatedCard: AICardInstance | undefined;
1240
+ if (isQueueBusy) {
1241
+ const ackPhrases = QUEUE_BUSY_ACK_PHRASES;
1242
+ const ackText = ackPhrases[Math.floor(Math.random() * ackPhrases.length)];
1243
+ const cardTarget: AICardTarget = isDirect
1244
+ ? { type: 'user', userId: senderId }
1245
+ : { type: 'group', openConversationId: data.conversationId };
1246
+
1247
+ try {
1248
+ const card = await createAICardForTarget(config, cardTarget, log);
1249
+ if (card) {
1250
+ // 用 streamAICard 把 ACK 文案写入 Card(INPUTING 状态,表示正在处理中)
1251
+ await streamAICard(card, ackText, false, config, log);
1252
+ preCreatedCard = card;
1253
+ log?.info?.(`[队列] 队列繁忙,已创建排队 ACK Card,cardInstanceId=${card.cardInstanceId}`);
1254
+ } else {
1255
+ log?.warn?.(`[队列] 创建排队 ACK Card 失败(返回 null),跳过 ACK`);
1256
+ }
1257
+ // 在发送 ACK 的同时立即贴上思考中表情,让用户知道消息已被接收
1258
+ addEmotionReply(config, data, log).catch(err => {
1259
+ log?.warn?.(`[队列] 贴排队表情失败: ${err.message}`);
1260
+ });
1261
+ } catch (ackErr: any) {
1262
+ log?.warn?.(`[队列] 创建排队 ACK Card 异常: ${ackErr?.message || ackErr}`);
1263
+ }
1264
+ }
1265
+
1224
1266
  // 创建当前消息的处理任务
1225
1267
  const currentTask = previousTask
1226
1268
  .then(async () => {
1227
1269
  log?.info?.(`[队列] 开始处理消息,queueKey=${queueKey}`);
1228
- await handleDingTalkMessageInternal(params);
1270
+ await handleDingTalkMessageInternal({ ...params, preCreatedCard, emotionAlreadyAdded: isQueueBusy });
1229
1271
  log?.info?.(`[队列] 消息处理完成,queueKey=${queueKey}`);
1230
1272
  })
1231
1273
  .catch((err: any) => {
@@ -1243,14 +1285,12 @@ export async function handleDingTalkMessage(params: HandleMessageParams): Promis
1243
1285
  // 更新队列
1244
1286
  sessionQueues.set(queueKey, currentTask);
1245
1287
 
1246
- // 等待当前任务完成
1247
- await currentTask;
1248
- console.log(`[DEBUG] 任务执行完成`);
1288
+ // 不等待任务完成,立即返回,不阻塞 WebSocket 消息接收
1289
+ // 消息处理在后台异步执行,队列保证同一会话+agent的消息串行处理
1249
1290
  } catch (err: any) {
1250
- console.error(`[DEBUG] 队列管理异常: ${err.message}`);
1251
- console.error(`[DEBUG] 队列管理异常堆栈: ${err.stack}`);
1252
- // 如果队列管理失败,直接调用内部处理函数
1253
- return handleDingTalkMessageInternal(params);
1291
+ log?.error?.(`[队列] 队列管理异常,直接处理: ${err.message}`);
1292
+ // 如果队列管理失败,直接调用内部处理函数(不阻塞)
1293
+ void handleDingTalkMessageInternal(params);
1254
1294
  }
1255
1295
  }
1256
1296
 
@@ -1,13 +1,21 @@
1
- import type {
2
- ClawdbotConfig,
3
- RuntimeEnv,
4
- ReplyPayload,
5
- } from "openclaw/plugin-sdk";
6
- import {
1
+ // openclaw 2026.3.23+ 将 plugin-sdk 拆分为子模块,旧版本仍从主入口导出。
2
+ // 使用动态 import 兼容新旧两种版本:优先尝试新版子路径,失败则回退到旧版主入口。
3
+ import type { ClawdbotConfig, RuntimeEnv } from "openclaw/plugin-sdk/runtime";
4
+ import type { ReplyPayload } from "openclaw/plugin-sdk/reply-payload";
5
+
6
+ type ChannelRuntimeModule = {
7
+ createReplyPrefixOptions: typeof import("openclaw/plugin-sdk/channel-runtime").createReplyPrefixOptions;
8
+ createTypingCallbacks: typeof import("openclaw/plugin-sdk/channel-runtime").createTypingCallbacks;
9
+ logTypingFailure: typeof import("openclaw/plugin-sdk/channel-runtime").logTypingFailure;
10
+ };
11
+
12
+ const {
7
13
  createReplyPrefixOptions,
8
14
  createTypingCallbacks,
9
15
  logTypingFailure,
10
- } from "openclaw/plugin-sdk";
16
+ }: ChannelRuntimeModule = await import("openclaw/plugin-sdk/channel-runtime").catch(
17
+ () => import("openclaw/plugin-sdk") as Promise<ChannelRuntimeModule>
18
+ );
11
19
  import { resolveDingtalkAccount } from "./config/accounts.ts";
12
20
  import { getDingtalkRuntime } from "./runtime.ts";
13
21
  import type { DingtalkConfig } from "./types/index.ts";
@@ -56,6 +64,8 @@ export type CreateDingtalkReplyDispatcherParams = {
56
64
  messageCreateTimeMs?: number;
57
65
  sessionWebhook: string;
58
66
  asyncMode?: boolean;
67
+ /** 队列繁忙时预先创建的 AI Card,startStreaming 时直接复用而非新建 */
68
+ preCreatedCard?: AICardInstance;
59
69
  };
60
70
 
61
71
  export function createDingtalkReplyDispatcher(params: CreateDingtalkReplyDispatcherParams) {
@@ -69,6 +79,7 @@ export function createDingtalkReplyDispatcher(params: CreateDingtalkReplyDispatc
69
79
  accountId,
70
80
  sessionWebhook,
71
81
  asyncMode = false,
82
+ preCreatedCard,
72
83
  } = params;
73
84
 
74
85
  const account = resolveDingtalkAccount({ cfg, accountId });
@@ -114,7 +125,7 @@ export function createDingtalkReplyDispatcher(params: CreateDingtalkReplyDispatc
114
125
 
115
126
  // ✅ 节流控制:避免频繁调用钉钉 API 导致 QPS 限流
116
127
  let lastUpdateTime = 0;
117
- const updateInterval = 1000; // 最小更新间隔 1000ms(钉钉 QPS 限制:40 次/秒,安全起见设为 1 秒)
128
+ const updateInterval = 500; // 最小更新间隔 500ms(钉钉 QPS 限制:40 次/秒,保守起见设为 0.5 秒)
118
129
 
119
130
  // ✅ 错误兜底:防止重复发送错误消息
120
131
  const deliveredErrorTypes = new Set<string>();
@@ -227,6 +238,15 @@ export function createDingtalkReplyDispatcher(params: CreateDingtalkReplyDispatc
227
238
  log.info(`[DingTalk][startStreaming] AI Card 正在创建中,跳过`);
228
239
  return;
229
240
  }
241
+
242
+ // 若队列繁忙时已预先创建了 Card(显示排队 ACK 文案),直接复用,无需新建
243
+ // 这样用户看到的是同一条消息从 ACK 文案更新为最终结果,而不是多出一条消息
244
+ if (preCreatedCard) {
245
+ log.info(`[DingTalk][startStreaming] 复用预创建 AI Card,cardInstanceId=${preCreatedCard.cardInstanceId}`);
246
+ currentCardTarget = preCreatedCard;
247
+ accumulatedText = "";
248
+ return;
249
+ }
230
250
 
231
251
  isCreatingCard = true;
232
252
  log.info(`[DingTalk][startStreaming] 开始创建 AI Card...`);
@@ -385,11 +405,12 @@ export function createDingtalkReplyDispatcher(params: CreateDingtalkReplyDispatc
385
405
  core.channel.reply.createReplyDispatcherWithTyping({
386
406
  ...prefixOptions,
387
407
  humanDelay: core.channel.reply.resolveHumanDelayConfig(cfg, agentId),
388
- onReplyStart: async () => {
408
+ onReplyStart: () => {
389
409
  deliveredFinalTexts.clear();
390
410
  log.info(`[DingTalk][onReplyStart] 开始回复,流式 enabled=${streamingEnabled}`);
391
411
  if (streamingEnabled) {
392
- await startStreaming();
412
+ // fire-and-forget:不阻塞 onReplyStart 返回,onPartialReply 会等待 Card 创建完成
413
+ void startStreaming();
393
414
  }
394
415
  typingCallbacks.onActive?.();
395
416
  },
@@ -666,7 +687,7 @@ export function createDingtalkReplyDispatcher(params: CreateDingtalkReplyDispatc
666
687
  }
667
688
  },
668
689
  }),
669
- disableBlockStreaming: true, // 强制使用 onPartialReply 而不是 block
690
+ disableBlockStreaming: true, // block 内容合并到 final,流式更新通过 onPartialReply 实现
670
691
  },
671
692
  markDispatchIdle,
672
693
  getAsyncModeResponse: () => asyncModeFullResponse,
package/src/runtime.ts CHANGED
@@ -1,7 +1,32 @@
1
- import { createPluginRuntimeStore } from "openclaw/plugin-sdk/compat";
2
1
  import type { PluginRuntime } from "openclaw/plugin-sdk";
3
2
 
3
+ /**
4
+ * 自实现的运行时存储工厂,避免依赖特定版本 openclaw 是否导出 createPluginRuntimeStore。
5
+ * 旧版 openclaw 没有导出该函数,直接 import 会导致 TypeError,因此在此处内联实现。
6
+ */
7
+ function createRuntimeStore<T>(errorMessage: string) {
8
+ let runtimeValue: T | null = null;
9
+
10
+ return {
11
+ setRuntime: (next: T): void => {
12
+ runtimeValue = next;
13
+ },
14
+ clearRuntime: (): void => {
15
+ runtimeValue = null;
16
+ },
17
+ tryGetRuntime: (): T | null => {
18
+ return runtimeValue;
19
+ },
20
+ getRuntime: (): T => {
21
+ if (runtimeValue === null) {
22
+ throw new Error(errorMessage);
23
+ }
24
+ return runtimeValue;
25
+ },
26
+ };
27
+ }
28
+
4
29
  const { setRuntime: setDingtalkRuntime, getRuntime: getDingtalkRuntime } =
5
- createPluginRuntimeStore<PluginRuntime>("DingTalk runtime not initialized");
30
+ createRuntimeStore<PluginRuntime>("DingTalk runtime not initialized");
6
31
 
7
32
  export { getDingtalkRuntime, setDingtalkRuntime };
@@ -6,6 +6,7 @@
6
6
  import type { DingtalkConfig } from "../types/index.ts";
7
7
  import { DINGTALK_API, getAccessToken, getOapiAccessToken } from "../utils/index.ts";
8
8
  import { dingtalkHttp, dingtalkOapiHttp } from "../utils/http-client.ts";
9
+ import { MEDIA_MSG_TYPES } from "../utils/constants.ts";
9
10
  import { createLoggerFromConfig } from "../utils/logger.ts";
10
11
  import {
11
12
  processLocalImages,
@@ -863,8 +864,11 @@ async function sendProactiveInternal(
863
864
  log: externalLog,
864
865
  } = options;
865
866
 
866
- // 如果启用 AI Card
867
- if (useAICard) {
867
+ // 图片、音频、视频、文件等媒体类型消息不支持 AI Card,必须走普通消息 API
868
+ const isMediaMessage = MEDIA_MSG_TYPES.has(msgType as any);
869
+
870
+ // 如果启用 AI Card(媒体消息强制跳过)
871
+ if (useAICard && !isMediaMessage) {
868
872
  try {
869
873
  const card = await createAICardForTarget(config, target, externalLog);
870
874
  if (card) {
@@ -7,3 +7,24 @@ export const DEFAULT_ACCOUNT_ID = '__default__';
7
7
 
8
8
  /** 新会话触发命令 */
9
9
  export const NEW_SESSION_COMMANDS = ['/new', '/reset', '/clear', '新会话', '重新开始', '清空对话'];
10
+
11
+ /**
12
+ * 媒体类消息类型集合。
13
+ *
14
+ * 这些消息类型需要通过钉钉原生消息 API 发送,不支持 AI Card 形式,
15
+ * 在 sendProactiveInternal 中会强制跳过 AI Card 路径。
16
+ */
17
+ export const MEDIA_MSG_TYPES = new Set(['image', 'voice', 'file', 'video'] as const);
18
+
19
+ /**
20
+ * 队列繁忙时的即时 ACK 回复短语。
21
+ *
22
+ * 当消息入队时检测到上一条消息仍在处理中,立即从此列表中随机选取一条回复,
23
+ * 告知用户消息已收到并排队,避免用户以为 Bot 没有响应。
24
+ * 参考 delivery.rs 中 DINGTALK_ACK_PHRASES_BUSY_ZH_CN 的设计。
25
+ */
26
+ export const QUEUE_BUSY_ACK_PHRASES = [
27
+ '上一条还没结束,这条我已经记下,稍后按顺序继续处理。',
28
+ '当前还在忙,你的新消息已经排队,上一条完成后我马上继续。',
29
+ '我这边还在处理上一条,这条已加入队列,完成后继续处理。',
30
+ ] as const;
@@ -10,7 +10,19 @@ export interface SessionContext {
10
10
  channel: 'dingtalk-connector';
11
11
  accountId: string;
12
12
  chatType: 'direct' | 'group';
13
+ /**
14
+ * 真实的 peer 标识,不受任何会话隔离配置影响。
15
+ * 群聊为 conversationId,单聊为 senderId。
16
+ * 与配置中 match.peer.id 语义一致,专用于 bindings 路由匹配。
17
+ */
13
18
  peerId: string;
19
+ /**
20
+ * 用于 session/memory 隔离的 peer 标识(session 键的一部分)。
21
+ * 受 sharedMemoryAcrossConversations、separateSessionByConversation、groupSessionScope 等配置影响,
22
+ * 可能与 peerId 不同(如 sharedMemoryAcrossConversations=true 时被设为 accountId)。
23
+ * 注意:不要用此字段做 binding 路由匹配,应使用 peerId。
24
+ */
25
+ sessionPeerId: string;
14
26
  conversationId?: string;
15
27
  senderName?: string;
16
28
  groupSubject?: string;
@@ -48,13 +60,19 @@ export function buildSessionContext(params: {
48
60
  } = params;
49
61
  const isDirect = conversationType === '1';
50
62
 
63
+ // peerId:真实的 peer 标识,不受任何会话隔离配置影响,专用于 bindings 路由匹配
64
+ // 群聊为 conversationId,单聊为 senderId,与配置中 match.peer.id 语义一致
65
+ const peerId = isDirect ? senderId : (conversationId || senderId);
66
+
51
67
  // sharedMemoryAcrossConversations=true 时,所有会话共享记忆
68
+ // sessionPeerId 被设为 accountId 以合并记忆,peerId 仍保留真实 peer,供路由匹配使用
52
69
  if (sharedMemoryAcrossConversations === true) {
53
70
  return {
54
71
  channel: 'dingtalk-connector',
55
72
  accountId,
56
73
  chatType: isDirect ? 'direct' : 'group',
57
- peerId: accountId, // 使用 accountId 作为 peerId,实现跨会话记忆共享
74
+ peerId,
75
+ sessionPeerId: accountId, // 使用 accountId 作为 sessionPeerId,实现跨会话记忆共享
58
76
  conversationId: isDirect ? undefined : conversationId,
59
77
  senderName,
60
78
  groupSubject: isDirect ? undefined : groupSubject,
@@ -67,19 +85,21 @@ export function buildSessionContext(params: {
67
85
  channel: 'dingtalk-connector',
68
86
  accountId,
69
87
  chatType: isDirect ? 'direct' : 'group',
70
- peerId: senderId, // 只用 senderId,不区分会话
88
+ peerId,
89
+ sessionPeerId: senderId, // 只用 senderId,不区分会话
71
90
  senderName,
72
91
  };
73
92
  }
74
93
 
75
94
  // 以下是 separateSessionByConversation=true(默认)的逻辑
76
95
  if (isDirect) {
77
- // 单聊:peerId 为发送者 ID,由 OpenClaw Gateway 根据 dmScope 配置处理
96
+ // 单聊:sessionPeerId 为发送者 ID,由 OpenClaw Gateway 根据 dmScope 配置处理
78
97
  return {
79
98
  channel: 'dingtalk-connector',
80
99
  accountId,
81
100
  chatType: 'direct',
82
- peerId: senderId,
101
+ peerId,
102
+ sessionPeerId: senderId,
83
103
  senderName,
84
104
  };
85
105
  }
@@ -91,7 +111,8 @@ export function buildSessionContext(params: {
91
111
  channel: 'dingtalk-connector',
92
112
  accountId,
93
113
  chatType: 'group',
94
- peerId: `${conversationId}:${senderId}`,
114
+ peerId,
115
+ sessionPeerId: `${conversationId}:${senderId}`,
95
116
  conversationId,
96
117
  senderName,
97
118
  groupSubject,
@@ -103,7 +124,8 @@ export function buildSessionContext(params: {
103
124
  channel: 'dingtalk-connector',
104
125
  accountId,
105
126
  chatType: 'group',
106
- peerId: conversationId || senderId,
127
+ peerId,
128
+ sessionPeerId: conversationId || senderId,
107
129
  conversationId,
108
130
  senderName,
109
131
  groupSubject,
@@ -245,6 +245,41 @@ export function markMessageProcessed(messageId: string): void {
245
245
  }
246
246
  }
247
247
 
248
+ /**
249
+ * 对钉钉 Stream 消息做双层去重检查,并在首次处理时标记。
250
+ *
251
+ * 背景:钉钉 Stream 模式存在两套消息 ID:
252
+ * - headers.messageId:WebSocket 协议层的投递 ID,每次重发都会生成新值
253
+ * - data.msgId:业务层的用户消息 ID,重发时保持不变
254
+ *
255
+ * 因此必须同时检查两个 ID,才能可靠地拦截钉钉服务端的重发消息:
256
+ * 1. 协议层去重(headers.messageId):拦截同一次投递的重复回调
257
+ * 2. 业务层去重(data.msgId):拦截 ~60 秒后服务端因未收到业务回复而触发的重发
258
+ *
259
+ * @param protocolMessageId - res.headers.messageId(WebSocket 协议层投递 ID)
260
+ * @param businessMsgId - data.msgId(钉钉业务层消息 ID,来自 JSON.parse(res.data).msgId)
261
+ * @returns true 表示消息已处理过(应跳过),false 表示首次处理(已标记为已处理)
262
+ */
263
+ export function checkAndMarkDingtalkMessage(
264
+ protocolMessageId: string | undefined,
265
+ businessMsgId: string | undefined,
266
+ ): boolean {
267
+ // 先完整检查两个 ID,再决定是否标记
268
+ // 不能提前 return,否则命中去重的那条路径会漏掉对另一个 ID 的标记
269
+ const isProtocolDuplicate = protocolMessageId ? isMessageProcessed(protocolMessageId) : false;
270
+ const isBusinessDuplicate = businessMsgId ? isMessageProcessed(businessMsgId) : false;
271
+
272
+ if (isProtocolDuplicate || isBusinessDuplicate) {
273
+ return true;
274
+ }
275
+
276
+ // 首次处理:同时标记两个 ID,确保后续任意一个 ID 都能命中去重
277
+ if (protocolMessageId) markMessageProcessed(protocolMessageId);
278
+ if (businessMsgId) markMessageProcessed(businessMsgId);
279
+
280
+ return false;
281
+ }
282
+
248
283
  // ============ 配置工具 ============
249
284
 
250
285
  /**