@dingtalk-real-ai/dingtalk-connector 0.8.20 → 0.8.21
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 +45 -0
- package/README.en.md +18 -2
- package/README.md +18 -2
- package/dist/{connection-BZd5NXuh.mjs → connection-D4uO_J9G.mjs} +33 -7
- package/dist/entry-bundled.mjs +1 -1
- package/dist/gateway-methods-B0_tBGPn.mjs +2 -0
- package/dist/{gateway-methods-DI8lkjSd.mjs → gateway-methods-BNuB2wXl.mjs} +2 -2
- package/dist/index.mjs +2 -2
- package/dist/{media-DUMfXnwJ.mjs → media-BRqGsKUB.mjs} +8 -8
- package/dist/{media-DEuF7r3G.mjs → media-DD7Rlljd.mjs} +1 -1
- package/dist/{message-handler-_vk6QsWo.mjs → message-handler-CPGT1bgU.mjs} +74 -13
- package/dist/{messaging-CyIJY4h2.mjs → messaging-DQwrrd68.mjs} +90 -10
- package/dist/{runtime-b4xvqwW6.mjs → runtime-BphH7_vR.mjs} +5 -5
- package/dist/{utils-DY1gFCdU.mjs → utils-BqUoUOwd.mjs} +1 -1
- package/dist/utils-QEvgZ2uM.mjs +119 -0
- package/docs/RELEASE_NOTES_V0.8.21-beta.0.md +163 -0
- package/docs/RELEASE_NOTES_V0.8.21.md +154 -0
- package/docs/TROUBLESHOOTING.md +28 -0
- package/openclaw.plugin.json +1 -1
- package/package.json +1 -1
- package/src/core/connection.ts +75 -13
- package/src/core/message-handler.ts +15 -1
- package/src/reply-dispatcher.ts +101 -5
- package/src/services/messaging/card.ts +117 -5
- package/src/utils/empty-reply.ts +72 -0
- package/src/utils/index.ts +1 -0
- package/dist/gateway-methods-DtdiDpYK.mjs +0 -2
- package/dist/utils-CIfI_3Jh.mjs +0 -63
package/openclaw.plugin.json
CHANGED
package/package.json
CHANGED
package/src/core/connection.ts
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
*
|
|
4
4
|
* 职责:
|
|
5
5
|
* - 管理单个钉钉账号的 WebSocket 连接
|
|
6
|
-
* - 实现应用层心跳检测(10 秒间隔,
|
|
6
|
+
* - 实现应用层心跳检测(10 秒间隔,20 秒超时)
|
|
7
7
|
* - 处理连接重连逻辑,带指数退避
|
|
8
8
|
* - 消息去重(内置 Map,5 分钟 TTL)
|
|
9
9
|
*
|
|
@@ -64,6 +64,48 @@ const BASE_BACKOFF_DELAY = 1000; // 1 秒
|
|
|
64
64
|
/** 最大退避时间(毫秒) */
|
|
65
65
|
const MAX_BACKOFF_DELAY = 30 * 1000; // 30 秒
|
|
66
66
|
|
|
67
|
+
// ============ 上游 SDK 噪音抑制 ============
|
|
68
|
+
//
|
|
69
|
+
// 背景(#571 / #536 / #573):
|
|
70
|
+
// 上游 SDK `dingtalk-stream@2.1.4`(client.cjs:138 / :185)在连接建立和断开时
|
|
71
|
+
// 直接调用 `console.info(...)`,输出 `Disconnecting.` / `[time] connect success`
|
|
72
|
+
// 两条字符串。任何触发 `client.disconnect()` + `client.connect()` 的链路
|
|
73
|
+
// (网络抖动、服务端下发 disconnect topic、connector 主动重连等)都会让这两
|
|
74
|
+
// 条日志成对出现,绕过本插件 logger,给运维造成"故障"误判。
|
|
75
|
+
//
|
|
76
|
+
// 这里在模块加载时一次性 patch `console.info`,**只过滤这两条精确字符串**,
|
|
77
|
+
// 其他 console.info 不受影响。connector 自己在 `doReconnect` 流程中通过
|
|
78
|
+
// `logger.info` 输出更友好的连接生命周期日志(受 debug 配置控制)。
|
|
79
|
+
//
|
|
80
|
+
// 此函数不动 keepAlive 心跳逻辑、不动 lastSocketAvailableTime 写入时机、
|
|
81
|
+
// 不动 setupPongListener。
|
|
82
|
+
let _streamNoiseSilenced = false;
|
|
83
|
+
function silenceDingtalkStreamConsoleNoise(): void {
|
|
84
|
+
if (_streamNoiseSilenced) return;
|
|
85
|
+
_streamNoiseSilenced = true;
|
|
86
|
+
const origConsoleInfo = console.info.bind(console);
|
|
87
|
+
console.info = (...args: any[]) => {
|
|
88
|
+
const first = args[0];
|
|
89
|
+
if (typeof first === "string") {
|
|
90
|
+
if (first === "Disconnecting.") return;
|
|
91
|
+
if (/^\[[^\]]+\] connect success$/.test(first)) return;
|
|
92
|
+
}
|
|
93
|
+
return origConsoleInfo(...args);
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// 一次性提示:解释 SDK console.info 噪音的来源与重连预期,避免多 bot 启动时重复
|
|
98
|
+
let _connectionNoticePrinted = false;
|
|
99
|
+
function printConnectionNoticeOnce(): void {
|
|
100
|
+
if (_connectionNoticePrinted) return;
|
|
101
|
+
_connectionNoticePrinted = true;
|
|
102
|
+
console.log(
|
|
103
|
+
"[dingtalk-connector] ℹ️ 上游 dingtalk-stream SDK 的 `Disconnecting.` / " +
|
|
104
|
+
"`connect success` 日志已由本插件过滤;真实重连(网络抖动、服务端推 disconnect 等)" +
|
|
105
|
+
"由 connector 自动处理。正常运行下不应看到高频(≤30s)周期性重连,如有请提 issue。",
|
|
106
|
+
);
|
|
107
|
+
}
|
|
108
|
+
|
|
67
109
|
// ============ 监控账号 ============
|
|
68
110
|
|
|
69
111
|
export async function monitorSingleAccount(
|
|
@@ -72,6 +114,11 @@ export async function monitorSingleAccount(
|
|
|
72
114
|
const { cfg, account, runtime, abortSignal, messageHandler, onStatusChange } = opts;
|
|
73
115
|
const { accountId } = account;
|
|
74
116
|
|
|
117
|
+
// 在 dingtalk-stream SDK 被动态 import 之前抑制其 console.info 噪音
|
|
118
|
+
// (#571 / #536 / #573:SDK 在每次 connect/disconnect 时直接 console.info,
|
|
119
|
+
// 在频繁重连场景下会刷屏)
|
|
120
|
+
silenceDingtalkStreamConsoleNoise();
|
|
121
|
+
|
|
75
122
|
// 保存 cfg 以便传递给 messageHandler
|
|
76
123
|
const clawdbotConfig = cfg;
|
|
77
124
|
const log = runtime?.log;
|
|
@@ -166,25 +213,26 @@ export async function monitorSingleAccount(
|
|
|
166
213
|
|
|
167
214
|
/**
|
|
168
215
|
* 标记消息处理开始,启动定期更新机制
|
|
169
|
-
*
|
|
216
|
+
* 在消息处理期间,定时刷新 lastSocketAvailableTime
|
|
170
217
|
* 防止长时间处理(如复杂的 AI 任务)触发心跳超时
|
|
171
218
|
*/
|
|
172
219
|
function markMessageProcessingStart() {
|
|
173
220
|
activeMessageProcessing = true;
|
|
174
221
|
lastSocketAvailableTime = Date.now();
|
|
175
|
-
|
|
222
|
+
|
|
176
223
|
// 清理旧的定时器(如果存在)
|
|
177
224
|
if (messageProcessingKeepAliveTimer) {
|
|
178
225
|
clearInterval(messageProcessingKeepAliveTimer);
|
|
179
226
|
}
|
|
180
|
-
|
|
181
|
-
// 每
|
|
227
|
+
|
|
228
|
+
// 每 15 秒更新一次(< TIMEOUT_THRESHOLD = 20s),保证 AI 长任务期间不会
|
|
229
|
+
// 因为 elapsed 超过 20 秒触发 keepAlive 兜底重连
|
|
182
230
|
messageProcessingKeepAliveTimer = setInterval(() => {
|
|
183
231
|
if (activeMessageProcessing) {
|
|
184
232
|
lastSocketAvailableTime = Date.now();
|
|
185
233
|
logger.debug(`📝 消息处理中,更新 socket 可用时间`);
|
|
186
234
|
}
|
|
187
|
-
},
|
|
235
|
+
}, 15 * 1000); // 15 秒间隔
|
|
188
236
|
|
|
189
237
|
logger.debug(`📝 消息处理开始,启动活跃标记定时器`);
|
|
190
238
|
}
|
|
@@ -242,6 +290,12 @@ export async function monitorSingleAccount(
|
|
|
242
290
|
// 2. 重新建立连接
|
|
243
291
|
await client.connect();
|
|
244
292
|
|
|
293
|
+
// 立即注册 socket 事件监听器,避免在等待 OPEN 期间错过 pong/message/close 事件
|
|
294
|
+
// (keepAlive 期间发的 ping,如果 pong 回来时 listener 还没挂,会被丢,最坏踩到 TIMEOUT_THRESHOLD 边界)
|
|
295
|
+
setupPongListener();
|
|
296
|
+
setupMessageListener();
|
|
297
|
+
setupCloseListener();
|
|
298
|
+
|
|
245
299
|
// 3. 等待连接真正建立(监听 open 事件,最多等待 10 秒)
|
|
246
300
|
const connectionEstablished = await new Promise<boolean>((resolve) => {
|
|
247
301
|
const timeout = setTimeout(() => {
|
|
@@ -313,6 +367,9 @@ export async function monitorSingleAccount(
|
|
|
313
367
|
try {
|
|
314
368
|
const msg = JSON.parse(data);
|
|
315
369
|
if (msg.type === "SYSTEM" && msg.headers?.topic === "disconnect") {
|
|
370
|
+
// 钉钉服务端在 LB / 实例切换等场景下可能下发 disconnect topic,
|
|
371
|
+
// 客户端需立即断开重连;这是协议机制,非故障
|
|
372
|
+
logger.debug(`收到服务端 disconnect topic,即将重连`);
|
|
316
373
|
if (!isStopped && !isReconnecting) {
|
|
317
374
|
// 立即重连,不退避
|
|
318
375
|
doReconnect(true).catch((err) => {
|
|
@@ -371,7 +428,7 @@ export async function monitorSingleAccount(
|
|
|
371
428
|
try {
|
|
372
429
|
const elapsed = Date.now() - lastSocketAvailableTime;
|
|
373
430
|
|
|
374
|
-
// 【超时检测】超过
|
|
431
|
+
// 【超时检测】超过 TIMEOUT_THRESHOLD(当前 20s = 2 次心跳未响应),触发重连
|
|
375
432
|
if (elapsed > TIMEOUT_THRESHOLD) {
|
|
376
433
|
logger.info(
|
|
377
434
|
`⚠️ 超时检测:已 ${Math.round(elapsed / 1000)} 秒未确认 socket 可用,触发重连...`,
|
|
@@ -449,11 +506,6 @@ export async function monitorSingleAccount(
|
|
|
449
506
|
logger.debug(`Connection 已停止`);
|
|
450
507
|
}
|
|
451
508
|
|
|
452
|
-
// 初始化:设置所有事件监听器
|
|
453
|
-
setupPongListener();
|
|
454
|
-
setupMessageListener();
|
|
455
|
-
setupCloseListener();
|
|
456
|
-
|
|
457
509
|
return new Promise<void>(async (resolve, reject) => {
|
|
458
510
|
// Handle abort signal
|
|
459
511
|
if (abortSignal) {
|
|
@@ -638,12 +690,22 @@ export async function monitorSingleAccount(
|
|
|
638
690
|
// Connect to DingTalk Stream
|
|
639
691
|
try {
|
|
640
692
|
await client.connect();
|
|
693
|
+
|
|
694
|
+
// 注册 socket 事件监听器(必须在 connect 后,此时 client.socket 已创建)
|
|
695
|
+
setupPongListener();
|
|
696
|
+
setupMessageListener();
|
|
697
|
+
setupCloseListener();
|
|
698
|
+
|
|
641
699
|
logger.info(`Connected to DingTalk Stream successfully`);
|
|
642
700
|
logger.info(`PID: ${process.pid}`);
|
|
643
701
|
logger.info(
|
|
644
|
-
`✅ 自定义 keepAlive: true (10 秒心跳,
|
|
702
|
+
`✅ 自定义 keepAlive: true (10 秒心跳,20 秒超时), 指数退避重连`,
|
|
645
703
|
);
|
|
646
704
|
|
|
705
|
+
// 首个账号连上时,打印一次连接生命周期说明,解释 SDK console.info 噪音
|
|
706
|
+
// 的来源以及"高频重连不正常"的预期(#571 / #536 / #573)
|
|
707
|
+
printConnectionNoticeOnce();
|
|
708
|
+
|
|
647
709
|
// 初次连接成功,向框架报告 connected: true
|
|
648
710
|
onStatusChange?.({ connected: true, lastConnectedAt: Date.now() });
|
|
649
711
|
|
|
@@ -56,6 +56,10 @@ import { createAICardForTarget, streamAICard, type AICardInstance } from "../ser
|
|
|
56
56
|
import { QUEUE_BUSY_ACK_PHRASES } from "../utils/constants.ts";
|
|
57
57
|
import { createDingtalkReplyDispatcher } from "../reply-dispatcher.ts";
|
|
58
58
|
import { normalizeSlashCommand } from "../utils/session.ts";
|
|
59
|
+
import {
|
|
60
|
+
pickEmptyReplyFallbackText,
|
|
61
|
+
emptyGroupReplyLogHint,
|
|
62
|
+
} from "../utils/empty-reply.ts";
|
|
59
63
|
import { getDingtalkRuntime } from "../runtime.ts";
|
|
60
64
|
import { dingtalkHttp } from '../utils/http-client.ts';
|
|
61
65
|
import { createLoggerFromConfig } from '../utils/index.ts';
|
|
@@ -1573,7 +1577,17 @@ export async function handleDingTalkMessageInternal(params: HandleMessageParams)
|
|
|
1573
1577
|
);
|
|
1574
1578
|
}
|
|
1575
1579
|
|
|
1576
|
-
|
|
1580
|
+
// ✅ 异步模式下 final 文本为空时的兜底
|
|
1581
|
+
// 群聊场景下,常见根因是 OpenClaw `messages.groupChat.visibleReplies` 未设为 "automatic"
|
|
1582
|
+
// (详见 src/utils/empty-reply.ts),给运维一份可操作的指引而不是无信息量的「任务执行完成」。
|
|
1583
|
+
let textToSend = finalText.trim();
|
|
1584
|
+
if (!textToSend) {
|
|
1585
|
+
const isGroup = !isDirect;
|
|
1586
|
+
textToSend = pickEmptyReplyFallbackText(isGroup);
|
|
1587
|
+
if (isGroup) {
|
|
1588
|
+
log?.warn?.(`[DingTalk][asyncMode] ${emptyGroupReplyLogHint()}`);
|
|
1589
|
+
}
|
|
1590
|
+
}
|
|
1577
1591
|
const title =
|
|
1578
1592
|
textToSend.split('\n')[0]?.replace(/^[#*\s\->]+/, '').trim() || '消息';
|
|
1579
1593
|
await sendProactive(config, proactiveTarget, textToSend, {
|
package/src/reply-dispatcher.ts
CHANGED
|
@@ -47,6 +47,11 @@ import {
|
|
|
47
47
|
processAudioMarkers,
|
|
48
48
|
uploadAndReplaceFileMarkers,
|
|
49
49
|
} from "./services/media/index.ts";
|
|
50
|
+
import {
|
|
51
|
+
pickEmptyReplyFallbackText,
|
|
52
|
+
emptyGroupReplyLogHint,
|
|
53
|
+
groupChatLacksVisibleRepliesAutomatic,
|
|
54
|
+
} from "./utils/empty-reply.ts";
|
|
50
55
|
|
|
51
56
|
|
|
52
57
|
export type CreateDingtalkReplyDispatcherParams = {
|
|
@@ -93,7 +98,12 @@ export function createDingtalkReplyDispatcher(params: CreateDingtalkReplyDispatc
|
|
|
93
98
|
let currentCardTarget: AICardTarget | null = null;
|
|
94
99
|
let accumulatedText = "";
|
|
95
100
|
const deliveredFinalTexts = new Set<string>();
|
|
96
|
-
|
|
101
|
+
|
|
102
|
+
/** 本轮是否已向用户发出过可见回复(final / 流式更新 / 错误兜底等) */
|
|
103
|
+
let outboundUserVisibleThisTurn = false;
|
|
104
|
+
/** 防止 onIdle / onError 重复发送 visibleReplies 配置指引 */
|
|
105
|
+
let idleConfigNudgeSent = false;
|
|
106
|
+
|
|
97
107
|
// 异步模式:累积完整响应
|
|
98
108
|
let asyncModeFullResponse = "";
|
|
99
109
|
|
|
@@ -161,6 +171,7 @@ export function createDingtalkReplyDispatcher(params: CreateDingtalkReplyDispatc
|
|
|
161
171
|
);
|
|
162
172
|
deliveredErrorTypes.add(errorKey);
|
|
163
173
|
lastErrorTime = now;
|
|
174
|
+
outboundUserVisibleThisTurn = true;
|
|
164
175
|
log.info(`[DingTalk][Fallback] ✅ 错误消息发送成功`);
|
|
165
176
|
} catch (fallbackErr: any) {
|
|
166
177
|
log.error(`[DingTalk][Fallback] ❌ 错误消息发送失败:${fallbackErr.message}`);
|
|
@@ -238,6 +249,7 @@ export function createDingtalkReplyDispatcher(params: CreateDingtalkReplyDispatc
|
|
|
238
249
|
log.info(`[DingTalk][startStreaming] 复用预创建 AI Card,cardInstanceId=${preCreatedCard.cardInstanceId}`);
|
|
239
250
|
currentCardTarget = preCreatedCard as any;
|
|
240
251
|
accumulatedText = "";
|
|
252
|
+
outboundUserVisibleThisTurn = true;
|
|
241
253
|
return;
|
|
242
254
|
}
|
|
243
255
|
|
|
@@ -294,9 +306,17 @@ export function createDingtalkReplyDispatcher(params: CreateDingtalkReplyDispatc
|
|
|
294
306
|
let finalText = accumulatedText;
|
|
295
307
|
|
|
296
308
|
// ✅ 如果累积的文本为空,使用默认提示文案
|
|
309
|
+
// 群聊场景下,常见根因是 OpenClaw `messages.groupChat.visibleReplies` 未设为
|
|
310
|
+
// "automatic"(上游 source-reply-delivery-mode.ts 走 message_tool_only 时
|
|
311
|
+
// 会跳过 onPartialReply,accumulatedText 始终为空)。给运维一份可操作的指引,
|
|
312
|
+
// 而不是一句无信息量的「任务执行完成」。详见 src/utils/empty-reply.ts。
|
|
297
313
|
if (!finalText.trim()) {
|
|
298
|
-
|
|
299
|
-
|
|
314
|
+
const isGroup = !isDirect;
|
|
315
|
+
finalText = pickEmptyReplyFallbackText(isGroup);
|
|
316
|
+
log.info(`[DingTalk][closeStreaming] 累积文本为空,使用默认提示文案 (isGroup=${isGroup})`);
|
|
317
|
+
if (isGroup) {
|
|
318
|
+
log.warn?.(`[DingTalk][closeStreaming] ${emptyGroupReplyLogHint()}`);
|
|
319
|
+
}
|
|
300
320
|
}
|
|
301
321
|
|
|
302
322
|
// 获取 oapiToken 用于媒体处理
|
|
@@ -402,6 +422,7 @@ export function createDingtalkReplyDispatcher(params: CreateDingtalkReplyDispatc
|
|
|
402
422
|
account.config as DingtalkConfig,
|
|
403
423
|
log
|
|
404
424
|
);
|
|
425
|
+
outboundUserVisibleThisTurn = true;
|
|
405
426
|
log.info(`[DingTalk][closeStreaming] ✅ AI Card 关闭成功`);
|
|
406
427
|
} catch (error: any) {
|
|
407
428
|
log.error(`[DingTalk][closeStreaming] ❌ AI Card 关闭失败:${error?.message || String(error)}`);
|
|
@@ -421,6 +442,7 @@ export function createDingtalkReplyDispatcher(params: CreateDingtalkReplyDispatc
|
|
|
421
442
|
log: params.runtime.log,
|
|
422
443
|
}
|
|
423
444
|
);
|
|
445
|
+
outboundUserVisibleThisTurn = true;
|
|
424
446
|
log.info(`[DingTalk][closeStreaming] ✅ 降级发送成功`);
|
|
425
447
|
} catch (sendErr: any) {
|
|
426
448
|
log.error(`[DingTalk][closeStreaming] ❌ 降级发送失败:${sendErr.message}`);
|
|
@@ -432,6 +454,67 @@ export function createDingtalkReplyDispatcher(params: CreateDingtalkReplyDispatc
|
|
|
432
454
|
}
|
|
433
455
|
};
|
|
434
456
|
|
|
457
|
+
/**
|
|
458
|
+
* 群聊且 OpenClaw 未配置 `messages.groupChat.visibleReplies=automatic` 时,
|
|
459
|
+
* 若本轮结束时仍没有任何用户可见输出(上游可能未调用空 final 的 deliver),
|
|
460
|
+
* 补发与空 final 一致的配置指引,避免只有「思考中」却无声。
|
|
461
|
+
*/
|
|
462
|
+
const maybeSendGroupVisibleRepliesIdleNudge = async () => {
|
|
463
|
+
if (isDirect) return;
|
|
464
|
+
if (!groupChatLacksVisibleRepliesAutomatic(cfg)) return;
|
|
465
|
+
if (asyncMode) return;
|
|
466
|
+
if (outboundUserVisibleThisTurn) return;
|
|
467
|
+
if (idleConfigNudgeSent) return;
|
|
468
|
+
idleConfigNudgeSent = true;
|
|
469
|
+
log.info(
|
|
470
|
+
`[DingTalk][idleNudge] 本轮无用户可见回复且群聊未启用 visibleReplies=automatic,发送配置指引`,
|
|
471
|
+
);
|
|
472
|
+
try {
|
|
473
|
+
const text = pickEmptyReplyFallbackText(true);
|
|
474
|
+
log.warn(`[DingTalk][idleNudge] ${emptyGroupReplyLogHint()}`);
|
|
475
|
+
for (const chunk of core.channel.text.chunkTextWithMode(
|
|
476
|
+
text,
|
|
477
|
+
textChunkLimit,
|
|
478
|
+
chunkMode,
|
|
479
|
+
)) {
|
|
480
|
+
if (isTextMode) {
|
|
481
|
+
if (groupReplyMode === 'markdown') {
|
|
482
|
+
await sendMarkdownMessage(
|
|
483
|
+
account.config as DingtalkConfig,
|
|
484
|
+
sessionWebhook,
|
|
485
|
+
chunk.split('\n')[0]?.replace(/^[#*\s\->]+/, '').slice(0, 20) || 'Message',
|
|
486
|
+
chunk,
|
|
487
|
+
{ cfg, detectBareAliases: true },
|
|
488
|
+
);
|
|
489
|
+
} else {
|
|
490
|
+
await sendTextMessage(
|
|
491
|
+
account.config as DingtalkConfig,
|
|
492
|
+
sessionWebhook,
|
|
493
|
+
chunk,
|
|
494
|
+
{ cfg, detectBareAliases: true },
|
|
495
|
+
);
|
|
496
|
+
}
|
|
497
|
+
} else {
|
|
498
|
+
await sendMessage(
|
|
499
|
+
account.config as DingtalkConfig,
|
|
500
|
+
sessionWebhook,
|
|
501
|
+
chunk,
|
|
502
|
+
{
|
|
503
|
+
useMarkdown: true,
|
|
504
|
+
log: params.runtime.log,
|
|
505
|
+
cfg,
|
|
506
|
+
detectBareAliases: true,
|
|
507
|
+
},
|
|
508
|
+
);
|
|
509
|
+
}
|
|
510
|
+
}
|
|
511
|
+
outboundUserVisibleThisTurn = true;
|
|
512
|
+
log.info(`[DingTalk][idleNudge] ✅ 配置指引已发送`);
|
|
513
|
+
} catch (e: any) {
|
|
514
|
+
log.error(`[DingTalk][idleNudge] 发送失败: ${e?.message || e}`);
|
|
515
|
+
}
|
|
516
|
+
};
|
|
517
|
+
|
|
435
518
|
const { dispatcher, replyOptions, markDispatchIdle } =
|
|
436
519
|
core.channel.reply.createReplyDispatcherWithTyping({
|
|
437
520
|
...prefixOptions,
|
|
@@ -440,6 +523,8 @@ export function createDingtalkReplyDispatcher(params: CreateDingtalkReplyDispatc
|
|
|
440
523
|
log.info(`[DingTalk][onReplyStart] 开始回复,流式 enabled=${streamingEnabled}`);
|
|
441
524
|
// 每次 onReplyStart 都是全新的回复周期,清空去重集合
|
|
442
525
|
deliveredFinalTexts.clear();
|
|
526
|
+
outboundUserVisibleThisTurn = false;
|
|
527
|
+
idleConfigNudgeSent = false;
|
|
443
528
|
if (streamingEnabled) {
|
|
444
529
|
// fire-and-forget:提前创建 AI Card,onPartialReply 会等待创建完成
|
|
445
530
|
void startStreaming();
|
|
@@ -482,9 +567,15 @@ export function createDingtalkReplyDispatcher(params: CreateDingtalkReplyDispatc
|
|
|
482
567
|
info?.kind === "final" && hasText && deliveredFinalTexts.has(text);
|
|
483
568
|
|
|
484
569
|
// ✅ 如果是 final 响应且没有文本,使用默认提示文案
|
|
570
|
+
// 群聊空 final 常由 OpenClaw `messages.groupChat.visibleReplies !== "automatic"`
|
|
571
|
+
// 触发,群聊场景给一句可操作的修复指引;单聊保持原文案。
|
|
485
572
|
if (info?.kind === "final" && !hasText) {
|
|
486
|
-
|
|
487
|
-
|
|
573
|
+
const isGroup = !isDirect;
|
|
574
|
+
text = pickEmptyReplyFallbackText(isGroup);
|
|
575
|
+
log.info(`[DingTalk][deliver] final 响应无文本,使用默认提示文案 (isGroup=${isGroup})`);
|
|
576
|
+
if (isGroup) {
|
|
577
|
+
log.warn?.(`[DingTalk][deliver] ${emptyGroupReplyLogHint()}`);
|
|
578
|
+
}
|
|
488
579
|
}
|
|
489
580
|
|
|
490
581
|
const shouldDeliverText = Boolean(text.trim()) && !skipTextForDuplicateFinal;
|
|
@@ -527,6 +618,7 @@ export function createDingtalkReplyDispatcher(params: CreateDingtalkReplyDispatc
|
|
|
527
618
|
account.config as DingtalkConfig,
|
|
528
619
|
log
|
|
529
620
|
);
|
|
621
|
+
outboundUserVisibleThisTurn = true;
|
|
530
622
|
log.info(`[DingTalk][deliver] ✅ block 更新到 AI Card 成功`);
|
|
531
623
|
} catch (streamErr: any) {
|
|
532
624
|
log.error(`[DingTalk][deliver] ❌ block 更新 AI Card 失败:${streamErr.message}`);
|
|
@@ -598,6 +690,7 @@ export function createDingtalkReplyDispatcher(params: CreateDingtalkReplyDispatc
|
|
|
598
690
|
);
|
|
599
691
|
}
|
|
600
692
|
}
|
|
693
|
+
outboundUserVisibleThisTurn = true;
|
|
601
694
|
log.info(`[DingTalk][deliver] ✅ 非流式发送成功`);
|
|
602
695
|
deliveredFinalTexts.add(text);
|
|
603
696
|
} catch (error: any) {
|
|
@@ -618,11 +711,13 @@ export function createDingtalkReplyDispatcher(params: CreateDingtalkReplyDispatc
|
|
|
618
711
|
);
|
|
619
712
|
await closeStreaming();
|
|
620
713
|
typingCallbacks.onIdle?.();
|
|
714
|
+
await maybeSendGroupVisibleRepliesIdleNudge();
|
|
621
715
|
},
|
|
622
716
|
onIdle: async () => {
|
|
623
717
|
log.info(`[DingTalk][onIdle] 回复空闲,关闭 AI Card`);
|
|
624
718
|
typingCallbacks.onIdle?.();
|
|
625
719
|
await closeStreaming();
|
|
720
|
+
await maybeSendGroupVisibleRepliesIdleNudge();
|
|
626
721
|
},
|
|
627
722
|
onCleanup: () => {
|
|
628
723
|
log.info(`[DingTalk][onCleanup] 清理回调`);
|
|
@@ -685,6 +780,7 @@ export function createDingtalkReplyDispatcher(params: CreateDingtalkReplyDispatc
|
|
|
685
780
|
account.config as DingtalkConfig,
|
|
686
781
|
log
|
|
687
782
|
);
|
|
783
|
+
outboundUserVisibleThisTurn = true;
|
|
688
784
|
log.debug(`[DingTalk][onPartialReply] ✅ AI Card 更新成功`);
|
|
689
785
|
} catch (err: any) {
|
|
690
786
|
// QPS 限流是瞬时错误:streamAICard 内部已自动退避+重试,
|
|
@@ -167,11 +167,18 @@ export type AICardTarget =
|
|
|
167
167
|
|
|
168
168
|
// ============ Markdown 格式修正 ============
|
|
169
169
|
|
|
170
|
+
/**
|
|
171
|
+
* 统一换行符为 \n,避免 CRLF 干扰 Markdown 解析
|
|
172
|
+
*/
|
|
173
|
+
function normalizeLineEndings(text: string): string {
|
|
174
|
+
return text.replace(/\r\n?/g, "\n");
|
|
175
|
+
}
|
|
176
|
+
|
|
170
177
|
/**
|
|
171
178
|
* 确保 Markdown 表格前有空行,否则钉钉无法正确渲染表格
|
|
172
179
|
*/
|
|
173
180
|
function ensureTableBlankLines(text: string): string {
|
|
174
|
-
const lines = text.split("\n");
|
|
181
|
+
const lines = normalizeLineEndings(text).split("\n");
|
|
175
182
|
const result: string[] = [];
|
|
176
183
|
|
|
177
184
|
const tableDividerRegex = /^\s*\|?\s*:?-+:?\s*(\|?\s*:?-+:?\s*)+\|?\s*$/;
|
|
@@ -202,6 +209,109 @@ function ensureTableBlankLines(text: string): string {
|
|
|
202
209
|
return result.join("\n");
|
|
203
210
|
}
|
|
204
211
|
|
|
212
|
+
/**
|
|
213
|
+
* 将单个 \n 转换为 <br>,保留 \n\n 段落分隔。
|
|
214
|
+
*
|
|
215
|
+
* 钉钉 AI Card 渲染器的换行约定:
|
|
216
|
+
* - 普通文本:用 `<br>` 做换行,`\n` 不创建视觉换行
|
|
217
|
+
* - 代码块(```):用 `\n` 做换行,`<br>` 会原样显示为文本
|
|
218
|
+
* - 列表(- / 1.)、表格(|)、标题(#):用 `\n` 做语法行分隔
|
|
219
|
+
* - 引用块(>):用 `<br>` + lazy continuation,续行不需要 `>`
|
|
220
|
+
* - 段落间距:`\n\n`
|
|
221
|
+
*
|
|
222
|
+
* 本函数按上述约定转换:
|
|
223
|
+
* - 代码块内:完全保留原始 `\n`
|
|
224
|
+
* - 连续引用行:合并为一行,`<br>` 连接,去掉续行 `>` 前缀
|
|
225
|
+
* - 其余:Markdown 块语法行前保留 `\n`,单 `\n` → `<br>`
|
|
226
|
+
*/
|
|
227
|
+
export function fixNewlines(text: string): string {
|
|
228
|
+
const normalized = normalizeLineEndings(text);
|
|
229
|
+
const markdownBlockStartPattern =
|
|
230
|
+
/^(\s{0,3}(?:[-*+]|\d+[.)])[ ])|(\s{0,3}\|)|(\s{0,3}#{1,6}\s)|(\s{0,3}(?:[-*_])\s*(?:[-*_])\s*(?:[-*_]))/;
|
|
231
|
+
const fencePattern = /^\s{0,3}```/;
|
|
232
|
+
const quotePattern = /^\s{0,3}>\s?/;
|
|
233
|
+
|
|
234
|
+
// 1. 合并连续引用行:仅在代码块外生效;去掉续行的 > 前缀,用 <br> 连接
|
|
235
|
+
const mergedLines: string[] = [];
|
|
236
|
+
let pendingQuoteLines: string[] = [];
|
|
237
|
+
let inCodeBlock = false;
|
|
238
|
+
const flushPendingQuoteLines = () => {
|
|
239
|
+
if (pendingQuoteLines.length > 0) {
|
|
240
|
+
mergedLines.push(pendingQuoteLines.join("<br>"));
|
|
241
|
+
pendingQuoteLines = [];
|
|
242
|
+
}
|
|
243
|
+
};
|
|
244
|
+
|
|
245
|
+
for (const line of normalized.split("\n")) {
|
|
246
|
+
const isFence = fencePattern.test(line);
|
|
247
|
+
|
|
248
|
+
if (inCodeBlock) {
|
|
249
|
+
flushPendingQuoteLines();
|
|
250
|
+
mergedLines.push(line);
|
|
251
|
+
if (isFence) {
|
|
252
|
+
inCodeBlock = false;
|
|
253
|
+
}
|
|
254
|
+
continue;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
if (isFence) {
|
|
258
|
+
flushPendingQuoteLines();
|
|
259
|
+
mergedLines.push(line);
|
|
260
|
+
inCodeBlock = true;
|
|
261
|
+
continue;
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
if (quotePattern.test(line)) {
|
|
265
|
+
if (pendingQuoteLines.length === 0) {
|
|
266
|
+
pendingQuoteLines.push(line);
|
|
267
|
+
} else {
|
|
268
|
+
pendingQuoteLines.push(line.replace(quotePattern, ""));
|
|
269
|
+
}
|
|
270
|
+
} else {
|
|
271
|
+
flushPendingQuoteLines();
|
|
272
|
+
mergedLines.push(line);
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
flushPendingQuoteLines();
|
|
276
|
+
|
|
277
|
+
// 2. 逐行处理:代码块保留 \n,Markdown 块语法行前保留 \n,其余转 <br>
|
|
278
|
+
const lines = mergedLines;
|
|
279
|
+
inCodeBlock = false;
|
|
280
|
+
const parts: string[] = [];
|
|
281
|
+
|
|
282
|
+
for (let i = 0; i < lines.length; i++) {
|
|
283
|
+
const currentLine = lines[i];
|
|
284
|
+
const nextInCodeBlock = fencePattern.test(currentLine)
|
|
285
|
+
? !inCodeBlock
|
|
286
|
+
: inCodeBlock;
|
|
287
|
+
|
|
288
|
+
if (i < lines.length - 1) {
|
|
289
|
+
const nextLine = lines[i + 1];
|
|
290
|
+
const keepNewline =
|
|
291
|
+
nextInCodeBlock ||
|
|
292
|
+
currentLine === "" ||
|
|
293
|
+
nextLine === "" ||
|
|
294
|
+
fencePattern.test(nextLine) ||
|
|
295
|
+
markdownBlockStartPattern.test(nextLine);
|
|
296
|
+
parts.push(currentLine + (keepNewline ? "\n" : "<br>"));
|
|
297
|
+
} else {
|
|
298
|
+
parts.push(currentLine);
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
inCodeBlock = nextInCodeBlock;
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
return parts.join("");
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
/**
|
|
308
|
+
* 标准化 AI Card 消息内容:先修复表格空行,再处理换行符。
|
|
309
|
+
* 用于 streamAICard 和 finishAICard 的所有路径,确保行为一致。
|
|
310
|
+
*/
|
|
311
|
+
export function normalizeForCard(content: string): string {
|
|
312
|
+
return fixNewlines(ensureTableBlankLines(content));
|
|
313
|
+
}
|
|
314
|
+
|
|
205
315
|
// ============ AI Card 相关 ============
|
|
206
316
|
|
|
207
317
|
/**
|
|
@@ -368,7 +478,7 @@ export async function streamAICard(
|
|
|
368
478
|
cardData: {
|
|
369
479
|
cardParamMap: {
|
|
370
480
|
flowStatus: AICardStatus.INPUTING,
|
|
371
|
-
msgContent: content,
|
|
481
|
+
msgContent: normalizeForCard(content),
|
|
372
482
|
staticMsgContent: "",
|
|
373
483
|
sys_full_json_obj: JSON.stringify({
|
|
374
484
|
order: ["msgContent"],
|
|
@@ -417,12 +527,14 @@ export async function streamAICard(
|
|
|
417
527
|
card.inputingStarted = true;
|
|
418
528
|
}
|
|
419
529
|
|
|
420
|
-
const fixedContent =
|
|
530
|
+
const fixedContent = normalizeForCard(content);
|
|
531
|
+
// 未结束的帧去掉末尾 \n,避免先渲染为 <br> 再被下一帧修正的闪烁
|
|
532
|
+
const streamContent = finished ? fixedContent : fixedContent.replace(/\n+$/, '');
|
|
421
533
|
const body = {
|
|
422
534
|
outTrackId: card.cardInstanceId,
|
|
423
535
|
guid: `${Date.now()}_${Math.random().toString(36).slice(2, 8)}`,
|
|
424
536
|
key: "msgContent",
|
|
425
|
-
content:
|
|
537
|
+
content: streamContent,
|
|
426
538
|
isFull: true,
|
|
427
539
|
isFinalize: finished,
|
|
428
540
|
isError: false,
|
|
@@ -494,7 +606,7 @@ export async function finishAICard(
|
|
|
494
606
|
if (config) {
|
|
495
607
|
await ensureValidToken(card, config);
|
|
496
608
|
}
|
|
497
|
-
const fixedContent =
|
|
609
|
+
const fixedContent = normalizeForCard(content);
|
|
498
610
|
log?.info?.(
|
|
499
611
|
`[DingTalk][AICard] 开始 finish,最终内容长度=${fixedContent.length}`,
|
|
500
612
|
);
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 空回复(final 文本为空)兜底文案集中处。
|
|
3
|
+
*
|
|
4
|
+
* 背景
|
|
5
|
+
* ----
|
|
6
|
+
* 群聊场景下用户 @ 机器人后看到「任务执行完成(无文本输出)」,常见根因不是 connector,
|
|
7
|
+
* 而是上游 OpenClaw 的 reply delivery mode(`source-reply-delivery-mode.ts`):群聊
|
|
8
|
+
* 默认走 `message_tool_only`,会跳过 `onPartialReply` 与 `accumulatedText`,
|
|
9
|
+
* 导致本插件累积的文本始终为空,最后落到 connector 的空回复兜底。
|
|
10
|
+
*
|
|
11
|
+
* 修复路径在 OpenClaw 的 `openclaw.json`:
|
|
12
|
+
* {
|
|
13
|
+
* "messages": {
|
|
14
|
+
* "groupChat": { "visibleReplies": "automatic" }
|
|
15
|
+
* }
|
|
16
|
+
* }
|
|
17
|
+
*
|
|
18
|
+
* 本模块的优化目标:让群聊场景看到的兜底文案变成一句可操作的提示,
|
|
19
|
+
* 并在日志里给运维一份完整指引,而不是一句无信息量的「任务执行完成」。
|
|
20
|
+
* 单聊场景的兜底文案保持原样(单聊空 final 通常是模型自身输出空)。
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
const DIRECT_FALLBACK_TEXT = '✅ 任务执行完成(无文本输出)';
|
|
24
|
+
|
|
25
|
+
const GROUP_FALLBACK_TEXT = [
|
|
26
|
+
'ℹ️ 暂未收到模型回复内容。',
|
|
27
|
+
'若群聊频繁出现该提示,请联系机器人管理员检查 OpenClaw 配置:',
|
|
28
|
+
'`messages.groupChat.visibleReplies` 需设为 `"automatic"`',
|
|
29
|
+
'(详见 README / TROUBLESHOOTING.md)。',
|
|
30
|
+
].join('\n');
|
|
31
|
+
|
|
32
|
+
const GROUP_FALLBACK_LOG_HINT =
|
|
33
|
+
'群聊 final 文本为空:常见根因是 OpenClaw `messages.groupChat.visibleReplies` ' +
|
|
34
|
+
'未设为 "automatic"(上游 source-reply-delivery-mode.ts 默认 message_tool_only, ' +
|
|
35
|
+
'会跳过 partial/accumulated 文本)。' +
|
|
36
|
+
'请在 openclaw.json 中追加:' +
|
|
37
|
+
'{ "messages": { "groupChat": { "visibleReplies": "automatic" } } },' +
|
|
38
|
+
'然后 `openclaw gateway restart`。详见 docs/TROUBLESHOOTING.md。';
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* 选取空回复的兜底文案。
|
|
42
|
+
*
|
|
43
|
+
* - 群聊:附带修复指引(指向 OpenClaw `messages.groupChat.visibleReplies` 配置)。
|
|
44
|
+
* - 单聊:维持原文案,避免对模型本身就输出空的常规场景产生噪音。
|
|
45
|
+
*/
|
|
46
|
+
export function pickEmptyReplyFallbackText(isGroup: boolean): string {
|
|
47
|
+
return isGroup ? GROUP_FALLBACK_TEXT : DIRECT_FALLBACK_TEXT;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* 群聊空回复时给运维的 warn 级别日志指引(含 openclaw.json 修复片段)。
|
|
52
|
+
* 单聊不需要这条 hint,因为单聊空回复多半与配置无关。
|
|
53
|
+
*/
|
|
54
|
+
export function emptyGroupReplyLogHint(): string {
|
|
55
|
+
return GROUP_FALLBACK_LOG_HINT;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* 群聊是否未显式将 OpenClaw `messages.groupChat.visibleReplies` 设为 `"automatic"`。
|
|
60
|
+
*
|
|
61
|
+
* `undefined` / 缺失 / `messages: {}` 均视为未开启(与上游默认 `message_tool_only` 行为一致),
|
|
62
|
+
* 需要 connector 在「本轮无任何用户可见回复」时用 idle 兜底提示配置。
|
|
63
|
+
*/
|
|
64
|
+
export function groupChatLacksVisibleRepliesAutomatic(cfg: any): boolean {
|
|
65
|
+
return cfg?.messages?.groupChat?.visibleReplies !== 'automatic';
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export const __testables = {
|
|
69
|
+
DIRECT_FALLBACK_TEXT,
|
|
70
|
+
GROUP_FALLBACK_TEXT,
|
|
71
|
+
GROUP_FALLBACK_LOG_HINT,
|
|
72
|
+
};
|
package/src/utils/index.ts
CHANGED