@dingtalk-real-ai/dingtalk-connector 0.8.15 → 0.8.16
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 +16 -0
- package/docs/RELEASE_NOTES_V0.8.16.md +40 -0
- package/package.json +8 -2
- package/src/reply-dispatcher.ts +13 -14
- package/src/services/messaging/card.ts +152 -0
package/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,22 @@ 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.16] - 2026-04-16
|
|
9
|
+
|
|
10
|
+
### 修复 / Fixes
|
|
11
|
+
- 🐛 **AI Card 流式更新 QPS 限流** - 新增全局令牌桶限流器(`cardRateLimiter`),所有会话共享 20 QPS 速率限制,避免多会话并发时总 QPS 叠加超过钉钉 API 限制;遇到 403 QpsLimit 自动退避 2s 后重试
|
|
12
|
+
**AI Card streaming QPS rate limiting** - Added global token bucket rate limiter shared across all sessions (20 QPS cap) with automatic 2s backoff and retry on 403 QpsLimit
|
|
13
|
+
|
|
14
|
+
- 🐛 **streamAICard null card 崩溃** - 修复 `createAICardForTarget` 返回 `null` 后调用方通过 `as any` 绕过类型检查导致 `Cannot read properties of null` 崩溃,添加 null 守卫
|
|
15
|
+
**streamAICard null card crash** - Fixed crash when `createAICardForTarget` returns `null` and callers bypass type checking; added null guard
|
|
16
|
+
|
|
17
|
+
- 🐛 **插件配置格式兼容性** - 修复 `package.json` 中缺少 `openclaw.channels` 数组导致旧版 OpenClaw 框架安装失败的问题,同时保留新版 `openclaw.channel` 对象格式
|
|
18
|
+
**Plugin config format compatibility** - Fixed missing `openclaw.channels` array in `package.json` that caused installation failure on older OpenClaw framework versions; both old and new config formats are now present
|
|
19
|
+
|
|
20
|
+
### 改进 / Improvements
|
|
21
|
+
- ✅ **单实例节流间隔优化** - `reply-dispatcher.ts` 的 `updateInterval` 从 500ms 增大到 800ms,配合全局限流器降低单实例发送频率
|
|
22
|
+
**Per-instance throttle interval** - Increased `updateInterval` from 500ms to 800ms to complement global rate limiter
|
|
23
|
+
|
|
8
24
|
## [0.8.15] - 2026-04-15
|
|
9
25
|
|
|
10
26
|
### 新增 / Added
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
# Release Notes - v0.8.15-beta.1
|
|
2
|
+
|
|
3
|
+
## 🎉 新版本亮点 / Highlights
|
|
4
|
+
|
|
5
|
+
本次 beta 版本主要修复 AI Card 流式更新频繁触发钉钉 QPS 限流的问题,通过引入全局令牌桶限流器,确保多会话并发时不超过钉钉 API 速率限制。
|
|
6
|
+
|
|
7
|
+
This beta release fixes frequent DingTalk QPS rate limiting during AI Card streaming updates by introducing a global token bucket rate limiter that enforces API rate limits across all concurrent sessions.
|
|
8
|
+
|
|
9
|
+
## 🐛 修复 / Fixes
|
|
10
|
+
|
|
11
|
+
- **AI Card 流式更新 QPS 限流 / AI Card streaming QPS rate limiting**
|
|
12
|
+
新增全局令牌桶限流器(`cardRateLimiter`),所有会话共享同一速率限制(20 QPS),避免多会话并发时总 QPS 叠加超过钉钉 API 限制(~40 次/秒)。遇到 403 QpsLimit 错误时自动退避 2 秒后重试一次,确保 finalize 等关键更新不丢失。
|
|
13
|
+
Added global token bucket rate limiter (`cardRateLimiter`) shared across all sessions (20 QPS cap). Automatically backs off for 2 seconds and retries once on 403 QpsLimit errors, ensuring critical updates like finalize are not lost.
|
|
14
|
+
|
|
15
|
+
- **streamAICard null card 崩溃 / streamAICard null card crash**
|
|
16
|
+
修复 `createAICardForTarget` 创建失败返回 `null` 后,调用方通过 `as any` 绕过类型检查传入 `streamAICard`,导致 `Cannot read properties of null (reading 'tokenExpireTime')` 崩溃的问题。现在 `streamAICard` 入口添加了 null 守卫,安全跳过并打印警告日志。
|
|
17
|
+
Fixed crash when `createAICardForTarget` returns `null` and callers bypass type checking with `as any`. Added null guard at `streamAICard` entry point.
|
|
18
|
+
|
|
19
|
+
## ✅ 改进 / Improvements
|
|
20
|
+
|
|
21
|
+
- **单实例节流间隔优化 / Per-instance throttle interval optimization**
|
|
22
|
+
`reply-dispatcher.ts` 中的 `updateInterval` 从 500ms 增大到 800ms,配合全局限流器降低单实例发送频率,减少不必要的 API 调用。
|
|
23
|
+
Increased `updateInterval` from 500ms to 800ms in `reply-dispatcher.ts` to complement the global rate limiter and reduce unnecessary API calls.
|
|
24
|
+
|
|
25
|
+
## 📥 安装升级 / Installation & Upgrade
|
|
26
|
+
|
|
27
|
+
```bash
|
|
28
|
+
openclaw plugins install @dingtalk-real-ai/dingtalk-connector@0.8.15-beta.1
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
## 🔗 相关链接 / Related Links
|
|
32
|
+
|
|
33
|
+
- [完整变更日志 / Full Changelog](https://github.com/DingTalk-Real-AI/dingtalk-openclaw-connector/blob/main/CHANGELOG.md)
|
|
34
|
+
- [使用文档 / Documentation](https://github.com/DingTalk-Real-AI/dingtalk-openclaw-connector/blob/main/README.md)
|
|
35
|
+
|
|
36
|
+
---
|
|
37
|
+
|
|
38
|
+
**发布日期 / Release Date**:2026-04-16
|
|
39
|
+
**版本号 / Version**:v0.8.16
|
|
40
|
+
**兼容性 / Compatibility**:OpenClaw Gateway 0.4.0+
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@dingtalk-real-ai/dingtalk-connector",
|
|
3
|
-
"version": "0.8.
|
|
3
|
+
"version": "0.8.16",
|
|
4
4
|
"description": "Official OpenClaw DingTalk channel plugin | 钉钉官方 OpenClaw 插件",
|
|
5
5
|
"main": "index.ts",
|
|
6
6
|
"type": "module",
|
|
@@ -87,6 +87,9 @@
|
|
|
87
87
|
}
|
|
88
88
|
},
|
|
89
89
|
"openclaw": {
|
|
90
|
+
"channels": [
|
|
91
|
+
"dingtalk-connector"
|
|
92
|
+
],
|
|
90
93
|
"extensions": [
|
|
91
94
|
"./index.ts"
|
|
92
95
|
],
|
|
@@ -95,7 +98,10 @@
|
|
|
95
98
|
"label": "DingTalk",
|
|
96
99
|
"selectionLabel": "DingTalk (钉钉)",
|
|
97
100
|
"blurb": "Official DingTalk plugin with AI Card streaming & multi-agent routing | 钉钉官方插件,支持 AI Card 流式响应与多 Agent 路由",
|
|
98
|
-
"aliases": [
|
|
101
|
+
"aliases": [
|
|
102
|
+
"dingtalk",
|
|
103
|
+
"钉钉"
|
|
104
|
+
],
|
|
99
105
|
"order": 33,
|
|
100
106
|
"quickstartAllowFrom": true
|
|
101
107
|
},
|
package/src/reply-dispatcher.ts
CHANGED
|
@@ -96,8 +96,10 @@ export function createDingtalkReplyDispatcher(params: CreateDingtalkReplyDispatc
|
|
|
96
96
|
let asyncModeFullResponse = "";
|
|
97
97
|
|
|
98
98
|
// ✅ 节流控制:避免频繁调用钉钉 API 导致 QPS 限流
|
|
99
|
+
// 全局令牌桶限流器已在 streamAICard 内部实现(card.ts),此处的 updateInterval
|
|
100
|
+
// 作为单实例级别的前置过滤,减少不必要的 streamAICard 调用
|
|
99
101
|
let lastUpdateTime = 0;
|
|
100
|
-
const updateInterval =
|
|
102
|
+
const updateInterval = 800; // 最小更新间隔 800ms(配合 card.ts 全局限流器,降低单实例发送频率)
|
|
101
103
|
|
|
102
104
|
// ✅ 错误兜底:防止重复发送错误消息
|
|
103
105
|
const deliveredErrorTypes = new Set<string>();
|
|
@@ -460,6 +462,8 @@ export function createDingtalkReplyDispatcher(params: CreateDingtalkReplyDispatc
|
|
|
460
462
|
if (currentCardTarget) {
|
|
461
463
|
const now = Date.now();
|
|
462
464
|
if (now - lastUpdateTime >= updateInterval) {
|
|
465
|
+
// ✅ 乐观更新:防止并发回调在 await 期间通过节流检查
|
|
466
|
+
lastUpdateTime = now;
|
|
463
467
|
try {
|
|
464
468
|
await streamAICard(
|
|
465
469
|
currentCardTarget as any,
|
|
@@ -468,7 +472,6 @@ export function createDingtalkReplyDispatcher(params: CreateDingtalkReplyDispatc
|
|
|
468
472
|
account.config as DingtalkConfig,
|
|
469
473
|
log
|
|
470
474
|
);
|
|
471
|
-
lastUpdateTime = now;
|
|
472
475
|
log.info(`[DingTalk][deliver] ✅ block 更新到 AI Card 成功`);
|
|
473
476
|
} catch (streamErr: any) {
|
|
474
477
|
log.error(`[DingTalk][deliver] ❌ block 更新 AI Card 失败:${streamErr.message}`);
|
|
@@ -593,6 +596,10 @@ export function createDingtalkReplyDispatcher(params: CreateDingtalkReplyDispatc
|
|
|
593
596
|
|
|
594
597
|
log.debug(`[DingTalk][onPartialReply] 更新 AI Card,显示文本长度=${displayContent.length}`);
|
|
595
598
|
|
|
599
|
+
// ✅ 乐观更新:在发起 HTTP 请求前立即更新 lastUpdateTime,
|
|
600
|
+
// 防止并发的 onPartialReply 回调在 await 期间通过节流检查,
|
|
601
|
+
// 导致多个请求同时打到同一张卡片触发服务端 403 并发保护
|
|
602
|
+
lastUpdateTime = now;
|
|
596
603
|
try {
|
|
597
604
|
await streamAICard(
|
|
598
605
|
currentCardTarget as any,
|
|
@@ -601,20 +608,12 @@ export function createDingtalkReplyDispatcher(params: CreateDingtalkReplyDispatc
|
|
|
601
608
|
account.config as DingtalkConfig,
|
|
602
609
|
log
|
|
603
610
|
);
|
|
604
|
-
lastUpdateTime = now;
|
|
605
611
|
log.debug(`[DingTalk][onPartialReply] ✅ AI Card 更新成功`);
|
|
606
612
|
} catch (err: any) {
|
|
607
|
-
//
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
lastUpdateTime = now;
|
|
612
|
-
log.warn(`[DingTalk][AICard] QPS 限流,跳过本次更新`);
|
|
613
|
-
} else {
|
|
614
|
-
log.error(`[DingTalk][onPartialReply] ❌ AI Card 更新失败:${err.message}`);
|
|
615
|
-
// ✅ 发送兜底错误消息,但不抛出异常,避免中断后续处理
|
|
616
|
-
await sendFallbackErrorMessage('sendMessage', err.message);
|
|
617
|
-
}
|
|
613
|
+
// QPS 限流已在 streamAICard 内部处理(自动退避+重试),
|
|
614
|
+
// 到达此处说明重试也失败了,记录错误但不中断流式更新
|
|
615
|
+
log.error(`[DingTalk][onPartialReply] ❌ AI Card 更新失败:${err.message}`);
|
|
616
|
+
await sendFallbackErrorMessage('sendMessage', err.message);
|
|
618
617
|
}
|
|
619
618
|
} else {
|
|
620
619
|
log.debug(`[DingTalk][onPartialReply] 节流控制,跳过本次更新(距离上次更新 ${now - lastUpdateTime}ms)`);
|
|
@@ -11,6 +11,109 @@ import { dingtalkHttp } from "../../utils/http-client.ts";
|
|
|
11
11
|
|
|
12
12
|
const AI_CARD_TEMPLATE_ID = "02fcf2f4-5e02-4a85-b672-46d1f715543e.schema";
|
|
13
13
|
|
|
14
|
+
/**
|
|
15
|
+
* 钉钉卡片 API 的最大 QPS(官方限制约 40 次/秒)。
|
|
16
|
+
* 保守取 20,为 createAICardForTarget / finishAICard 等非流式调用留余量。
|
|
17
|
+
*/
|
|
18
|
+
const CARD_API_MAX_QPS = 20;
|
|
19
|
+
|
|
20
|
+
/** QPS 限流退避时长(ms),遇到 403 QpsLimit 后暂停发送 */
|
|
21
|
+
const QPS_BACKOFF_DURATION_MS = 2_000;
|
|
22
|
+
|
|
23
|
+
// ============ 全局令牌桶限流器 ============
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* 全局令牌桶限流器,所有 streamAICard 调用共享。
|
|
27
|
+
*
|
|
28
|
+
* 解决的问题:每个 reply-dispatcher 实例有独立的 500ms 节流间隔,
|
|
29
|
+
* 但多个会话并发时总 QPS 会叠加超过钉钉 API 限制(40 次/秒),
|
|
30
|
+
* 导致频繁触发 403 QpsLimit 错误。
|
|
31
|
+
*
|
|
32
|
+
* 工作原理:
|
|
33
|
+
* - 令牌桶以 CARD_API_MAX_QPS 的速率补充令牌
|
|
34
|
+
* - 每次 API 调用前消耗一个令牌,无令牌时等待
|
|
35
|
+
* - 遇到 QpsLimit 错误时触发退避,暂停所有调用
|
|
36
|
+
*/
|
|
37
|
+
const cardRateLimiter = {
|
|
38
|
+
/** 当前可用令牌数 */
|
|
39
|
+
tokens: CARD_API_MAX_QPS,
|
|
40
|
+
/** 上次令牌补充时间 */
|
|
41
|
+
lastRefillTime: Date.now(),
|
|
42
|
+
/** QPS 退避截止时间(遇到限流错误后设置) */
|
|
43
|
+
backoffUntil: 0,
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* 补充令牌:按时间流逝恢复令牌数
|
|
47
|
+
*/
|
|
48
|
+
refill(): void {
|
|
49
|
+
const now = Date.now();
|
|
50
|
+
const elapsedSeconds = (now - this.lastRefillTime) / 1000;
|
|
51
|
+
if (elapsedSeconds > 0) {
|
|
52
|
+
this.tokens = Math.min(
|
|
53
|
+
CARD_API_MAX_QPS,
|
|
54
|
+
this.tokens + elapsedSeconds * CARD_API_MAX_QPS,
|
|
55
|
+
);
|
|
56
|
+
this.lastRefillTime = now;
|
|
57
|
+
}
|
|
58
|
+
},
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* 等待直到有可用令牌,或退避期结束
|
|
62
|
+
* @returns 等待的毫秒数(0 表示无需等待)
|
|
63
|
+
*/
|
|
64
|
+
async waitForToken(): Promise<number> {
|
|
65
|
+
let totalWaitMs = 0;
|
|
66
|
+
|
|
67
|
+
// 如果处于退避期,先等待退避结束
|
|
68
|
+
const now = Date.now();
|
|
69
|
+
if (now < this.backoffUntil) {
|
|
70
|
+
const backoffWaitMs = this.backoffUntil - now;
|
|
71
|
+
await sleep(backoffWaitMs);
|
|
72
|
+
totalWaitMs += backoffWaitMs;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
this.refill();
|
|
76
|
+
|
|
77
|
+
// 如果没有可用令牌,等待直到有令牌
|
|
78
|
+
if (this.tokens < 1) {
|
|
79
|
+
const waitMs = Math.ceil((1 - this.tokens) / CARD_API_MAX_QPS * 1000);
|
|
80
|
+
await sleep(waitMs);
|
|
81
|
+
totalWaitMs += waitMs;
|
|
82
|
+
this.refill();
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
this.tokens -= 1;
|
|
86
|
+
return totalWaitMs;
|
|
87
|
+
},
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* 触发退避:遇到 QpsLimit 错误时调用
|
|
91
|
+
*/
|
|
92
|
+
triggerBackoff(): void {
|
|
93
|
+
this.backoffUntil = Date.now() + QPS_BACKOFF_DURATION_MS;
|
|
94
|
+
// 清空令牌,退避期结束后重新补充
|
|
95
|
+
this.tokens = 0;
|
|
96
|
+
this.lastRefillTime = Date.now() + QPS_BACKOFF_DURATION_MS;
|
|
97
|
+
},
|
|
98
|
+
};
|
|
99
|
+
|
|
100
|
+
/** 简单的 sleep 工具函数 */
|
|
101
|
+
function sleep(ms: number): Promise<void> {
|
|
102
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* 判断错误是否为钉钉 QPS 限流错误
|
|
107
|
+
*/
|
|
108
|
+
function isQpsLimitError(err: any): boolean {
|
|
109
|
+
const errorCode = err?.response?.data?.code;
|
|
110
|
+
return (
|
|
111
|
+
err?.response?.status === 403 &&
|
|
112
|
+
typeof errorCode === "string" &&
|
|
113
|
+
errorCode.includes("QpsLimit")
|
|
114
|
+
);
|
|
115
|
+
}
|
|
116
|
+
|
|
14
117
|
/** AI Card 状态 */
|
|
15
118
|
const AICardStatus = {
|
|
16
119
|
PROCESSING: "1",
|
|
@@ -204,6 +307,9 @@ async function ensureValidToken(
|
|
|
204
307
|
|
|
205
308
|
/**
|
|
206
309
|
* 流式更新 AI Card 内容
|
|
310
|
+
*
|
|
311
|
+
* 内置全局令牌桶限流:所有会话共享同一速率限制,
|
|
312
|
+
* 遇到 QpsLimit 错误时自动退避 2 秒后重试一次。
|
|
207
313
|
*/
|
|
208
314
|
export async function streamAICard(
|
|
209
315
|
card: AICardInstance,
|
|
@@ -212,11 +318,22 @@ export async function streamAICard(
|
|
|
212
318
|
config?: DingtalkConfig,
|
|
213
319
|
log?: any,
|
|
214
320
|
): Promise<void> {
|
|
321
|
+
// 防御 null card(createAICardForTarget 失败返回 null,调用方可能用 as any 绕过类型检查)
|
|
322
|
+
if (!card) {
|
|
323
|
+
log?.warn?.(`[DingTalk][AICard] streamAICard 收到 null card,跳过更新`);
|
|
324
|
+
return;
|
|
325
|
+
}
|
|
215
326
|
// 确保 token 有效
|
|
216
327
|
if (config) {
|
|
217
328
|
await ensureValidToken(card, config);
|
|
218
329
|
}
|
|
219
330
|
if (!card.inputingStarted) {
|
|
331
|
+
// 等待全局限流令牌(INPUTING 状态切换也消耗 QPS)
|
|
332
|
+
const inputingWaitMs = await cardRateLimiter.waitForToken();
|
|
333
|
+
if (inputingWaitMs > 0) {
|
|
334
|
+
log?.debug?.(`[DingTalk][AICard] INPUTING 等待限流令牌 ${inputingWaitMs}ms`);
|
|
335
|
+
}
|
|
336
|
+
|
|
220
337
|
const statusBody = {
|
|
221
338
|
outTrackId: card.cardInstanceId,
|
|
222
339
|
cardData: {
|
|
@@ -246,6 +363,10 @@ export async function streamAICard(
|
|
|
246
363
|
`[DingTalk][AICard] INPUTING 响应:status=${statusResp.status}`,
|
|
247
364
|
);
|
|
248
365
|
} catch (err: any) {
|
|
366
|
+
if (isQpsLimitError(err)) {
|
|
367
|
+
cardRateLimiter.triggerBackoff();
|
|
368
|
+
log?.warn?.(`[DingTalk][AICard] INPUTING 触发 QPS 限流,退避 ${QPS_BACKOFF_DURATION_MS}ms`);
|
|
369
|
+
}
|
|
249
370
|
log?.error?.(`[DingTalk][AICard] INPUTING 切换失败:${err.message}`);
|
|
250
371
|
throw err;
|
|
251
372
|
}
|
|
@@ -263,6 +384,12 @@ export async function streamAICard(
|
|
|
263
384
|
isError: false,
|
|
264
385
|
};
|
|
265
386
|
|
|
387
|
+
// 等待全局限流令牌
|
|
388
|
+
const streamWaitMs = await cardRateLimiter.waitForToken();
|
|
389
|
+
if (streamWaitMs > 0) {
|
|
390
|
+
log?.debug?.(`[DingTalk][AICard] streaming 等待限流令牌 ${streamWaitMs}ms`);
|
|
391
|
+
}
|
|
392
|
+
|
|
266
393
|
log?.info?.(
|
|
267
394
|
`[DingTalk][AICard] PUT /v1.0/card/streaming contentLen=${content.length} isFinalize=${finished}`,
|
|
268
395
|
);
|
|
@@ -281,6 +408,31 @@ export async function streamAICard(
|
|
|
281
408
|
`[DingTalk][AICard] streaming 响应:status=${streamResp.status}`,
|
|
282
409
|
);
|
|
283
410
|
} catch (err: any) {
|
|
411
|
+
if (isQpsLimitError(err)) {
|
|
412
|
+
// 触发退避后重试一次,确保 finalize 等关键更新不丢失
|
|
413
|
+
cardRateLimiter.triggerBackoff();
|
|
414
|
+
log?.warn?.(`[DingTalk][AICard] streaming 触发 QPS 限流,退避 ${QPS_BACKOFF_DURATION_MS}ms 后重试`);
|
|
415
|
+
await cardRateLimiter.waitForToken();
|
|
416
|
+
try {
|
|
417
|
+
// 重试时更新 guid 避免重复
|
|
418
|
+
body.guid = `${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
|
|
419
|
+
await dingtalkHttp.put(
|
|
420
|
+
`${DINGTALK_API}/v1.0/card/streaming`,
|
|
421
|
+
body,
|
|
422
|
+
{
|
|
423
|
+
headers: {
|
|
424
|
+
"x-acs-dingtalk-access-token": card.accessToken,
|
|
425
|
+
"Content-Type": "application/json",
|
|
426
|
+
},
|
|
427
|
+
},
|
|
428
|
+
);
|
|
429
|
+
log?.info?.(`[DingTalk][AICard] streaming 重试成功`);
|
|
430
|
+
return;
|
|
431
|
+
} catch (retryErr: any) {
|
|
432
|
+
log?.error?.(`[DingTalk][AICard] streaming 重试失败:${retryErr.message}`);
|
|
433
|
+
throw retryErr;
|
|
434
|
+
}
|
|
435
|
+
}
|
|
284
436
|
throw err;
|
|
285
437
|
}
|
|
286
438
|
}
|