@dingtalk-real-ai/dingtalk-connector 0.8.1 → 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 CHANGED
@@ -5,6 +5,48 @@ 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
+
30
+ ## [0.8.2] - 2026-03-22
31
+
32
+ ### 修复 / Fixes
33
+ - 🐛 **多账号重复启动问题** - 修复 `enabled: false` 的账号仍会建立 WebSocket 连接的问题,禁用账号现在正确保持 pending 状态直到 Gateway 停止
34
+ **Multi-account duplicate startup** - Fixed accounts with `enabled: false` still establishing WebSocket connections; disabled accounts now correctly remain in a pending state until the Gateway stops
35
+
36
+ - 🐛 **相同 clientId 账号去重** - 修复多个账号配置相同 `clientId` 时建立重复连接的问题,通过静态配置分析确保同一 `clientId` 只有列表中第一个启用账号建立连接
37
+ **Duplicate clientId deduplication** - Fixed duplicate connections when multiple accounts share the same `clientId`; static config analysis now ensures only the first enabled account per `clientId` establishes a connection
38
+
39
+
40
+ ### 改进 / Improvements
41
+ - ✅ **Onboarding 配置向导优化** - 改进钉钉连接器配置引导逻辑,调整凭据输入顺序(先 Client ID 后 Client Secret),优化引导文案
42
+ **Onboarding wizard improvement** - Improved DingTalk connector onboarding flow, adjusted credential input order (Client ID first, then Client Secret), and refined guidance text
43
+
44
+ - ✅ **会话 Key 遵循 OpenClaw 规范** - 会话上下文按 OpenClaw 标准规则构建,通过 `channel`、`accountId`、`chatType`、`peerId` 唯一标识会话,支持 `sharedMemoryAcrossConversations` 跨会话记忆共享
45
+ **Session key follows OpenClaw convention** - Session context now built per OpenClaw standard rules, uniquely identified via `channel`, `accountId`, `chatType`, `peerId`; supports `sharedMemoryAcrossConversations` for cross-conversation memory sharing
46
+
47
+ - ✅ **消息处理逻辑优化** - 重构消息处理流程,提升消息响应速度和处理可靠性,确保消息按序正确处理
48
+ **Message processing logic optimization** - Refactored message processing flow to improve response speed and reliability, ensuring messages are processed correctly in order
49
+
8
50
  ## [0.8.1] - 2026-03-20
9
51
 
10
52
  ### 修复 / 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.1",
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": ["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.1",
3
+ "version": "0.8.3",
4
4
  "description": "DingTalk (钉钉) channel connector — Stream mode with AI Card streaming",
5
5
  "main": "index.ts",
6
6
  "type": "module",
package/src/channel.ts CHANGED
@@ -495,64 +495,64 @@ export const dingtalkPlugin: ChannelPlugin<ResolvedDingtalkAccount> = {
495
495
  gateway: {
496
496
  startAccount: async (ctx) => {
497
497
  const account = resolveDingtalkAccount({ cfg: ctx.cfg, accountId: ctx.accountId });
498
- const logger = createLogger(account.config?.debug ?? false, 'DingTalk:Gateway');
498
+
499
+ // 检查账号是否启用和配置
500
+ if (!account.enabled) {
501
+ ctx.log?.info?.(`dingtalk-connector[${ctx.accountId}] is disabled, skipping startup`);
502
+ // 返回一个永不 resolve 的 Promise,保持 pending 状态直到 abort
503
+ return new Promise<void>((resolve) => {
504
+ if (ctx.abortSignal?.aborted) {
505
+ resolve();
506
+ return;
507
+ }
508
+ ctx.abortSignal?.addEventListener('abort', () => resolve(), { once: true });
509
+ });
510
+ }
499
511
 
500
- logger.info(`startAccount 被调用:accountId=${ctx.accountId}`);
501
- try {
502
- logger.info('='.repeat(60));
503
- logger.info('开始加载 provider 模块...');
504
- const monitorModule = await import("./core/provider.ts");
505
- logger.info(`monitor module 加载完成`);
506
- logger.info(`monitor module keys: ${Object.keys(monitorModule).join(', ')}`);
507
- logger.info(`monitorModule 类型: ${typeof monitorModule}`);
508
- logger.info(`monitorModule 是否为 null: ${monitorModule === null}`);
509
- logger.info(`monitorModule 是否为 undefined: ${monitorModule === undefined}`);
510
-
511
- // 使用 Object.getOwnPropertyDescriptor 检查属性
512
- const descriptor = Object.getOwnPropertyDescriptor(monitorModule, 'monitorSingleAccount');
513
- logger.info(`monitorSingleAccount descriptor: ${JSON.stringify(descriptor)}`);
514
-
515
- // 尝试安全地访问 monitorSingleAccount
516
- let monitorSingleAccountType = 'unknown';
517
- try {
518
- monitorSingleAccountType = typeof monitorModule.monitorSingleAccount;
519
- } catch (e) {
520
- monitorSingleAccountType = `error: ${e.message}`;
521
- }
522
- logger.info(`monitorModule.monitorSingleAccount: ${monitorSingleAccountType}`);
523
-
524
- logger.info(`monitorModule.monitorDingtalkProvider: ${typeof monitorModule.monitorDingtalkProvider}`);
525
-
526
- // 使用直接属性访问而不是解构
527
- const monitorDingtalkProvider = monitorModule.monitorDingtalkProvider;
528
- logger.info(`解构 monitorDingtalkProvider 完成: ${typeof monitorDingtalkProvider}`);
529
-
530
- if (!monitorDingtalkProvider) {
531
- ctx.log?.error?.(`monitorDingtalkProvider 未找到!可用导出: ${Object.keys(monitorModule).join(', ')}`);
532
- throw new Error("monitorDingtalkProvider not found in monitor module");
512
+ if (!account.configured) {
513
+ throw new Error(`DingTalk account "${ctx.accountId}" is not properly configured`);
514
+ }
515
+
516
+ // 去重检查:如果列表中排在当前账号之前的账号已使用相同 clientId,则跳过当前账号
517
+ // 使用静态配置分析(而非运行时状态),避免并发竞态条件
518
+ // 规则:同一 clientId 只有列表中第一个启用且已配置的账号才会建立连接
519
+ if (account.clientId) {
520
+ const clientId = String(account.clientId);
521
+ const allAccountIds = listDingtalkAccountIds(ctx.cfg);
522
+ const currentIndex = allAccountIds.indexOf(ctx.accountId);
523
+ const priorAccountWithSameClientId = allAccountIds.slice(0, currentIndex).find((otherId) => {
524
+ const other = resolveDingtalkAccount({ cfg: ctx.cfg, accountId: otherId });
525
+ return other.enabled && other.configured && other.clientId && String(other.clientId) === clientId;
526
+ });
527
+ if (priorAccountWithSameClientId) {
528
+ ctx.log?.info?.(
529
+ `dingtalk-connector[${ctx.accountId}] skipped: clientId "${clientId.substring(0, 8)}..." is already used by account "${priorAccountWithSameClientId}"`
530
+ );
531
+ return new Promise<void>((resolve) => {
532
+ if (ctx.abortSignal?.aborted) {
533
+ resolve();
534
+ return;
535
+ }
536
+ ctx.abortSignal?.addEventListener('abort', () => resolve(), { once: true });
537
+ });
533
538
  }
534
- logger.info(`monitorDingtalkProvider 找到`);
535
-
536
- logger.info(`account 解析完成: ${account.accountId}, enabled=${account.enabled}, configured=${account.configured}`);
537
-
538
- ctx.setStatus({ accountId: ctx.accountId, port: null });
539
- ctx.log?.info(
540
- `starting dingtalk-connector[${ctx.accountId}] (mode: stream)`,
541
- );
542
- logger.info(`准备调用 monitorDingtalkProvider`);
543
-
544
- const result = await monitorDingtalkProvider({
539
+ }
540
+
541
+ ctx.setStatus({ accountId: ctx.accountId, port: null });
542
+ ctx.log?.info(
543
+ `starting dingtalk-connector[${ctx.accountId}] (mode: stream)`,
544
+ );
545
+ try {
546
+ return await monitorDingtalkProvider({
545
547
  config: ctx.cfg,
546
548
  runtime: ctx.runtime,
547
549
  abortSignal: ctx.abortSignal,
548
550
  accountId: ctx.accountId,
549
551
  });
550
- logger.info(`monitorDingtalkProvider 调用完成`);
551
- return result;
552
- } catch (error) {
553
- ctx.log?.error?.(`startAccount 发生错误: ${error.message}`);
554
- ctx.log?.error?.(`错误堆栈: ${error.stack}`);
555
- throw error;
552
+ } catch (err: any) {
553
+ // 打印真实错误到 stderr,绕过框架 log 系统(框架的 runtime.log 可能未初始化)
554
+ ctx.log?.error(`[dingtalk-connector][${ctx.accountId}] startAccount error:`, err?.message ?? err, err?.stack);
555
+ throw err;
556
556
  }
557
557
  },
558
558
  },
@@ -222,9 +222,21 @@ export function resolveDingtalkAccount(params: {
222
222
 
223
223
  /**
224
224
  * List all enabled and configured accounts.
225
+ * Deduplicates by clientId to avoid creating multiple connections with the same credentials.
225
226
  */
226
227
  export function listEnabledDingtalkAccounts(cfg: ClawdbotConfig): ResolvedDingtalkAccount[] {
227
- return listDingtalkAccountIds(cfg)
228
+ const accounts = listDingtalkAccountIds(cfg)
228
229
  .map((accountId) => resolveDingtalkAccount({ cfg, accountId }))
229
230
  .filter((account) => account.enabled && account.configured);
231
+
232
+ // Deduplicate by clientId to avoid multiple connections with same credentials
233
+ const seen = new Set<string>();
234
+ return accounts.filter((account) => {
235
+ if (!account.clientId) return true;
236
+ if (seen.has(account.clientId)) {
237
+ return false;
238
+ }
239
+ seen.add(account.clientId);
240
+ return true;
241
+ });
230
242
  }