@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 +39 -6
- package/README.md +1 -1
- package/docs/RELEASE_NOTES_V0.7.10.md +40 -0
- package/openclaw.plugin.json +1 -1
- package/package.json +1 -1
- package/plugin.ts +123 -131
- package/src/socket-manager.ts +312 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,10 +1,44 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
-
|
|
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
|
-
|
|
7
|
-
and
|
|
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.
|
|
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+
|
package/openclaw.plugin.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"id": "dingtalk-connector",
|
|
3
3
|
"name": "DingTalk Channel",
|
|
4
|
-
"version": "0.7.
|
|
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
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
|
-
|
|
1473
|
-
|
|
1474
|
-
|
|
1475
|
-
|
|
1476
|
-
|
|
1477
|
-
|
|
1478
|
-
|
|
1479
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1492
|
-
|
|
1493
|
-
|
|
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
|
-
|
|
1496
|
-
const
|
|
1497
|
-
|
|
1503
|
+
const reader = response.body.getReader();
|
|
1504
|
+
const decoder = new TextDecoder();
|
|
1505
|
+
let buffer = '';
|
|
1498
1506
|
|
|
1499
|
-
|
|
1500
|
-
|
|
1501
|
-
|
|
1507
|
+
while (true) {
|
|
1508
|
+
const { done, value } = await reader.read();
|
|
1509
|
+
if (done) break;
|
|
1502
1510
|
|
|
1503
|
-
|
|
1504
|
-
|
|
1505
|
-
|
|
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
|
-
|
|
1509
|
-
|
|
1510
|
-
const
|
|
1511
|
-
if (
|
|
1512
|
-
|
|
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: {
|
|
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, '
|
|
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:
|
|
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.
|
|
3573
|
-
|
|
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
|
-
//
|
|
3615
|
-
|
|
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
|
-
//
|
|
3624
|
-
|
|
3625
|
-
|
|
3626
|
-
|
|
3627
|
-
ctx.log
|
|
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
|
|
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
|
|
3709
|
-
|
|
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
|
+
}
|