@dingtalk-real-ai/dingtalk-connector 0.7.9 → 0.7.10

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
@@ -1,10 +1,44 @@
1
1
  # Changelog
2
2
 
3
- 本文档记录所有重要的变更。格式基于 [Keep a Changelog](https://keepachangelog.com/zh-CN/1.0.0/),
4
- 版本号遵循 [Semantic Versioning](https://semver.org/lang/zh-CN/)。
3
+ All notable changes to this project will be documented in this file.
5
4
 
6
- This document records all significant changes. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
7
- and version numbers follow [Semantic Versioning](https://semver.org/).
5
+ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
6
+ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
+
8
+ ## [0.7.10] - 2026-03-16
9
+
10
+ ### 新增 / Added
11
+ - ✨ **WebSocket 心跳重连机制优化** - 关闭 DWClient 的 `autoConnect`,采用应用层自动重连机制(修复 DWClient 重连 bug);添加指数退避重连策略,避免雪崩效应;使用 WebSocket 原生 Ping 进行心跳检测
12
+ **WebSocket heartbeat & reconnect optimization** - Disabled DWClient's `autoConnect`, implemented app-layer auto-reconnect (fixing DWClient bug); added exponential backoff to avoid avalanche; using WebSocket native Ping for heartbeat
13
+ - ✨ **socket-manager 模块** - 新增模块统一管理 WebSocket 连接生命周期,包括心跳检测、自动重连、指数退避、事件监听等
14
+ **socket-manager module** - New module for unified WebSocket connection lifecycle management, including heartbeat, auto-reconnect, exponential backoff, event listening
15
+ - ✨ **debug 参数** - 添加 `debug` 配置项控制详细日志输出,便于问题排查
16
+ **debug parameter** - Added `debug` config to control detailed log output for easier troubleshooting
17
+ - ✨ **WebSocket 无限重连机制** - 移除最大重连次数限制,实现无限重连,确保长连接服务的高可用性
18
+ **WebSocket infinite reconnection** - Removed maximum reconnection attempt limit, implemented infinite reconnection to ensure high availability for long-lived connection services
19
+
20
+ ### 修复 / Fixes
21
+ - 🐛 **修复 DWClient 重连 bug** - DWClient 内置重连机制存在缺陷,通过应用层重连机制替代,确保连接稳定可靠
22
+ **Fixed DWClient reconnect bug** - DWClient's built-in reconnect has defects; replaced with app-layer reconnect for stable connection
23
+ - 🐛 **长连接静默断开** - 通过应用层心跳检测连接活性,超时后主动重连,减少因长时间无数据导致的静默断连且无法恢复
24
+ **Long-lived connection silent disconnect** - App-layer heartbeat detects liveness and triggers reconnect on timeout, reducing silent disconnects when idle
25
+ ### 改进 / Improvements
26
+ - ✅ **DWClient 配置** - 设置 `autoReconnect: false`、`keepAlive: false`,由应用层接管重连和心跳,避免与钉钉服务端策略冲突
27
+ **DWClient config** - Set `autoReconnect: false`, `keepAlive: false`; app-layer takes over reconnect and heartbeat to avoid server conflicts
28
+ - ✅ **指数退避策略** - 公式 `baseBackoffDelay * Math.pow(2, attempt) + jitter(0-1s)`,最大退避 30 秒,避免雪崩效应
29
+ **Exponential backoff strategy** - Formula `baseBackoffDelay * Math.pow(2, attempt) + jitter(0-1s)`, max 30s backoff to avoid avalanche effect
30
+ - ✅ **统一事件监听** - `pong`、`message`、`close`、`open` 四个事件统一管理和清理,提升代码可维护性
31
+ **Unified event listening** - `pong`, `message`, `close`, `open` events managed and cleaned up uniformly, improving maintainability
32
+ - ✅ **配置简化** - 从 `SocketManagerConfig` 中移除 `maxReconnectAttempts` 配置项,简化配置复杂度
33
+ **Configuration simplification** - Removed `maxReconnectAttempts` from `SocketManagerConfig`, simplifying configuration
34
+ - ✅ **日志输出优化** - 更新重连日志格式,移除最大次数显示(从 "尝试 X/5" 改为 "尝试 X")
35
+ **Log output optimization** - Updated reconnection log format, removed maximum attempt display (from "attempt X/5" to "attempt X")
36
+
37
+ ### 技术细节 / Technical Details
38
+ - **退避策略**:指数退避 + 随机抖动,公式 `baseBackoffDelay * Math.pow(2, attempt) + jitter(0-1s)`
39
+ - **最大退避**:30 秒(由 `maxBackoffDelay` 限制)
40
+ - **重置条件**:重连成功后 `reconnectAttempts` 归零
41
+ - **立即重连**:心跳检测失败、WebSocket close、disconnect 消息触发时立即重连,不退避
8
42
 
9
43
  ## [0.7.9] - 2026-03-13
10
44
 
@@ -243,5 +277,4 @@ and version numbers follow [Semantic Versioning](https://semver.org/).
243
277
  - 新增"多 Agent 配置"章节,提供详细的配置示例和说明
244
278
  Added "Multi-Agent Configuration" section with detailed configuration examples and instructions
245
279
  - 补充常见问题解答
246
- Added FAQ section
247
-
280
+ Added FAQ section
package/README.md CHANGED
@@ -3,7 +3,7 @@
3
3
 
4
4
  以下提供两种方案连接到 [OpenClaw](https://openclaw.ai) Gateway,分别是钉钉机器人和钉钉 DEAP Agent。
5
5
 
6
- > 📝 **版本信息**:当前版本 v0.7.7 | [查看变更日志](CHANGELOG.md) | [发布说明](docs/RELEASE_NOTES_V0.7.7.md) | [发布指南](RELEASE.md)
6
+ > 📝 **版本信息**:当前版本 v0.7.10 | [查看变更日志](CHANGELOG.md) | [发布说明](docs/RELEASE_NOTES_V0.7.10.md) | [发布指南](RELEASE.md)
7
7
 
8
8
  ## 快速导航
9
9
 
@@ -0,0 +1,40 @@
1
+ # Release Notes - v0.7.10
2
+
3
+ ## ✨ 功能与体验改进 / Features & Improvements
4
+
5
+ - **WebSocket 心跳重连机制优化 / WebSocket Reconnection**
6
+ WebSocket 会持续尝试重连,保留指数退避策略(1s → 2s → 4s → 8s → 16s → 30s),避免雪崩效应。重连成功后重置计数器,下次失败从 1 秒开始退避。
7
+ Removed maximum reconnection attempt limit, implemented infinite reconnection mechanism. WebSocket will continuously attempt to reconnect without giving up. Exponential backoff strategy retained (1s → 2s → 4s → 8s → 16s → 30s) to avoid avalanche effect. Counter resets on successful reconnection, starting from 1 second on next failure.
8
+
9
+ - **配置简化 / Configuration Simplification**
10
+ 从 `SocketManagerConfig` 中移除 `maxReconnectAttempts` 配置项,简化配置复杂度。
11
+ Removed `maxReconnectAttempts` configuration from `SocketManagerConfig`, simplifying configuration complexity.
12
+
13
+ - **日志输出优化 / Log Output Optimization**
14
+ 更新重连日志格式,移除最大次数显示(从 "尝试 X/5" 改为 "尝试 X"),更清晰地展示重连进度。
15
+ Updated reconnection log format, removed maximum attempt display (from "attempt X/5" to "attempt X"), providing clearer reconnection progress.
16
+
17
+
18
+ ## 📥 安装升级 / Installation & Upgrade
19
+
20
+ ```bash
21
+ # 通过 npm 安装最新版本 / Install latest version via npm
22
+ openclaw plugins install @dingtalk-real-ai/dingtalk-connector
23
+
24
+ # 或升级现有版本 / Or upgrade existing version
25
+ openclaw plugins update dingtalk-connector
26
+
27
+ # 通过 Git 安装 / Install via Git
28
+ openclaw plugins install https://github.com/DingTalk-Real-AI/dingtalk-openclaw-connector.git
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-03-16
39
+ **版本号 / Version**:v0.7.10
40
+ **兼容性 / Compatibility**:OpenClaw Gateway 0.4.0+
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "id": "dingtalk-connector",
3
3
  "name": "DingTalk Channel",
4
- "version": "0.7.9",
4
+ "version": "0.7.10",
5
5
  "description": "DingTalk (钉钉) messaging channel via Stream mode with AI Card streaming",
6
6
  "author": "DingTalk Real Team",
7
7
  "channels": ["dingtalk-connector"],
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dingtalk-real-ai/dingtalk-connector",
3
- "version": "0.7.9",
3
+ "version": "0.7.10",
4
4
  "description": "DingTalk (钉钉) channel connector — Stream mode with AI Card streaming",
5
5
  "main": "plugin.ts",
6
6
  "type": "module",
package/plugin.ts CHANGED
@@ -11,6 +11,7 @@ import * as fs from 'fs';
11
11
  import * as path from 'path';
12
12
  import * as os from 'os';
13
13
  import type { ClawdbotPluginApi, PluginRuntime, ClawdbotConfig } from 'clawdbot/plugin-sdk';
14
+ import { createSocketManager, addToPendingAckQueue, removeFromPendingAckQueue, clearPendingAckQueue } from './src/socket-manager';
14
15
 
15
16
  // ============ 常量 ============
16
17
 
@@ -1263,6 +1264,7 @@ async function finishAICard(
1263
1264
  order: ['msgContent'], // 只声明实际使用的字段,避免部分客户端显示空占位
1264
1265
  }),
1265
1266
  },
1267
+ cardUpdateOptions:{updateCardDataByKey:true}
1266
1268
  },
1267
1269
  };
1268
1270
 
@@ -1469,47 +1471,64 @@ async function* streamFromGateway(options: GatewayOptions, accountId: string): A
1469
1471
 
1470
1472
  log?.info?.(`[DingTalk][Gateway] POST ${gatewayUrl}, session=${sessionKey}, accountId=${accountId}, agentId=${agentId}, peerKind=${peerKind}, messages=${messages.length}`);
1471
1473
 
1472
- const response = await fetch(gatewayUrl, {
1473
- method: 'POST',
1474
- headers,
1475
- body: JSON.stringify({
1476
- model: 'main',
1477
- messages,
1478
- stream: true,
1479
- user: sessionKey, // 用于 session 持久化
1480
- }),
1481
- });
1474
+ // 【TLS 模式修复】保存原始 TLS 设置,用于 finally 块中恢复
1475
+ const originalRejectUnauthorized = process.env.NODE_TLS_REJECT_UNAUTHORIZED;
1476
+
1477
+ try {
1478
+ // TLS 模式:如果是 HTTPS URL,临时禁用证书验证(用于自签名证书场景)
1479
+ if (gatewayUrl.startsWith('https://')) {
1480
+ process.env.NODE_TLS_REJECT_UNAUTHORIZED = '0';
1481
+ log?.debug?.(`[DingTalk][Gateway] TLS 模式:已临时禁用证书验证`);
1482
+ }
1482
1483
 
1483
- log?.info?.(`[DingTalk][Gateway] 响应 status=${response.status}, ok=${response.ok}, hasBody=${!!response.body}`);
1484
+ const response = await fetch(gatewayUrl, {
1485
+ method: 'POST',
1486
+ headers,
1487
+ body: JSON.stringify({
1488
+ model: 'main',
1489
+ messages,
1490
+ stream: true,
1491
+ user: sessionKey, // 用于 session 持久化
1492
+ }),
1493
+ });
1484
1494
 
1485
- if (!response.ok || !response.body) {
1486
- const errText = response.body ? await response.text() : '(no body)';
1487
- log?.error?.(`[DingTalk][Gateway] 错误响应: ${errText}`);
1488
- throw new Error(`Gateway error: ${response.status} - ${errText}`);
1489
- }
1495
+ log?.info?.(`[DingTalk][Gateway] 响应 status=${response.status}, ok=${response.ok}, hasBody=${!!response.body}`);
1490
1496
 
1491
- const reader = response.body.getReader();
1492
- const decoder = new TextDecoder();
1493
- let buffer = '';
1497
+ if (!response.ok || !response.body) {
1498
+ const errText = response.body ? await response.text() : '(no body)';
1499
+ log?.error?.(`[DingTalk][Gateway] 错误响应:${errText}`);
1500
+ throw new Error(`Gateway error: ${response.status} - ${errText}`);
1501
+ }
1494
1502
 
1495
- while (true) {
1496
- const { done, value } = await reader.read();
1497
- if (done) break;
1503
+ const reader = response.body.getReader();
1504
+ const decoder = new TextDecoder();
1505
+ let buffer = '';
1498
1506
 
1499
- buffer += decoder.decode(value, { stream: true });
1500
- const lines = buffer.split('\n');
1501
- buffer = lines.pop() || '';
1507
+ while (true) {
1508
+ const { done, value } = await reader.read();
1509
+ if (done) break;
1502
1510
 
1503
- for (const line of lines) {
1504
- if (!line.startsWith('data: ')) continue;
1505
- const data = line.slice(6).trim();
1506
- if (data === '[DONE]') return;
1511
+ buffer += decoder.decode(value, { stream: true });
1512
+ const lines = buffer.split('\n');
1513
+ buffer = lines.pop() || '';
1507
1514
 
1508
- try {
1509
- const chunk = JSON.parse(data);
1510
- const content = chunk.choices?.[0]?.delta?.content;
1511
- if (content) yield content;
1512
- } catch {}
1515
+ for (const line of lines) {
1516
+ if (!line.startsWith('data: ')) continue;
1517
+ const data = line.slice(6).trim();
1518
+ if (data === '[DONE]') return;
1519
+
1520
+ try {
1521
+ const chunk = JSON.parse(data);
1522
+ const content = chunk.choices?.[0]?.delta?.content;
1523
+ if (content) yield content;
1524
+ } catch {}
1525
+ }
1526
+ }
1527
+ } finally {
1528
+ // 【TLS 模式修复】恢复原始 TLS 证书验证设置,避免影响其他 HTTPS 请求
1529
+ if (gatewayUrl.startsWith('https://')) {
1530
+ process.env.NODE_TLS_REJECT_UNAUTHORIZED = originalRejectUnauthorized;
1531
+ log?.debug?.(`[DingTalk][Gateway] TLS 模式:已恢复证书验证设置`);
1513
1532
  }
1514
1533
  }
1515
1534
  }
@@ -1864,7 +1883,11 @@ async function createAICardForTarget(
1864
1883
  const createBody = {
1865
1884
  cardTemplateId: AI_CARD_TEMPLATE_ID,
1866
1885
  outTrackId: cardInstanceId,
1867
- cardData: { cardParamMap: {} },
1886
+ cardData: {
1887
+ cardParamMap: {
1888
+ config: JSON.stringify({ autoLayout: true }), // 启用宽屏模式
1889
+ },
1890
+ },
1868
1891
  callbackType: 'STREAM',
1869
1892
  imGroupOpenSpaceModel: { supportForward: true },
1870
1893
  imRobotOpenSpaceModel: { supportForward: true },
@@ -2950,7 +2973,7 @@ async function handleDingTalkMessage(params: {
2950
2973
  const finalContent = accumulated.trim();
2951
2974
  if (finalContent.length === 0) {
2952
2975
  log?.info?.(`[DingTalk][AICard] 内容为空(纯媒体消息),使用默认提示`);
2953
- await finishAICard(card, '✅ 媒体已发送', log);
2976
+ await finishAICard(card, '当前没有可展示的回复内容', log);
2954
2977
  } else {
2955
2978
  await finishAICard(card, finalContent, log);
2956
2979
  }
@@ -3550,27 +3573,49 @@ const dingtalkPlugin = {
3550
3573
  }
3551
3574
 
3552
3575
  ctx.log?.info(`[${account.accountId}] 启动钉钉 Stream 客户端...`);
3576
+ ctx.log?.info(`[${account.accountId}] 配置信息:clientId=${config.clientId}, endpoint=${config.endpoint || '默认'}`);
3553
3577
 
3554
- // 配置 DWClient:关闭 SDK 内置的 keepAlive,使用应用层自定义心跳
3555
- // - autoReconnect: 连接断开时自动重连
3578
+ // 配置 DWClient:关闭 SDK 内置的 keepAlive 和 autoReconnect,使用应用层自定义心跳和重连
3579
+ // - autoReconnect: false(关闭 SDK 的自动重连,避免与应用层重连冲突)
3556
3580
  // - keepAlive: false(关闭 SDK 的激进心跳检测,避免 8 秒超时强制终止连接)
3581
+ // - endpoint: 可选,自定义钉钉 API 网关地址,默认使用 SDK 内置的 https://api.dingtalk.com/v1.0/gateway/connections/open
3557
3582
  const client = new DWClient({
3558
3583
  clientId: config.clientId,
3559
3584
  clientSecret: config.clientSecret,
3560
3585
  debug: config.debug || false,
3561
- autoReconnect: true,
3586
+ autoReconnect: false, // ← 关闭 SDK 的自动重连,使用应用层重连
3562
3587
  keepAlive: false,
3588
+ // ← 可选:自定义 endpoint,如使用内网代理或测试环境。如果不配置或配置错误,使用 SDK 默认值
3589
+ ...(config.endpoint ? { endpoint: config.endpoint } : {}),
3563
3590
  } as any);
3564
3591
 
3592
+ ctx.log?.info(`[${account.accountId}] DWClient 初始化完成,endpoint=${client.getConfig()?.endpoint || '默认'}`);
3593
+
3565
3594
  client.registerCallbackListener(TOPIC_ROBOT, async (res: any) => {
3566
3595
  const messageId = res.headers?.messageId;
3567
3596
  ctx.log?.info?.(`[DingTalk] 收到 Stream 回调, messageId=${messageId}, headers=${JSON.stringify(res.headers)}`);
3568
3597
 
3569
- // 【关键修复】立即确认回调,避免钉钉服务器因超时而重发
3570
- // 钉钉 Stream 模式要求及时响应,否则约60秒后会重发消息
3598
+ // 【关键修复】检查 WebSocket 状态后再确认回调,避免在 CONNECTING 状态下发送失败
3571
3599
  if (messageId) {
3572
- client.socketCallBackResponse(messageId, { success: true });
3573
- ctx.log?.info?.(`[DingTalk] 已立即确认回调: messageId=${messageId}`);
3600
+ if (client.socket?.readyState === 1) { // 1 = OPEN
3601
+ client.socketCallBackResponse(messageId, { success: true });
3602
+ ctx.log?.info?.(`[DingTalk] 已立即确认回调:messageId=${messageId}`);
3603
+ } else {
3604
+ ctx.log?.warn?.(`[DingTalk] WebSocket 未就绪 (readyState=${client.socket?.readyState}),延迟确认回调:messageId=${messageId}`);
3605
+ // 【关键修复】将消息 ID 加入待确认队列,等待 WebSocket 打开后批量确认
3606
+ pendingAckQueue.add(messageId);
3607
+ // 等待 WebSocket 打开后再确认(兼容旧逻辑,但主要依赖 open 事件)
3608
+ setTimeout(() => {
3609
+ if (client.socket?.readyState === 1) {
3610
+ client.socketCallBackResponse(messageId, { success: true });
3611
+ pendingAckQueue.delete(messageId); // 确认成功后从队列移除
3612
+ ctx.log?.info?.(`[DingTalk] 延迟确认回调成功:messageId=${messageId}`);
3613
+ } else {
3614
+ ctx.log?.warn?.(`[DingTalk] 延迟确认回调失败:WebSocket 仍未就绪,messageId=${messageId},将在 open 事件中批量确认`);
3615
+ // 不再从队列移除,等待 open 事件处理
3616
+ }
3617
+ }, 500); // 【关键修复】增加延迟时间到 500ms,确保 WebSocket 有足够时间打开
3618
+ }
3574
3619
  }
3575
3620
 
3576
3621
  // 【消息去重】检查是否已处理过该消息
@@ -3604,6 +3649,19 @@ const dingtalkPlugin = {
3604
3649
  });
3605
3650
 
3606
3651
  await client.connect();
3652
+
3653
+ // 【关键修复】等待 WebSocket 完全打开后再继续
3654
+ // 避免 Gateway 重启后 WebSocket 还在 CONNECTING 状态就开始处理消息
3655
+ if (client.socket) {
3656
+ if (client.socket.readyState !== 1) { // 1 = OPEN
3657
+ ctx.log?.info?.(`[${account.accountId}] 等待 WebSocket 打开...`);
3658
+ await new Promise((resolve) => {
3659
+ client.socket!.once('open', resolve);
3660
+ setTimeout(resolve, 5000); // 最多等 5 秒
3661
+ });
3662
+ }
3663
+ }
3664
+
3607
3665
  ctx.log?.info(`[${account.accountId}] 钉钉 Stream 客户端已连接`);
3608
3666
 
3609
3667
  const rt = getRuntime();
@@ -3611,92 +3669,25 @@ const dingtalkPlugin = {
3611
3669
 
3612
3670
  let stopped = false;
3613
3671
 
3614
- // 【应用层心跳机制】基于 WebSocket ping/pong 的主动心跳检测
3615
- // - 心跳间隔:30
3616
- // - 超时时间:90 秒(允许 3 次 ping 无响应)
3617
- // - 检测方式:主动发送 ping,等待 pong 响应
3618
- let lastPongTime = Date.now();
3619
- let pendingPingId: string | null = null;
3620
- const HEARTBEAT_INTERVAL = 30 * 1000; // 30 秒
3621
- const HEARTBEAT_TIMEOUT = 90 * 1000; // 90 秒
3672
+ // 【关键修复】待确认消息队列:重连期间暂存需要确认的消息 ID
3673
+ const pendingAckQueue = new Set<string>();
3622
3674
 
3623
- // 监听 pong 响应(SDK keepAlive=false 时仍然会收到服务端的 pong)
3624
- client.socket?.on('pong', () => {
3625
- lastPongTime = Date.now();
3626
- pendingPingId = null;
3627
- ctx.log?.debug?.(`[${account.accountId}] 收到 PONG 响应`);
3675
+ // 【使用 SocketManager 统一管理 WebSocket 连接、心跳、重连】
3676
+ const debugMode = config.debug || false;
3677
+ const socketManager = createSocketManager(client, {
3678
+ accountId: account.accountId,
3679
+ log: ctx.log,
3680
+ stopped: () => stopped,
3681
+ onReconnect: () => {
3682
+ // 重连成功后的回调(如果需要)
3683
+ },
3684
+ pendingAckQueue,
3685
+ client,
3686
+ debug: debugMode,
3628
3687
  });
3629
3688
 
3630
- // 启动心跳检测定时器
3631
- const heartbeatTimer = setInterval(async () => {
3632
- if (stopped) {
3633
- clearInterval(heartbeatTimer);
3634
- return;
3635
- }
3636
-
3637
- const elapsed = Date.now() - lastPongTime;
3638
-
3639
- // 如果超过 90 秒没有收到 pong,认为连接已断开
3640
- if (elapsed > HEARTBEAT_TIMEOUT) {
3641
- ctx.log?.warn?.(`[${account.accountId}] ⚠️ 心跳超时:已 ${Math.round(elapsed / 1000)} 秒未收到 PONG,触发重连...`);
3642
-
3643
- // 【关键修复】主动重连:先断开再重新建立连接
3644
- try {
3645
- // 1. 先断开旧连接
3646
- await client.disconnect();
3647
- ctx.log?.info?.(`[${account.accountId}] 已断开旧连接`);
3648
-
3649
- // 2. 重新建立连接
3650
- ctx.log?.info?.(`[${account.accountId}] 正在重新建立连接...`);
3651
- await client.connect();
3652
-
3653
- // 3. 重置最后 pong 时间,避免立即再次触发重连
3654
- lastPongTime = Date.now();
3655
- pendingPingId = null;
3656
-
3657
- ctx.log?.info?.(`[${account.accountId}] ✅ 重连成功`);
3658
- } catch (err: any) {
3659
- ctx.log?.error?.(`[${account.accountId}] ❌ 重连失败:${err.message}`);
3660
- // 重连失败后,等待 5 秒后再次尝试
3661
- ctx.log?.info?.(`[${account.accountId}] 5 秒后再次尝试重连...`);
3662
- setTimeout(async () => {
3663
- try {
3664
- await client.connect();
3665
- lastPongTime = Date.now();
3666
- pendingPingId = null;
3667
- ctx.log?.info?.(`[${account.accountId}] ✅ 重试重连成功`);
3668
- } catch (retryErr: any) {
3669
- ctx.log?.error?.(`[${account.accountId}] ❌ 重试重连失败:${retryErr.message}`);
3670
- }
3671
- }, 5000);
3672
- }
3673
- return;
3674
- }
3675
-
3676
- // 如果还有 ping 在等待响应,检查是否超时
3677
- if (pendingPingId) {
3678
- ctx.log?.debug?.(`[${account.accountId}] 心跳检测:等待 PONG 响应中...`);
3679
- return;
3680
- }
3681
-
3682
- // 主动发送 ping 消息
3683
- try {
3684
- const pingId = `ping_${Date.now()}`;
3685
- pendingPingId = pingId;
3686
-
3687
- // 通过 WebSocket 直接发送 ping(使用 SDK 的 socket)
3688
- client.socket?.ping(JSON.stringify({
3689
- type: 'PING',
3690
- id: pingId,
3691
- timestamp: Date.now()
3692
- }));
3693
-
3694
- ctx.log?.debug?.(`[${account.accountId}] 发送 PING 请求:${pingId}`);
3695
- } catch (err: any) {
3696
- ctx.log?.error?.(`[${account.accountId}] 发送 PING 失败:${err.message}`);
3697
- // 发送失败也计入超时
3698
- }
3699
- }, HEARTBEAT_INTERVAL);
3689
+ // 启动 keepAlive 机制
3690
+ const stopKeepAlive = socketManager.startKeepAlive();
3700
3691
 
3701
3692
  // 统一的停止逻辑
3702
3693
  const doStop = (reason: string) => {
@@ -3704,12 +3695,14 @@ const dingtalkPlugin = {
3704
3695
  stopped = true;
3705
3696
  ctx.log?.info(`[${account.accountId}] 停止钉钉 Stream 客户端 (${reason})...`);
3706
3697
 
3707
- // 清理心跳定时器
3708
- if (typeof heartbeatTimer !== 'undefined') {
3709
- clearInterval(heartbeatTimer);
3710
- ctx.log?.debug?.(`[${account.accountId}] 心跳定时器已清理`);
3698
+ // 清理 keepAlive 定时器
3699
+ if (typeof stopKeepAlive === 'function') {
3700
+ stopKeepAlive();
3711
3701
  }
3712
3702
 
3703
+ // 清理 SocketManager
3704
+ socketManager.stop();
3705
+
3713
3706
  try {
3714
3707
  // 【关键】调用 disconnect() 正确关闭 WebSocket 连接
3715
3708
  client.disconnect();
@@ -4052,7 +4045,6 @@ const plugin = {
4052
4045
  respond(true, { docs });
4053
4046
  });
4054
4047
 
4055
- api.logger?.info('[DingTalk] 插件已注册(支持主动发送 AI Card 消息、文档读写)');
4056
4048
  },
4057
4049
  };
4058
4050
 
@@ -0,0 +1,312 @@
1
+ /**
2
+ * DingTalk WebSocket Socket 管理器
3
+ *
4
+ * 负责管理 DingTalk Stream 客户端的 WebSocket 连接、心跳检测、自动重连等生命周期
5
+ */
6
+
7
+ import type { DWClient } from 'dingtalk-stream';
8
+
9
+ export interface SocketManagerOptions {
10
+ accountId: string;
11
+ log?: any;
12
+ stopped: () => boolean;
13
+ onReconnect?: () => void;
14
+ pendingAckQueue?: Set<string>; // 待确认消息队列
15
+ client?: any; // DWClient 实例,用于批量确认消息
16
+ debug?: boolean; // 是否开启 debug 日志
17
+ }
18
+
19
+ export interface SocketManagerConfig {
20
+ /** 心跳间隔(毫秒) */
21
+ heartbeatInterval: number;
22
+ /** 超时阈值(毫秒) */
23
+ timeoutThreshold: number;
24
+ /** 基础退避时间(毫秒) */
25
+ baseBackoffDelay: number;
26
+ /** 最大退避时间(毫秒) */
27
+ maxBackoffDelay: number;
28
+ }
29
+
30
+ export interface SocketManager {
31
+ /** 启动 keepAlive 机制,返回清理函数 */
32
+ startKeepAlive: () => () => void;
33
+ /** 停止并清理所有资源 */
34
+ stop: () => void;
35
+ }
36
+
37
+ /**
38
+ * 创建 Socket 管理器
39
+ *
40
+ * @param client - DingTalk Stream 客户端实例
41
+ * @param options - 配置选项
42
+ * @returns SocketManager 实例
43
+ */
44
+ export function createSocketManager(
45
+ client: DWClient,
46
+ options: SocketManagerOptions
47
+ ): SocketManager {
48
+ const { accountId, log, stopped, onReconnect, pendingAckQueue: externalPendingAckQueue, client: externalClient, debug = false } = options;
49
+
50
+ // 使用传入的 pendingAckQueue 或创建新的
51
+ const pendingAckQueue = externalPendingAckQueue || new Set<string>();
52
+ const targetClient = externalClient || client;
53
+
54
+ // 【业界最佳实践配置】
55
+ const config: SocketManagerConfig = {
56
+ heartbeatInterval: 10 * 1000, // 10 秒心跳间隔
57
+ timeoutThreshold: 90 * 1000, // 90 秒超时阈值
58
+ baseBackoffDelay: 1000, // 基础退避 1 秒
59
+ maxBackoffDelay: 30 * 1000, // 最大退避 30 秒
60
+ };
61
+
62
+ // 日志辅助函数
63
+ const debugLog = (...args: any[]) => {
64
+ if (debug && log) {
65
+ log?.info?.(...args);
66
+ }
67
+ };
68
+
69
+ // 状态管理
70
+ let lastSocketAvailableTime = Date.now();
71
+ let isReconnecting = false;
72
+ let reconnectAttempts = 0;
73
+
74
+ // 定时器引用
75
+ let keepAliveTimer: NodeJS.Timeout | null = null;
76
+
77
+ /**
78
+ * 计算指数退避延迟(带抖动)
79
+ */
80
+ function calculateBackoffDelay(attempt: number): number {
81
+ const exponentialDelay = config.baseBackoffDelay * Math.pow(2, attempt);
82
+ const jitter = Math.random() * 1000; // 0-1 秒随机抖动
83
+ return Math.min(exponentialDelay + jitter, config.maxBackoffDelay);
84
+ }
85
+
86
+ /**
87
+ * 统一重连函数,带指数退避(无限重连)
88
+ */
89
+ async function doReconnect(immediate = false) {
90
+ if (isReconnecting) {
91
+ log?.debug?.(`[${accountId}] 正在重连中,跳过`);
92
+ return;
93
+ }
94
+
95
+ isReconnecting = true;
96
+
97
+ // 应用指数退避(非立即重连时)
98
+ if (!immediate && reconnectAttempts > 0) {
99
+ const delay = calculateBackoffDelay(reconnectAttempts);
100
+ log?.info?.(`[${accountId}] ⏳ 等待 ${Math.round(delay / 1000)} 秒后重连 (尝试 ${reconnectAttempts + 1})`);
101
+ await new Promise(resolve => setTimeout(resolve, delay));
102
+ }
103
+
104
+ try {
105
+ // 1. 先断开旧连接(检查 WebSocket 状态)
106
+ if (client.socket?.readyState === 1 || client.socket?.readyState === 3) {
107
+ await client.disconnect();
108
+ log?.info?.(`[${accountId}] 已断开旧连接`);
109
+ }
110
+
111
+ // 2. 重新建立连接
112
+ await client.connect();
113
+
114
+ // 3. 重置 socket 可用时间和重连计数
115
+ lastSocketAvailableTime = Date.now();
116
+ reconnectAttempts = 0; // 重连成功,重置计数
117
+
118
+ log?.info?.(`[${accountId}] ✅ 重连成功`);
119
+
120
+ // 调用外部回调
121
+ if (onReconnect) {
122
+ onReconnect();
123
+ }
124
+ } catch (err: any) {
125
+ reconnectAttempts++;
126
+ log?.error?.(`[${accountId}] 重连失败:${err.message} (尝试 ${reconnectAttempts})`);
127
+ throw err;
128
+ } finally {
129
+ isReconnecting = false;
130
+ }
131
+ }
132
+
133
+ /**
134
+ * 监听 pong 响应(更新 socket 可用时间)
135
+ */
136
+ function setupPongListener() {
137
+ client.socket?.on('pong', () => {
138
+ lastSocketAvailableTime = Date.now();
139
+ log?.debug?.(`[${accountId}] 收到 PONG 响应`);
140
+ });
141
+ }
142
+
143
+ /**
144
+ * 监听 WebSocket message 事件,收到 disconnect 消息时立即触发重连
145
+ */
146
+ function setupMessageListener() {
147
+ client.socket?.on('message', (data: any) => {
148
+ try {
149
+ const msg = JSON.parse(data);
150
+ if (msg.type === 'SYSTEM' && msg.headers?.topic === 'disconnect') {
151
+ if (!stopped() && !isReconnecting) {
152
+ // 立即重连,不退避
153
+ doReconnect(true).catch(err => {
154
+ log?.error?.(`[${accountId}] 重连失败:${err.message}`);
155
+ });
156
+ }
157
+ }
158
+ } catch (e) {
159
+ // 忽略解析错误
160
+ }
161
+ });
162
+ }
163
+
164
+ /**
165
+ * 监听 WebSocket close 事件,服务端主动断开时立即触发重连
166
+ */
167
+ function setupCloseListener() {
168
+ client.socket?.on('close', (code, reason) => {
169
+ log?.info?.(`[${accountId}] WebSocket close: code=${code}, reason=${reason || '未知'}, stopped=${stopped()}`);
170
+
171
+ if (stopped()) {
172
+ return;
173
+ }
174
+
175
+ // 立即重连,不退避
176
+ setTimeout(() => {
177
+ doReconnect(true).catch(err => {
178
+ log?.error?.(`[${accountId}] 重连失败:${err.message}`);
179
+ });
180
+ }, 0);
181
+ });
182
+ }
183
+
184
+ /**
185
+ * 监听 WebSocket open 事件,批量确认重连期间积压的消息
186
+ */
187
+ function setupOpenListener() {
188
+ client.socket?.on('open', () => {
189
+ if (pendingAckQueue.size > 0) {
190
+ log?.info?.(`[${accountId}] WebSocket 已打开,批量确认 ${pendingAckQueue.size} 条积压消息`);
191
+ for (const msgId of pendingAckQueue) {
192
+ try {
193
+ targetClient.socketCallBackResponse(msgId, { success: true });
194
+ log?.info?.(`[DingTalk] 批量确认成功:messageId=${msgId}`);
195
+ } catch (err: any) {
196
+ log?.error?.(`[DingTalk] 批量确认失败:messageId=${msgId}, error=${err.message}`);
197
+ }
198
+ }
199
+ pendingAckQueue.clear();
200
+ }
201
+ });
202
+ }
203
+
204
+ /**
205
+ * 启动 keepAlive 机制(单定时器 + 指数退避)
206
+ *
207
+ * 业界最佳实践:
208
+ * - 单定时器:每 10 秒检查一次,同时完成心跳和超时检测
209
+ * - 使用 WebSocket 原生 Ping
210
+ * - 指数退避重连:避免雪崩效应
211
+ */
212
+ function startKeepAlive(): () => void {
213
+ debugLog(`[${accountId}] 🚀 启动 keepAlive 定时器,间隔=${config.heartbeatInterval / 1000}秒`);
214
+
215
+ keepAliveTimer = setInterval(async () => {
216
+ if (stopped()) {
217
+ if (keepAliveTimer) clearInterval(keepAliveTimer);
218
+ return;
219
+ }
220
+
221
+ try {
222
+ const elapsed = Date.now() - lastSocketAvailableTime;
223
+
224
+ // 【超时检测】超过 90 秒未确认 socket 可用,触发重连
225
+ if (elapsed > config.timeoutThreshold) {
226
+ log?.info?.(`[${accountId}] ⚠️ 超时检测:已 ${Math.round(elapsed / 1000)} 秒未确认 socket 可用,触发重连...`);
227
+ await doReconnect();
228
+ return;
229
+ }
230
+
231
+ // 【心跳检测】检查 socket 状态
232
+ const socketState = client.socket?.readyState;
233
+ debugLog(`[${accountId}] 🔍 心跳检测:socket 状态=${socketState}, elapsed=${Math.round(elapsed / 1000)}s`);
234
+
235
+ if (socketState !== 1) {
236
+ log?.info?.(`[${accountId}] ⚠️ 心跳检测:socket 状态=${socketState},触发重连...`);
237
+ await doReconnect(true); // 立即重连,不退避
238
+ return;
239
+ }
240
+
241
+ // 【发送原生 Ping】更新可用时间
242
+ try {
243
+ client.socket?.ping();
244
+ lastSocketAvailableTime = Date.now();
245
+ debugLog(`[${accountId}] 💓 发送 PING 心跳成功`);
246
+ } catch (err: any) {
247
+ log?.warn?.(`[${accountId}] 发送 PING 失败:${err.message}`);
248
+ // 发送失败也计入超时
249
+ }
250
+ } catch (err: any) {
251
+ log?.error?.(`[${accountId}] keepAlive 检测失败:${err.message}`);
252
+ }
253
+ }, config.heartbeatInterval); // 每 10 秒检测一次
254
+
255
+ debugLog(`[${accountId}] ✅ keepAlive 定时器已启动`);
256
+
257
+ // 返回清理函数
258
+ return () => {
259
+ if (keepAliveTimer) clearInterval(keepAliveTimer);
260
+ keepAliveTimer = null;
261
+ debugLog(`[${accountId}] keepAlive 定时器已清理`);
262
+ };
263
+ }
264
+
265
+ /**
266
+ * 停止并清理所有资源
267
+ */
268
+ function stop() {
269
+ // 清理定时器
270
+ if (keepAliveTimer) clearInterval(keepAliveTimer);
271
+ keepAliveTimer = null;
272
+
273
+ // 清理事件监听器(WebSocket 会自动清理)
274
+ if (client.socket) {
275
+ client.socket.removeAllListeners();
276
+ }
277
+
278
+ log?.debug?.(`[${accountId}] SocketManager 已停止`);
279
+ }
280
+
281
+ // 初始化:设置所有事件监听器
282
+ setupPongListener();
283
+ setupMessageListener();
284
+ setupCloseListener();
285
+ setupOpenListener();
286
+
287
+ return {
288
+ startKeepAlive,
289
+ stop,
290
+ };
291
+ }
292
+
293
+ /**
294
+ * 添加消息到待确认队列
295
+ */
296
+ export function addToPendingAckQueue(queue: Set<string>, messageId: string) {
297
+ queue.add(messageId);
298
+ }
299
+
300
+ /**
301
+ * 从待确认队列移除消息
302
+ */
303
+ export function removeFromPendingAckQueue(queue: Set<string>, messageId: string) {
304
+ queue.delete(messageId);
305
+ }
306
+
307
+ /**
308
+ * 清空待确认队列
309
+ */
310
+ export function clearPendingAckQueue(queue: Set<string>) {
311
+ queue.clear();
312
+ }