@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 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.15",
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": ["dingtalk", "钉钉"],
101
+ "aliases": [
102
+ "dingtalk",
103
+ "钉钉"
104
+ ],
99
105
  "order": 33,
100
106
  "quickstartAllowFrom": true
101
107
  },
@@ -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 = 500; // 最小更新间隔 500ms(钉钉 QPS 限制:40 次/秒,保守起见设为 0.5 秒)
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
- // 安全检查:确保 code 存在且为字符串
608
- const errorCode = err.response?.data?.code;
609
- if (err.response?.status === 403 && typeof errorCode === 'string' && errorCode.includes('QpsLimit')) {
610
- // QPS 限流,跳过本次更新;同步更新节流时间,防止立即重试再次触发限流
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
  }