@dingtalk-real-ai/dingtalk-connector 0.7.7 → 0.7.9
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 +32 -0
- package/docs/RELEASE_NOTES_V0.7.8.md +101 -0
- package/docs/RELEASE_NOTES_V0.7.9.md +65 -0
- package/openclaw.plugin.json +1 -1
- package/package.json +1 -1
- package/plugin.ts +152 -15
package/CHANGELOG.md
CHANGED
|
@@ -6,6 +6,38 @@
|
|
|
6
6
|
This document records all significant changes. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
|
7
7
|
and version numbers follow [Semantic Versioning](https://semver.org/).
|
|
8
8
|
|
|
9
|
+
## [0.7.9] - 2026-03-13
|
|
10
|
+
|
|
11
|
+
### 新增 / Added
|
|
12
|
+
- ✨ **应用层心跳机制** - 钉钉 Stream 客户端使用自定义心跳(WebSocket ping/pong,30 秒间隔、90 秒超时),超时后主动断开并重连,重连失败 5 秒后重试
|
|
13
|
+
**Application-layer heartbeat** - Stream client uses custom ping/pong heartbeat (30s interval, 90s timeout), reconnects on timeout with 5s retry on failure
|
|
14
|
+
- ✨ **统一停止与清理** - 停止客户端时通过 `doStop` 统一清理心跳定时器并调用 `client.disconnect()`,确保连接正确关闭
|
|
15
|
+
**Unified stop & cleanup** - `doStop` clears heartbeat timer and calls `client.disconnect()` when stopping the client
|
|
16
|
+
|
|
17
|
+
### 修复 / Fixes
|
|
18
|
+
- 🐛 **长连接静默断开** - 关闭 SDK 激进 keepAlive(8 秒超时),改用应用层心跳,减少因长时间无数据导致的静默断连且无法恢复
|
|
19
|
+
**Long-lived connection silent disconnect** - Disabled SDK aggressive keepAlive (8s timeout), use app-layer heartbeat to reduce silent disconnects when idle
|
|
20
|
+
|
|
21
|
+
### 改进 / Improvements
|
|
22
|
+
- ✅ **DWClient 配置** - 启用 `autoReconnect: true`,设置 `keepAlive: false`,由应用层心跳替代 SDK 心跳,避免与钉钉服务端策略冲突
|
|
23
|
+
**DWClient config** - `autoReconnect: true`, `keepAlive: false`; app-layer heartbeat replaces SDK keepAlive to avoid conflicts with server
|
|
24
|
+
|
|
25
|
+
## [0.7.8] - 2026-03-13
|
|
26
|
+
|
|
27
|
+
### 修复 / Fixes
|
|
28
|
+
- 🐛 **AI 卡片模版与渲染优化** - 更新 AI 卡片模版 ID,使卡片样式与最新官方规范保持一致,并提升多终端展示效果
|
|
29
|
+
**AI card template & rendering optimization** - Updated the AI card template ID to match the latest official standard and improved rendering across clients
|
|
30
|
+
- 🐛 **Markdown 表格渲染修复** - 在发送到钉钉前自动为 Markdown 表格头部补充必要空行,避免因缺少空行导致表格被当作普通文本渲染
|
|
31
|
+
**Markdown table rendering fix** - Automatically inserts required blank lines before Markdown table headers to prevent DingTalk from rendering tables as plain text
|
|
32
|
+
- 🐛 **消息去重逻辑优化** - 将消息去重维度从「账号 + 消息 ID」简化为单一「消息 ID」,避免多账号场景下的重复处理或误判
|
|
33
|
+
**Message de-duplication optimization** - Simplified de-duplication from `(accountId, messageId)` to `messageId` only, preventing duplicate handling or misjudgment in multi-account scenarios
|
|
34
|
+
|
|
35
|
+
### 改进 / Improvements
|
|
36
|
+
- ✅ **统一 Markdown 修正管道** - 对 AI 卡片流式内容、最终内容、普通 Markdown 消息及 `sampleMarkdown` 卡片文本统一应用 Markdown 修正规则,确保表格等格式在各入口行为一致
|
|
37
|
+
**Unified Markdown normalization pipeline** - Applies the same Markdown normalization to streaming AI card content, final content, regular Markdown messages, and `sampleMarkdown` card text for consistent behavior
|
|
38
|
+
- ✅ **AI 卡片状态内容一致性** - 在完成 AI 卡片时,对展示内容和写入 `cardParamMap.msgContent` 的内容使用同一份 Markdown 修正结果,确保用户看到的内容与内部状态一致
|
|
39
|
+
**Consistent AI card status content** - Ensures the same normalized Markdown is used both for the visible content and `cardParamMap.msgContent` when finishing AI cards
|
|
40
|
+
|
|
9
41
|
## [0.7.7] - 2026-03-13
|
|
10
42
|
|
|
11
43
|
### 新增 / Added
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
# Release Notes - v0.7.8
|
|
2
|
+
|
|
3
|
+
## ✨ 功能与体验改进 / Features & Improvements
|
|
4
|
+
|
|
5
|
+
- **AI 卡片模版更新与展示效果优化 / AI Card Template & Rendering Improvements**
|
|
6
|
+
升级钉钉 AI 卡片模版 ID,使卡片样式与最新官方规范保持一致,并优化多终端的展示效果与兼容性。
|
|
7
|
+
Updated the DingTalk AI card template ID to align with the latest official template standard, improving visual consistency and compatibility across different clients.
|
|
8
|
+
|
|
9
|
+
- **Markdown 表格渲染修复与自动优化 / Markdown Table Rendering Fix & Auto-Adjustment**
|
|
10
|
+
新增 Markdown 预处理逻辑,在发送到钉钉前自动为表格头部补充必要的空行,避免因缺少空行导致的表格无法正确渲染问题;支持缩进表格场景。
|
|
11
|
+
Added a Markdown preprocessing step that automatically inserts a blank line before table headers when needed, ensuring DingTalk renders tables correctly, including indented table cases.
|
|
12
|
+
|
|
13
|
+
- **统一的 Markdown 修正管道 / Unified Markdown Normalization Pipeline**
|
|
14
|
+
对 AI 卡片流式更新、AI 卡片最终内容提交、普通 Markdown 消息发送以及卡片 `sampleMarkdown` 内容,统一通过同一套 Markdown 修正函数进行处理,确保所有下发到钉钉的文本在表格渲染等细节上行为一致。
|
|
15
|
+
Unified the Markdown normalization logic used for streaming AI card updates, final AI card content, regular Markdown messages, and `sampleMarkdown` card payloads, ensuring consistent behavior of table rendering and formatting in DingTalk.
|
|
16
|
+
|
|
17
|
+
- **AI 卡片状态信息更准确 / More Accurate AI Card Status Content**
|
|
18
|
+
在完成 AI 卡片时,对用于展示的内容与写入 `cardParamMap` 中的 `msgContent` 同步应用 Markdown 修正逻辑,保证用户看到的内容与卡片内部状态字段保持完全一致。
|
|
19
|
+
When finalizing AI cards, the same Markdown fixes are now applied both to the displayed content and the `msgContent` stored in `cardParamMap`, keeping the visible card and its internal state in sync.
|
|
20
|
+
|
|
21
|
+
## 🐛 修复 / Fixes
|
|
22
|
+
|
|
23
|
+
- **Markdown 表格无法正确显示的问题 / Incorrect Markdown Table Rendering**
|
|
24
|
+
修复了部分场景下 Markdown 表格前缺少空行,导致钉钉不将其识别为表格而当作普通文本渲染的问题;现在会自动检测表头与分隔行模式并在需要时插入空行。
|
|
25
|
+
Fixed an issue where missing blank lines before Markdown tables caused DingTalk to render them as plain text; the connector now detects table headers and divider lines and inserts a blank line when necessary.
|
|
26
|
+
|
|
27
|
+
- **消息去重维度优化 / Message De-duplication Scope Optimization**
|
|
28
|
+
优化消息去重逻辑,从「按账号+消息 ID」改为仅基于「消息 ID」维度标记与判断,避免在多账号场景中出现某些重复消息未被正确拦截或误判的情况。
|
|
29
|
+
Improved the message de-duplication mechanism by switching from an account-scoped `(accountId, messageId)` key to a global `messageId` key, preventing edge cases where duplicate messages across accounts might not be handled correctly.
|
|
30
|
+
|
|
31
|
+
## 📋 技术细节 / Technical Details
|
|
32
|
+
|
|
33
|
+
### AI 卡片模版 & 内容处理 / AI Card Template & Content Handling
|
|
34
|
+
|
|
35
|
+
- 更新 `AI_CARD_TEMPLATE_ID` 为新的模版 ID,以匹配最新的钉钉 AI 卡片样式规范。
|
|
36
|
+
- 新增 `ensureTableBlankLines(text: string)` 工具函数:
|
|
37
|
+
- 将文本按行拆分,识别包含竖线的表格行与 `---` 分隔行。
|
|
38
|
+
- 当前行看起来像表头、下一行是分隔行、且前一行既不是空行也不是表格行时,会在表头前插入一个空行。
|
|
39
|
+
- 支持带缩进的表格写法,保持原有内容顺序与缩进风格不变。
|
|
40
|
+
- 在以下路径中统一使用 `ensureTableBlankLines`:
|
|
41
|
+
- AI 卡片流式内容更新(`streamAICard`)中的 `content` 字段。
|
|
42
|
+
- AI 卡片结束时(`finishAICard`)的最终内容与日志长度统计。
|
|
43
|
+
- 普通 Markdown 消息发送(`sendMarkdownMessage`),在追加 `@user` 之前先做表格修正。
|
|
44
|
+
- `buildMsgPayload` 中 `sampleMarkdown` 类型的 `text` 字段。
|
|
45
|
+
- 为单元测试导出 `__testables.ensureTableBlankLines`,便于在不依赖具体业务逻辑的情况下验证 Markdown 修正规则。
|
|
46
|
+
|
|
47
|
+
### 消息去重逻辑 / Message De-duplication Logic
|
|
48
|
+
|
|
49
|
+
- 去重检查由 `isMessageProcessed(accountId, messageId)` 简化为 `isMessageProcessed(messageId)`,对同一消息 ID 统一判重。
|
|
50
|
+
- 标记逻辑由 `markMessageProcessed(accountId, messageId)` 更新为 `markMessageProcessed(messageId)`,减少多账号场景下可能出现的重复处理路径。
|
|
51
|
+
- 保持原有日志信息与跳过处理分支不变,仅调整内部去重键值结构。
|
|
52
|
+
|
|
53
|
+
## 📥 安装升级 / Installation & Upgrade
|
|
54
|
+
|
|
55
|
+
```bash
|
|
56
|
+
# 通过 npm 安装最新版本 / Install latest version via npm
|
|
57
|
+
openclaw plugins install @dingtalk-real-ai/dingtalk-connector
|
|
58
|
+
|
|
59
|
+
# 或升级现有版本 / Or upgrade existing version
|
|
60
|
+
openclaw plugins update dingtalk-connector
|
|
61
|
+
|
|
62
|
+
# 通过 Git 安装 / Install via Git
|
|
63
|
+
openclaw plugins install https://github.com/DingTalk-Real-AI/dingtalk-openclaw-connector.git
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
## ⚠️ 升级注意事项 / Upgrade Notes
|
|
67
|
+
|
|
68
|
+
### 兼容性说明 / Compatibility Notes
|
|
69
|
+
|
|
70
|
+
- **向下兼容 / Backward Compatible**:本次为小版本修复和体验优化更新,在保留 v0.7.x 既有行为的前提下增强了 Markdown 表格渲染与消息去重逻辑,对现有配置完全兼容。
|
|
71
|
+
- **Markdown 表格渲染更稳定 / More Robust Markdown Tables**:即便原始内容中未严格遵守表格前空行的写法,Connector 也会自动做最小化修正,以提高在钉钉中的可读性。
|
|
72
|
+
- **消息去重语义更清晰 / Clearer De-duplication Semantics**:以 `messageId` 为唯一维度进行去重,更贴合钉钉消息唯一标识的语义。
|
|
73
|
+
|
|
74
|
+
### 验证步骤 / Verification Steps
|
|
75
|
+
|
|
76
|
+
升级到此版本后,建议进行以下验证:
|
|
77
|
+
|
|
78
|
+
1. **AI 卡片模版与渲染验证 / AI Card Template & Rendering Verification**
|
|
79
|
+
- 触发一次典型的 AI 卡片对话,观察新模版下的卡片布局与字段展示是否符合预期。
|
|
80
|
+
- 在含有多段文字与表格的回复中,确认卡片内 Markdown 表格渲染正常。
|
|
81
|
+
|
|
82
|
+
2. **Markdown 表格兼容性验证 / Markdown Table Compatibility Verification**
|
|
83
|
+
- 通过机器人发送包含 Markdown 表格的消息(包含表头、分隔行与多列数据),且故意在表格前省略空行。
|
|
84
|
+
- 在移动端及 PC 端查看,确认钉钉能够正确以表格形式渲染内容。
|
|
85
|
+
|
|
86
|
+
3. **消息去重行为验证 / Message De-duplication Behavior Verification**
|
|
87
|
+
- 在相同会话中模拟重复推送同一个 `messageId` 的回调(或快速重复发送同一条消息)。
|
|
88
|
+
- 确认日志中出现去重命中提示,并且业务处理逻辑只执行一次。
|
|
89
|
+
|
|
90
|
+
## 🔗 相关链接 / Related Links
|
|
91
|
+
|
|
92
|
+
- [完整变更日志 / Full Changelog](https://github.com/DingTalk-Real-AI/dingtalk-openclaw-connector/blob/main/CHANGELOG.md)
|
|
93
|
+
- [使用文档 / Documentation](https://github.com/DingTalk-Real-AI/dingtalk-openclaw-connector/blob/main/README.md)
|
|
94
|
+
- [问题反馈 / Issue Feedback](https://github.com/DingTalk-Real-AI/dingtalk-openclaw-connector/issues)
|
|
95
|
+
|
|
96
|
+
---
|
|
97
|
+
|
|
98
|
+
**发布日期 / Release Date**:2026-03-13
|
|
99
|
+
**版本号 / Version**:v0.7.8
|
|
100
|
+
**兼容性 / Compatibility**:OpenClaw Gateway 0.4.0+
|
|
101
|
+
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
# Release Notes - v0.7.9
|
|
2
|
+
|
|
3
|
+
## ✨ 功能与体验改进 / Features & Improvements
|
|
4
|
+
|
|
5
|
+
- **钉钉 Stream 客户端心跳与重连机制优化 / DingTalk Stream Client Heartbeat & Reconnect**
|
|
6
|
+
关闭 DWClient SDK 内置的激进 keepAlive(避免 8 秒超时强制断连),启用应用层自定义心跳:基于 WebSocket ping/pong,30 秒间隔、90 秒超时,超时后主动断开并重连,重连失败时 5 秒后重试,提升长连稳定性。
|
|
7
|
+
Disabled the SDK's aggressive keepAlive (which could force disconnect after 8s), and added an application-layer heartbeat: WebSocket ping/pong with 30s interval and 90s timeout; on timeout the client disconnects and reconnects, with a 5s retry on failure, improving long-lived connection stability.
|
|
8
|
+
|
|
9
|
+
- **DWClient 配置调整 / DWClient Configuration**
|
|
10
|
+
启用 `autoReconnect: true` 以在连接断开时自动重连;设置 `keepAlive: false`,由应用层心跳替代 SDK 心跳,避免与钉钉服务端策略冲突。
|
|
11
|
+
Enabled `autoReconnect: true` for automatic reconnection on disconnect; set `keepAlive: false` and rely on application-layer heartbeat to avoid conflicts with DingTalk server behavior.
|
|
12
|
+
|
|
13
|
+
- **统一停止与清理逻辑 / Unified Stop & Cleanup**
|
|
14
|
+
停止 Stream 客户端时统一通过 `doStop` 清理心跳定时器并调用 `client.disconnect()`,确保资源释放与连接正确关闭。
|
|
15
|
+
When stopping the Stream client, a unified `doStop` now clears the heartbeat timer and calls `client.disconnect()` for consistent resource cleanup and connection closure.
|
|
16
|
+
|
|
17
|
+
## 🐛 修复 / Fixes
|
|
18
|
+
|
|
19
|
+
- **长连接被服务端或中间网络提前断开 / Long-lived Connection Premature Disconnect**
|
|
20
|
+
通过应用层心跳检测连接活性,超时后主动重连,减少因长时间无数据导致的静默断连且无法恢复的问题。
|
|
21
|
+
Application-layer heartbeat detects connection liveness and triggers reconnect on timeout, reducing silent disconnects when the link is idle.
|
|
22
|
+
|
|
23
|
+
## 📋 技术细节 / Technical Details
|
|
24
|
+
|
|
25
|
+
### 应用层心跳机制 / Application-Layer Heartbeat
|
|
26
|
+
|
|
27
|
+
- **参数**:心跳间隔 30 秒(`HEARTBEAT_INTERVAL`),超时 90 秒(`HEARTBEAT_TIMEOUT`),允许约 3 次 ping 无响应后再判定超时。
|
|
28
|
+
- **流程**:定时器每 30 秒通过 `client.socket?.ping()` 发送 ping;监听 `socket.on('pong')` 更新 `lastPongTime`;若当前时间与 `lastPongTime` 差值超过 90 秒则触发重连。
|
|
29
|
+
- **重连**:先 `await client.disconnect()`,再 `await client.connect()`,成功后重置 `lastPongTime`;若重连失败则 5 秒后再次尝试 `client.connect()`。
|
|
30
|
+
- **停止**:`doStop(reason)` 中设置 `stopped = true`、清除心跳定时器、调用 `client.disconnect()`,并记录停止原因与活动。
|
|
31
|
+
|
|
32
|
+
### DWClient 配置说明 / DWClient Config
|
|
33
|
+
|
|
34
|
+
- `autoReconnect: true` — 连接断开时由 SDK 参与自动重连。
|
|
35
|
+
- `keepAlive: false` — 关闭 SDK 内置的激进心跳,避免 8 秒无活动即强制断连,由应用层 30s/90s 心跳替代。
|
|
36
|
+
|
|
37
|
+
## 📥 安装升级 / Installation & Upgrade
|
|
38
|
+
|
|
39
|
+
```bash
|
|
40
|
+
# 通过 npm 安装最新版本 / Install latest version via npm
|
|
41
|
+
openclaw plugins install @dingtalk-real-ai/dingtalk-connector
|
|
42
|
+
|
|
43
|
+
# 或升级现有版本 / Or upgrade existing version
|
|
44
|
+
openclaw plugins update dingtalk-connector
|
|
45
|
+
|
|
46
|
+
# 通过 Git 安装 / Install via Git
|
|
47
|
+
openclaw plugins install https://github.com/DingTalk-Real-AI/dingtalk-openclaw-connector.git
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
## ⚠️ 升级注意事项 / Upgrade Notes
|
|
51
|
+
|
|
52
|
+
- **向下兼容 / Backward Compatible**:仅调整 Stream 客户端的心跳与重连策略,对现有配置与 API 无破坏性变更。
|
|
53
|
+
- **长连场景建议**:若依赖钉钉 Stream 长连接,升级后将自动使用新的心跳与重连逻辑,无需额外配置。
|
|
54
|
+
|
|
55
|
+
## 🔗 相关链接 / Related Links
|
|
56
|
+
|
|
57
|
+
- [完整变更日志 / Full Changelog](https://github.com/DingTalk-Real-AI/dingtalk-openclaw-connector/blob/main/CHANGELOG.md)
|
|
58
|
+
- [使用文档 / Documentation](https://github.com/DingTalk-Real-AI/dingtalk-openclaw-connector/blob/main/README.md)
|
|
59
|
+
- [问题反馈 / Issue Feedback](https://github.com/DingTalk-Real-AI/dingtalk-openclaw-connector/issues)
|
|
60
|
+
|
|
61
|
+
---
|
|
62
|
+
|
|
63
|
+
**发布日期 / Release Date**:2026-03-13
|
|
64
|
+
**版本号 / Version**:v0.7.9
|
|
65
|
+
**兼容性 / 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.9",
|
|
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
|
@@ -1103,7 +1103,7 @@ async function processFileMarkers(
|
|
|
1103
1103
|
|
|
1104
1104
|
const DINGTALK_API = 'https://api.dingtalk.com';
|
|
1105
1105
|
const DINGTALK_OAPI = 'https://oapi.dingtalk.com';
|
|
1106
|
-
const AI_CARD_TEMPLATE_ID = '
|
|
1106
|
+
const AI_CARD_TEMPLATE_ID = '02fcf2f4-5e02-4a85-b672-46d1f715543e.schema';
|
|
1107
1107
|
|
|
1108
1108
|
// flowStatus 值与 Python SDK AICardStatus 一致(cardParamMap 的值必须是字符串)
|
|
1109
1109
|
const AICardStatus = {
|
|
@@ -1141,6 +1141,45 @@ async function createAICard(
|
|
|
1141
1141
|
return createAICardForTarget(config, target, log);
|
|
1142
1142
|
}
|
|
1143
1143
|
|
|
1144
|
+
/**
|
|
1145
|
+
* 确保 Markdown 表格前有空行,否则钉钉无法正确渲染表格。
|
|
1146
|
+
*
|
|
1147
|
+
* 逐行向前看:当前行像表头(含 `|`)且下一行是分隔行时,
|
|
1148
|
+
* 若前一行非空且非表格行,则在表头前插入空行。
|
|
1149
|
+
* 支持缩进表格(行首有空白字符)。
|
|
1150
|
+
*/
|
|
1151
|
+
function ensureTableBlankLines(text: string): string {
|
|
1152
|
+
const lines = text.split('\n');
|
|
1153
|
+
const result: string[] = [];
|
|
1154
|
+
|
|
1155
|
+
// 匹配表格分隔行 (例如 | --- | --- | 或 --- | ---)
|
|
1156
|
+
const tableDividerRegex = /^\s*\|?\s*:?-+:?\s*(\|?\s*:?-+:?\s*)+\|?\s*$/;
|
|
1157
|
+
// 匹配包含竖线的表格行
|
|
1158
|
+
const tableRowRegex = /^\s*\|?.*\|.*\|?\s*$/;
|
|
1159
|
+
|
|
1160
|
+
const isDivider = (line: string) => line.includes('|') && tableDividerRegex.test(line);
|
|
1161
|
+
|
|
1162
|
+
for (let i = 0; i < lines.length; i++) {
|
|
1163
|
+
const currentLine = lines[i];
|
|
1164
|
+
const nextLine = lines[i + 1] ?? '';
|
|
1165
|
+
|
|
1166
|
+
// 逻辑:
|
|
1167
|
+
// 1. 当前行看起来像表头(包含 |)
|
|
1168
|
+
// 2. 下一行是分隔行(---)
|
|
1169
|
+
// 3. 前一行不是空行且不是表格行
|
|
1170
|
+
if (
|
|
1171
|
+
tableRowRegex.test(currentLine) &&
|
|
1172
|
+
isDivider(nextLine) &&
|
|
1173
|
+
i > 0 && lines[i - 1].trim() !== '' && !tableRowRegex.test(lines[i - 1])
|
|
1174
|
+
) {
|
|
1175
|
+
result.push('');
|
|
1176
|
+
}
|
|
1177
|
+
|
|
1178
|
+
result.push(currentLine);
|
|
1179
|
+
}
|
|
1180
|
+
return result.join('\n');
|
|
1181
|
+
}
|
|
1182
|
+
|
|
1144
1183
|
// 流式更新 AI Card 内容
|
|
1145
1184
|
async function streamAICard(
|
|
1146
1185
|
card: AICardInstance,
|
|
@@ -1177,11 +1216,12 @@ async function streamAICard(
|
|
|
1177
1216
|
}
|
|
1178
1217
|
|
|
1179
1218
|
// 调用 streaming API 更新内容
|
|
1219
|
+
const fixedContent = ensureTableBlankLines(content);
|
|
1180
1220
|
const body = {
|
|
1181
1221
|
outTrackId: card.cardInstanceId,
|
|
1182
1222
|
guid: `${Date.now()}_${Math.random().toString(36).slice(2, 8)}`,
|
|
1183
1223
|
key: 'msgContent',
|
|
1184
|
-
content:
|
|
1224
|
+
content: fixedContent,
|
|
1185
1225
|
isFull: true, // 全量替换
|
|
1186
1226
|
isFinalize: finished,
|
|
1187
1227
|
isError: false,
|
|
@@ -1205,10 +1245,11 @@ async function finishAICard(
|
|
|
1205
1245
|
content: string,
|
|
1206
1246
|
log?: any,
|
|
1207
1247
|
): Promise<void> {
|
|
1208
|
-
|
|
1248
|
+
const fixedContent = ensureTableBlankLines(content);
|
|
1249
|
+
log?.info?.(`[DingTalk][AICard] 开始 finish,最终内容长度=${fixedContent.length}`);
|
|
1209
1250
|
|
|
1210
1251
|
// 1. 先用最终内容关闭流式通道(isFinalize=true),确保卡片显示替换后的内容
|
|
1211
|
-
await streamAICard(card,
|
|
1252
|
+
await streamAICard(card, fixedContent, true, log);
|
|
1212
1253
|
|
|
1213
1254
|
// 2. 更新卡片状态为 FINISHED
|
|
1214
1255
|
const body = {
|
|
@@ -1216,7 +1257,7 @@ async function finishAICard(
|
|
|
1216
1257
|
cardData: {
|
|
1217
1258
|
cardParamMap: {
|
|
1218
1259
|
flowStatus: AICardStatus.FINISHED,
|
|
1219
|
-
msgContent:
|
|
1260
|
+
msgContent: fixedContent,
|
|
1220
1261
|
staticMsgContent: '',
|
|
1221
1262
|
sys_full_json_obj: JSON.stringify({
|
|
1222
1263
|
order: ['msgContent'], // 只声明实际使用的字段,避免部分客户端显示空占位
|
|
@@ -1698,7 +1739,7 @@ async function sendMarkdownMessage(
|
|
|
1698
1739
|
options: any = {},
|
|
1699
1740
|
): Promise<any> {
|
|
1700
1741
|
const token = await getAccessToken(config);
|
|
1701
|
-
let text = markdown;
|
|
1742
|
+
let text = ensureTableBlankLines(markdown);
|
|
1702
1743
|
if (options.atUserId) text = `${text} @${options.atUserId}`;
|
|
1703
1744
|
|
|
1704
1745
|
const body: any = {
|
|
@@ -2235,7 +2276,7 @@ function buildMsgPayload(
|
|
|
2235
2276
|
msgKey: 'sampleMarkdown',
|
|
2236
2277
|
msgParam: {
|
|
2237
2278
|
title: title || content.split('\n')[0].replace(/^[#*\s\->]+/, '').slice(0, 20) || 'Message',
|
|
2238
|
-
text: content,
|
|
2279
|
+
text: ensureTableBlankLines(content),
|
|
2239
2280
|
},
|
|
2240
2281
|
};
|
|
2241
2282
|
case 'link':
|
|
@@ -3510,15 +3551,15 @@ const dingtalkPlugin = {
|
|
|
3510
3551
|
|
|
3511
3552
|
ctx.log?.info(`[${account.accountId}] 启动钉钉 Stream 客户端...`);
|
|
3512
3553
|
|
|
3513
|
-
//
|
|
3554
|
+
// 配置 DWClient:关闭 SDK 内置的 keepAlive,使用应用层自定义心跳
|
|
3514
3555
|
// - autoReconnect: 连接断开时自动重连
|
|
3515
|
-
// - keepAlive:
|
|
3556
|
+
// - keepAlive: false(关闭 SDK 的激进心跳检测,避免 8 秒超时强制终止连接)
|
|
3516
3557
|
const client = new DWClient({
|
|
3517
3558
|
clientId: config.clientId,
|
|
3518
3559
|
clientSecret: config.clientSecret,
|
|
3519
3560
|
debug: config.debug || false,
|
|
3520
3561
|
autoReconnect: true,
|
|
3521
|
-
keepAlive:
|
|
3562
|
+
keepAlive: false,
|
|
3522
3563
|
} as any);
|
|
3523
3564
|
|
|
3524
3565
|
client.registerCallbackListener(TOPIC_ROBOT, async (res: any) => {
|
|
@@ -3532,15 +3573,15 @@ const dingtalkPlugin = {
|
|
|
3532
3573
|
ctx.log?.info?.(`[DingTalk] 已立即确认回调: messageId=${messageId}`);
|
|
3533
3574
|
}
|
|
3534
3575
|
|
|
3535
|
-
//
|
|
3536
|
-
if (messageId && isMessageProcessed(
|
|
3576
|
+
// 【消息去重】检查是否已处理过该消息
|
|
3577
|
+
if (messageId && isMessageProcessed(messageId)) {
|
|
3537
3578
|
ctx.log?.warn?.(`[DingTalk][${account.accountId}] 检测到重复消息,跳过处理: messageId=${messageId}`);
|
|
3538
3579
|
return;
|
|
3539
3580
|
}
|
|
3540
3581
|
|
|
3541
|
-
//
|
|
3582
|
+
// 标记消息为已处理
|
|
3542
3583
|
if (messageId) {
|
|
3543
|
-
markMessageProcessed(
|
|
3584
|
+
markMessageProcessed(messageId);
|
|
3544
3585
|
}
|
|
3545
3586
|
|
|
3546
3587
|
// 异步处理消息(不阻塞回调确认)
|
|
@@ -3570,16 +3611,110 @@ const dingtalkPlugin = {
|
|
|
3570
3611
|
|
|
3571
3612
|
let stopped = false;
|
|
3572
3613
|
|
|
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 秒
|
|
3622
|
+
|
|
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 响应`);
|
|
3628
|
+
});
|
|
3629
|
+
|
|
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);
|
|
3700
|
+
|
|
3573
3701
|
// 统一的停止逻辑
|
|
3574
3702
|
const doStop = (reason: string) => {
|
|
3575
3703
|
if (stopped) return;
|
|
3576
3704
|
stopped = true;
|
|
3577
3705
|
ctx.log?.info(`[${account.accountId}] 停止钉钉 Stream 客户端 (${reason})...`);
|
|
3706
|
+
|
|
3707
|
+
// 清理心跳定时器
|
|
3708
|
+
if (typeof heartbeatTimer !== 'undefined') {
|
|
3709
|
+
clearInterval(heartbeatTimer);
|
|
3710
|
+
ctx.log?.debug?.(`[${account.accountId}] 心跳定时器已清理`);
|
|
3711
|
+
}
|
|
3712
|
+
|
|
3578
3713
|
try {
|
|
3579
3714
|
// 【关键】调用 disconnect() 正确关闭 WebSocket 连接
|
|
3580
3715
|
client.disconnect();
|
|
3581
3716
|
} catch (err: any) {
|
|
3582
|
-
ctx.log?.warn?.(`[${account.accountId}]
|
|
3717
|
+
ctx.log?.warn?.(`[${account.accountId}] 断开连接时出错:${err.message}`);
|
|
3583
3718
|
}
|
|
3584
3719
|
rt.channel.activity.record('dingtalk-connector', account.accountId, 'stop');
|
|
3585
3720
|
};
|
|
@@ -3939,6 +4074,8 @@ export {
|
|
|
3939
4074
|
// ============ 测试辅助导出 ============
|
|
3940
4075
|
// 仅用于单元测试,避免在业务代码中直接依赖内部实现细节
|
|
3941
4076
|
export const __testables = {
|
|
4077
|
+
// Markdown 修正
|
|
4078
|
+
ensureTableBlankLines,
|
|
3942
4079
|
// 会话 & 去重
|
|
3943
4080
|
normalizeSlashCommand,
|
|
3944
4081
|
buildSessionContext,
|