@dingtalk-real-ai/dingtalk-connector 0.8.2 → 0.8.3
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 +22 -0
- package/docs/AGENT_ROUTING.md +335 -0
- package/openclaw.plugin.json +8 -3
- package/package.json +1 -1
- package/src/core/connection.ts +19 -11
- package/src/core/message-handler.ts +72 -32
- package/src/reply-dispatcher.ts +17 -4
- package/src/runtime.ts +1 -1
- package/src/services/messaging.ts +6 -2
- package/src/utils/constants.ts +21 -0
- package/src/utils/session.ts +28 -6
- package/src/utils/utils-legacy.ts +35 -0
package/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,28 @@ 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] - 2026-03-24
|
|
9
|
+
|
|
10
|
+
### 修复 / Fixes
|
|
11
|
+
- 🐛 **兼容 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+)均兼容
|
|
12
|
+
**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+)
|
|
13
|
+
|
|
14
|
+
- ✅ **AI 卡片流式更新延迟优化** - 改动前 `onReplyStart` 串行等待 AI Card 创建(约 500ms~1s),期间 partial reply 全部丢弃,节流间隔 1000ms 也过于保守。改动后 AI Card 创建改为 fire-and-forget 与 AI 生成并行,节流间隔调整为 500ms,流式内容能更早更频繁地呈现
|
|
15
|
+
**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
|
|
16
|
+
|
|
17
|
+
- 🐛 **多 Agent 路由与 sharedMemoryAcrossConversations 冲突** - 修复配置 `sharedMemoryAcrossConversations: true` 时,多群分配不同 Agent 的 bindings 全部路由到同一 Agent 的问题。路由匹配现在使用专用的 `peerId`(真实 peer 标识,不受会话隔离配置影响),session 构建使用 `sessionPeerId`,两者职责严格分离
|
|
18
|
+
**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
|
|
19
|
+
|
|
20
|
+
- 🐛 **发送图片失败** - 修复发送图片时出现异常的问题 ([#316](https://github.com/DingTalk-Real-AI/dingtalk-openclaw-connector/issues/316))
|
|
21
|
+
**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))
|
|
22
|
+
|
|
23
|
+
- 🐛 **发送人昵称与群名称未正确传递给 AI** - 修复会话上下文中 `SenderName` 字段错误传入用户 ID(而非昵称)、`GroupSubject` 字段错误传入群 ID(而非群名称)的问题,AI 现在能正确获取发送人的钉钉昵称和所在群的名称
|
|
24
|
+
**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
|
|
25
|
+
|
|
26
|
+
### 改进 / Improvements
|
|
27
|
+
- ✨ **消息队列繁忙时的即时排队反馈** - 当用户快速连续发送消息、上一条仍在处理中时,新消息现在会立即弹出一条 AI Card 气泡显示排队提示文案,同时贴上"思考中"表情。等轮到该消息处理时,气泡**原地更新**为最终回复内容,不会额外多发一条消息
|
|
28
|
+
**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
|
|
29
|
+
|
|
8
30
|
## [0.8.2] - 2026-03-22
|
|
9
31
|
|
|
10
32
|
### 修复 / 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` 不受影响,路由仍然正常工作。
|
package/openclaw.plugin.json
CHANGED
|
@@ -1,16 +1,21 @@
|
|
|
1
1
|
{
|
|
2
2
|
"id": "dingtalk-connector",
|
|
3
3
|
"name": "DingTalk Channel",
|
|
4
|
-
"version": "0.8.
|
|
4
|
+
"version": "0.8.3",
|
|
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": [
|
|
8
|
+
"channels": [
|
|
9
|
+
"dingtalk-connector"
|
|
10
|
+
],
|
|
9
11
|
"configSchema": {
|
|
10
12
|
"type": "object",
|
|
11
13
|
"additionalProperties": false,
|
|
12
14
|
"properties": {
|
|
13
|
-
"enabled": {
|
|
15
|
+
"enabled": {
|
|
16
|
+
"type": "boolean",
|
|
17
|
+
"default": true
|
|
18
|
+
}
|
|
14
19
|
}
|
|
15
20
|
}
|
|
16
21
|
}
|
package/package.json
CHANGED
package/src/core/connection.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
494
|
-
|
|
495
|
-
|
|
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
|
-
|
|
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,
|
|
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 之后,确保
|
|
699
|
-
// 使用 sessionContext.peerId
|
|
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
|
-
|
|
896
|
-
|
|
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}:{
|
|
950
|
-
// ✅
|
|
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},
|
|
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,
|
|
960
|
-
id: sessionContext.
|
|
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.
|
|
984
|
-
SenderName:
|
|
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.
|
|
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:使用
|
|
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
|
-
|
|
1248
|
-
console.log(`[DEBUG] 任务执行完成`);
|
|
1288
|
+
// 不等待任务完成,立即返回,不阻塞 WebSocket 消息接收
|
|
1289
|
+
// 消息处理在后台异步执行,队列保证同一会话+agent的消息串行处理
|
|
1249
1290
|
} catch (err: any) {
|
|
1250
|
-
|
|
1251
|
-
|
|
1252
|
-
|
|
1253
|
-
return handleDingTalkMessageInternal(params);
|
|
1291
|
+
log?.error?.(`[队列] 队列管理异常,直接处理: ${err.message}`);
|
|
1292
|
+
// 如果队列管理失败,直接调用内部处理函数(不阻塞)
|
|
1293
|
+
void handleDingTalkMessageInternal(params);
|
|
1254
1294
|
}
|
|
1255
1295
|
}
|
|
1256
1296
|
|
package/src/reply-dispatcher.ts
CHANGED
|
@@ -56,6 +56,8 @@ export type CreateDingtalkReplyDispatcherParams = {
|
|
|
56
56
|
messageCreateTimeMs?: number;
|
|
57
57
|
sessionWebhook: string;
|
|
58
58
|
asyncMode?: boolean;
|
|
59
|
+
/** 队列繁忙时预先创建的 AI Card,startStreaming 时直接复用而非新建 */
|
|
60
|
+
preCreatedCard?: AICardInstance;
|
|
59
61
|
};
|
|
60
62
|
|
|
61
63
|
export function createDingtalkReplyDispatcher(params: CreateDingtalkReplyDispatcherParams) {
|
|
@@ -69,6 +71,7 @@ export function createDingtalkReplyDispatcher(params: CreateDingtalkReplyDispatc
|
|
|
69
71
|
accountId,
|
|
70
72
|
sessionWebhook,
|
|
71
73
|
asyncMode = false,
|
|
74
|
+
preCreatedCard,
|
|
72
75
|
} = params;
|
|
73
76
|
|
|
74
77
|
const account = resolveDingtalkAccount({ cfg, accountId });
|
|
@@ -114,7 +117,7 @@ export function createDingtalkReplyDispatcher(params: CreateDingtalkReplyDispatc
|
|
|
114
117
|
|
|
115
118
|
// ✅ 节流控制:避免频繁调用钉钉 API 导致 QPS 限流
|
|
116
119
|
let lastUpdateTime = 0;
|
|
117
|
-
const updateInterval =
|
|
120
|
+
const updateInterval = 500; // 最小更新间隔 500ms(钉钉 QPS 限制:40 次/秒,保守起见设为 0.5 秒)
|
|
118
121
|
|
|
119
122
|
// ✅ 错误兜底:防止重复发送错误消息
|
|
120
123
|
const deliveredErrorTypes = new Set<string>();
|
|
@@ -227,6 +230,15 @@ export function createDingtalkReplyDispatcher(params: CreateDingtalkReplyDispatc
|
|
|
227
230
|
log.info(`[DingTalk][startStreaming] AI Card 正在创建中,跳过`);
|
|
228
231
|
return;
|
|
229
232
|
}
|
|
233
|
+
|
|
234
|
+
// 若队列繁忙时已预先创建了 Card(显示排队 ACK 文案),直接复用,无需新建
|
|
235
|
+
// 这样用户看到的是同一条消息从 ACK 文案更新为最终结果,而不是多出一条消息
|
|
236
|
+
if (preCreatedCard) {
|
|
237
|
+
log.info(`[DingTalk][startStreaming] 复用预创建 AI Card,cardInstanceId=${preCreatedCard.cardInstanceId}`);
|
|
238
|
+
currentCardTarget = preCreatedCard;
|
|
239
|
+
accumulatedText = "";
|
|
240
|
+
return;
|
|
241
|
+
}
|
|
230
242
|
|
|
231
243
|
isCreatingCard = true;
|
|
232
244
|
log.info(`[DingTalk][startStreaming] 开始创建 AI Card...`);
|
|
@@ -385,11 +397,12 @@ export function createDingtalkReplyDispatcher(params: CreateDingtalkReplyDispatc
|
|
|
385
397
|
core.channel.reply.createReplyDispatcherWithTyping({
|
|
386
398
|
...prefixOptions,
|
|
387
399
|
humanDelay: core.channel.reply.resolveHumanDelayConfig(cfg, agentId),
|
|
388
|
-
onReplyStart:
|
|
400
|
+
onReplyStart: () => {
|
|
389
401
|
deliveredFinalTexts.clear();
|
|
390
402
|
log.info(`[DingTalk][onReplyStart] 开始回复,流式 enabled=${streamingEnabled}`);
|
|
391
403
|
if (streamingEnabled) {
|
|
392
|
-
|
|
404
|
+
// fire-and-forget:不阻塞 onReplyStart 返回,onPartialReply 会等待 Card 创建完成
|
|
405
|
+
void startStreaming();
|
|
393
406
|
}
|
|
394
407
|
typingCallbacks.onActive?.();
|
|
395
408
|
},
|
|
@@ -666,7 +679,7 @@ export function createDingtalkReplyDispatcher(params: CreateDingtalkReplyDispatc
|
|
|
666
679
|
}
|
|
667
680
|
},
|
|
668
681
|
}),
|
|
669
|
-
disableBlockStreaming: true, //
|
|
682
|
+
disableBlockStreaming: true, // block 内容合并到 final,流式更新通过 onPartialReply 实现
|
|
670
683
|
},
|
|
671
684
|
markDispatchIdle,
|
|
672
685
|
getAsyncModeResponse: () => asyncModeFullResponse,
|
package/src/runtime.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { createPluginRuntimeStore } from "openclaw/plugin-sdk
|
|
1
|
+
import { createPluginRuntimeStore } from "openclaw/plugin-sdk";
|
|
2
2
|
import type { PluginRuntime } from "openclaw/plugin-sdk";
|
|
3
3
|
|
|
4
4
|
const { setRuntime: setDingtalkRuntime, getRuntime: getDingtalkRuntime } =
|
|
@@ -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
|
-
//
|
|
867
|
-
|
|
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) {
|
package/src/utils/constants.ts
CHANGED
|
@@ -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;
|
package/src/utils/session.ts
CHANGED
|
@@ -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
|
|
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
|
|
88
|
+
peerId,
|
|
89
|
+
sessionPeerId: senderId, // 只用 senderId,不区分会话
|
|
71
90
|
senderName,
|
|
72
91
|
};
|
|
73
92
|
}
|
|
74
93
|
|
|
75
94
|
// 以下是 separateSessionByConversation=true(默认)的逻辑
|
|
76
95
|
if (isDirect) {
|
|
77
|
-
// 单聊:
|
|
96
|
+
// 单聊:sessionPeerId 为发送者 ID,由 OpenClaw Gateway 根据 dmScope 配置处理
|
|
78
97
|
return {
|
|
79
98
|
channel: 'dingtalk-connector',
|
|
80
99
|
accountId,
|
|
81
100
|
chatType: 'direct',
|
|
82
|
-
peerId
|
|
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
|
|
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
|
|
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
|
/**
|