@jeik/dingtalk-connector 0.8.27 → 0.8.29
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 +48 -0
- package/README.en.md +40 -82
- package/README.md +50 -93
- package/dist/background-work-DkaSDqWk.mjs +53 -0
- package/dist/card-content-cache-C7-XHhHw.mjs +2 -0
- package/dist/{card-content-cache-CYUzCaQJ.mjs → card-content-cache-DT_OYThh.mjs} +15 -1
- package/dist/chunk-upload-BKM1y4Ba.mjs +2 -0
- package/dist/{chunk-upload-BBQgGtcZ.mjs → chunk-upload-CIU5G_ay.mjs} +1 -1
- package/dist/{common-DE0gd0Jg.mjs → common-BfIYs1yC.mjs} +3 -3
- package/dist/{common-B_A78n8u.mjs → common-C0Oaf0z-.mjs} +1 -1
- package/dist/connection-obtrPkj4.mjs +641 -0
- package/dist/entry-bundled.mjs +1 -1
- package/dist/{gateway-methods--aKsyMs-.mjs → gateway-methods-BdcpfxEZ.mjs} +1 -1
- package/dist/gateway-methods-DQ6TwOAr.mjs +2 -0
- package/dist/index.mjs +2 -2
- package/dist/{logger-mZ9OSbmD.mjs → logger-CnBTcwyq.mjs} +16 -2
- package/dist/logger-D8H2IcYz.mjs +2 -0
- package/dist/{media-BqpUGCni.mjs → media-DDtX37T2.mjs} +1 -1
- package/dist/{media-DnQB2BrL.mjs → media-g8tJtpfU.mjs} +29 -20
- package/dist/{message-handler-DQZiKx3i.mjs → message-handler-BrO2i7tg.mjs} +251 -139
- package/dist/{messaging-WleKf54h.mjs → messaging-CK0Wz3Ze.mjs} +23 -21
- package/dist/{runtime-DzZo6Gvk.mjs → runtime-DbqGRyhI.mjs} +6 -5
- package/dist/{utils-Bjh4r_qS.mjs → utils-Bwz2Fls_.mjs} +1 -1
- package/docs/RELEASE_NOTES_V0.8.28.md +56 -0
- package/docs/RELEASE_NOTES_V0.8.29.md +57 -0
- package/openclaw.plugin.json +9 -5
- package/package.json +2 -2
- package/src/config/schema.ts +2 -1
- package/src/core/connection.ts +504 -220
- package/src/core/message-handler.ts +197 -102
- package/src/reply-dispatcher.ts +244 -93
- package/src/services/media.ts +60 -27
- package/src/services/messaging/card-content-cache.ts +11 -6
- package/src/services/messaging/card.ts +2 -0
- package/src/services/messaging.ts +52 -38
- package/src/utils/background-work.ts +65 -0
- package/src/utils/logger.ts +23 -2
- package/dist/card-content-cache-Dwd8HICP.mjs +0 -2
- package/dist/chunk-upload-DaLXXZH3.mjs +0 -2
- package/dist/connection-fc6B4z4G.mjs +0 -423
- package/dist/gateway-methods-BQd4E0Sk.mjs +0 -2
- package/dist/logger-BmJkQkm1.mjs +0 -2
- /package/dist/{game-xiyou-CAT0ilSj.mjs → game-xiyou-uwsv23AC.mjs} +0 -0
package/src/reply-dispatcher.ts
CHANGED
|
@@ -26,7 +26,7 @@ const {
|
|
|
26
26
|
logTypingFailure,
|
|
27
27
|
} = channelRuntimeModule;
|
|
28
28
|
|
|
29
|
-
import { createLoggerFromConfig } from "./utils/logger.ts";
|
|
29
|
+
import { createLoggerFromConfig, isDingtalkDebug } from "./utils/logger.ts";
|
|
30
30
|
import { CHANNEL_ID } from "./channel.ts";
|
|
31
31
|
import { resolveDingtalkAccount } from "./config/accounts.ts";
|
|
32
32
|
import { getDingtalkRuntime } from "./runtime.ts";
|
|
@@ -302,11 +302,71 @@ export function createDingtalkReplyDispatcher(params: CreateDingtalkReplyDispatc
|
|
|
302
302
|
const log = createLoggerFromConfig(account.config, `DingTalk:${accountId}`);
|
|
303
303
|
|
|
304
304
|
// AI Card 状态管理
|
|
305
|
-
|
|
305
|
+
// 即时建卡(earlyCard/preCreatedCard)在 message-handler 已创建并显示「正在召唤大模型」。
|
|
306
|
+
// 必须在 dispatcher 启动时就挂到 currentCardTarget,否则模型在 startStreaming 之前失败时
|
|
307
|
+
// onIdle/closeStreaming 会因「无 AI Card」直接跳过,群聊卡永久停在 INPUTING。
|
|
308
|
+
let currentCardTarget: AICardInstance | null = preCreatedCard ?? null;
|
|
306
309
|
let accumulatedText = "";
|
|
307
310
|
const deliveredFinalTexts = new Set<string>();
|
|
308
311
|
// 防止 startStreaming 在 closeStreaming 之后重新创建新卡片(会导致多余 AI Card)
|
|
309
312
|
let sessionClosed = false;
|
|
313
|
+
/**
|
|
314
|
+
* 群聊 fail-before-reply 时 OpenClaw 会把用户可见文案收成 NO_REPLY(静默),
|
|
315
|
+
* 但 payload 仍带 rawError/isError。normalize 会在 deliver 前丢掉整包,
|
|
316
|
+
* 导致 closeStreaming 空文本 → answerCard 误定格「思考完成」。
|
|
317
|
+
* 用 onSkip 捕获后写入此处,供 closeStreaming 定稿错误卡。
|
|
318
|
+
*/
|
|
319
|
+
let pendingErrorText: string | null = null;
|
|
320
|
+
if (preCreatedCard && !isDirect) {
|
|
321
|
+
registerActiveCard(conversationId, preCreatedCard);
|
|
322
|
+
}
|
|
323
|
+
if (preCreatedCard) {
|
|
324
|
+
log.info(
|
|
325
|
+
`[DingTalk] 启动即挂接即时建卡 currentCardTarget=${preCreatedCard.cardInstanceId} isDirect=${isDirect}`,
|
|
326
|
+
);
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
/** 从 isError/rawError payload 提取中文错误文案(含被静默丢弃的群聊失败) */
|
|
330
|
+
const captureErrorFromPayload = (
|
|
331
|
+
payload: unknown,
|
|
332
|
+
source: string,
|
|
333
|
+
): string | null => {
|
|
334
|
+
const p = payload as {
|
|
335
|
+
text?: unknown;
|
|
336
|
+
isError?: unknown;
|
|
337
|
+
rawError?: unknown;
|
|
338
|
+
} | null;
|
|
339
|
+
if (!p) return null;
|
|
340
|
+
const raw = readPayloadRawError(p);
|
|
341
|
+
const text = typeof p.text === "string" ? p.text.trim() : "";
|
|
342
|
+
const looksSilent =
|
|
343
|
+
!text ||
|
|
344
|
+
/^NO_REPLY$/i.test(text) ||
|
|
345
|
+
text.split(/\s+/).every((t) => /^NO_REPLY$/i.test(t));
|
|
346
|
+
|
|
347
|
+
let errorText: string | null = null;
|
|
348
|
+
if (raw) {
|
|
349
|
+
errorText = matchModelErrorText(raw, { includeCatchAll: true });
|
|
350
|
+
}
|
|
351
|
+
if (!errorText && p.isError && text && !looksSilent) {
|
|
352
|
+
errorText =
|
|
353
|
+
matchModelErrorText(text, { includeCatchAll: true }) ?? text;
|
|
354
|
+
}
|
|
355
|
+
if (!errorText && (raw || p.isError)) {
|
|
356
|
+
errorText = MODEL_ERROR_GENERIC;
|
|
357
|
+
}
|
|
358
|
+
if (!errorText) return null;
|
|
359
|
+
|
|
360
|
+
pendingErrorText = errorText;
|
|
361
|
+
// 错误文案优先覆盖空/占位累积,供 closeStreaming 使用
|
|
362
|
+
if (!accumulatedText.trim() || looksSilent) {
|
|
363
|
+
accumulatedText = errorText;
|
|
364
|
+
}
|
|
365
|
+
log.info(
|
|
366
|
+
`[DingTalk][errorCapture] source=${source} len=${errorText.length} preview=${errorText.slice(0, 60)}`,
|
|
367
|
+
);
|
|
368
|
+
return errorText;
|
|
369
|
+
};
|
|
310
370
|
|
|
311
371
|
// 异步模式:累积完整响应
|
|
312
372
|
let asyncModeFullResponse = "";
|
|
@@ -545,12 +605,13 @@ export function createDingtalkReplyDispatcher(params: CreateDingtalkReplyDispatc
|
|
|
545
605
|
}
|
|
546
606
|
};
|
|
547
607
|
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
});
|
|
553
|
-
|
|
608
|
+
// 串行链:失败只记日志不阻断后续写;但本次 await 的 Promise 仍要 reject,
|
|
609
|
+
// 以便 onPartialReply 等调用方能走非 QPS 错误的兜底提示。
|
|
610
|
+
const writePromise = streamWriteChain.then(() => runWrite(force));
|
|
611
|
+
streamWriteChain = writePromise.catch((e) => {
|
|
612
|
+
log.warn(`[DingTalk][stream] 队列任务失败(不中断后续):${e?.message || e}`);
|
|
613
|
+
});
|
|
614
|
+
return writePromise;
|
|
554
615
|
};
|
|
555
616
|
|
|
556
617
|
/** 定稿前:取消尾随定时器,强制推送全文并等待队列排空 */
|
|
@@ -784,12 +845,22 @@ export function createDingtalkReplyDispatcher(params: CreateDingtalkReplyDispatc
|
|
|
784
845
|
await streamWriteChain;
|
|
785
846
|
|
|
786
847
|
// 选定最终答案:marker 优先 → lastAnswerText/accumulatedText 取更长 → 剥离尾标记
|
|
848
|
+
// 群聊模型失败被 OpenClaw 静默(NO_REPLY)时,用 onSkip 捕获的 pendingErrorText
|
|
787
849
|
let finalText = finalClean(pickFinalText());
|
|
850
|
+
if (!finalText.trim() && pendingErrorText) {
|
|
851
|
+
finalText = pendingErrorText;
|
|
852
|
+
log.info(
|
|
853
|
+
`[DingTalk][closeStreaming] 使用 pendingErrorText 定稿(群聊静默失败恢复),len=${finalText.length}`,
|
|
854
|
+
);
|
|
855
|
+
}
|
|
788
856
|
// 是否有真实对话答案(在套兜底文案之前判断)。纯工具进度/无回复时为 false,
|
|
789
857
|
// 用于 answerCard 模式下决定"不另建无文本输出答案卡"。
|
|
858
|
+
// 错误文案也算「真实内容」——绝不能走「思考完成」空答案分支。
|
|
790
859
|
const hadRealAnswer = finalText.trim().length > 0;
|
|
860
|
+
// pendingErrorText 非空 ⇒ 本轮是模型/上游异常定稿,禁止走「思考完成」
|
|
861
|
+
const isErrorFinal = Boolean(pendingErrorText);
|
|
791
862
|
log.info(
|
|
792
|
-
`[DingTalk][closeStreaming] 最终答案来源=${finalMarkedText !== null ? "marker[-final-]" : (lastAnswerText ? "非reasoning/累积取长" : "accumulatedText兜底")},长度=${finalText.length},lastAnswerLen=${lastAnswerText.length},accLen=${accumulatedText.length}`,
|
|
863
|
+
`[DingTalk][closeStreaming] 最终答案来源=${finalMarkedText !== null ? "marker[-final-]" : isErrorFinal ? "pendingErrorText" : (lastAnswerText ? "非reasoning/累积取长" : "accumulatedText兜底")},长度=${finalText.length},lastAnswerLen=${lastAnswerText.length},accLen=${accumulatedText.length},isErrorFinal=${isErrorFinal}`,
|
|
793
864
|
);
|
|
794
865
|
|
|
795
866
|
// ✅ 如果累积的文本为空,使用默认提示文案
|
|
@@ -806,30 +877,26 @@ export function createDingtalkReplyDispatcher(params: CreateDingtalkReplyDispatc
|
|
|
806
877
|
? { type: 'user', userId: senderId }
|
|
807
878
|
: { type: 'group', openConversationId: conversationId };
|
|
808
879
|
|
|
809
|
-
//
|
|
810
|
-
console.log(
|
|
811
|
-
`[DingTalk][LocalImage] closeStreaming 开始媒体处理 | target=${JSON.stringify(target)} hasToken=${!!oapiToken} textLen=${finalText.length}`,
|
|
812
|
-
);
|
|
813
|
-
// 预览正文中是否含 
|
|
814
881
|
const mdImgCount = (finalText.match(/!\[[^\]]*\]\([^)]+\)/g) || []).length;
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
882
|
+
if (mdImgCount > 0 || isDingtalkDebug()) {
|
|
883
|
+
log.info(
|
|
884
|
+
`[DingTalk][LocalImage] closeStreaming 媒体处理 | target=${JSON.stringify(target)} hasToken=${!!oapiToken} textLen=${finalText.length} mdImgs=${mdImgCount}`,
|
|
885
|
+
);
|
|
886
|
+
}
|
|
887
|
+
|
|
819
888
|
if (oapiToken) {
|
|
820
|
-
// 处理本地图片(含 /mnt 共享盘;失败会 /tmp 重试,避免留下本地路径灰图)
|
|
821
889
|
const beforeImg = finalText;
|
|
822
890
|
finalText = await processLocalImages(finalText, oapiToken, log);
|
|
823
891
|
if (beforeImg !== finalText) {
|
|
824
|
-
|
|
825
|
-
`[DingTalk][LocalImage] closeStreaming
|
|
892
|
+
log.info(
|
|
893
|
+
`[DingTalk][LocalImage] closeStreaming 图片已处理 | ${beforeImg.length}→${finalText.length}`,
|
|
826
894
|
);
|
|
827
895
|
}
|
|
828
|
-
// 仅统计代码块外的本地 ![]——示例 JSON/参数说明里的路径不算灰图风险
|
|
829
896
|
try {
|
|
830
897
|
const { hasResidualLocalMdImagesOutsideCode } = await import("./services/media.ts");
|
|
831
898
|
if (hasResidualLocalMdImagesOutsideCode(finalText)) {
|
|
832
|
-
|
|
899
|
+
log.warn(
|
|
833
900
|
`[DingTalk][LocalImage] closeStreaming 定稿后仍有代码块外的本地 MD 图未上传,钉钉可能灰图`,
|
|
834
901
|
);
|
|
835
902
|
}
|
|
@@ -867,7 +934,6 @@ export function createDingtalkReplyDispatcher(params: CreateDingtalkReplyDispatc
|
|
|
867
934
|
);
|
|
868
935
|
|
|
869
936
|
// ✅ 处理裸露的本地文件路径(绕过 OpenClaw SDK 的 bug)
|
|
870
|
-
log.info(`[DingTalk][closeStreaming] 准备调用 processRawMediaPaths`);
|
|
871
937
|
const { processRawMediaPaths } = await import('./services/media');
|
|
872
938
|
finalText = await processRawMediaPaths(
|
|
873
939
|
finalText,
|
|
@@ -956,11 +1022,35 @@ export function createDingtalkReplyDispatcher(params: CreateDingtalkReplyDispatc
|
|
|
956
1022
|
const answerActToken = Number((account.config as any)?.answerActToken) || 500;
|
|
957
1023
|
const answerTokens = estimateTokens(finalText);
|
|
958
1024
|
|
|
959
|
-
|
|
1025
|
+
// ⚠️ 分支顺序有意为之,勿合并:
|
|
1026
|
+
// 1) 错误 final → 单卡定错误文案(绝不能「思考完成」/双卡)
|
|
1027
|
+
// 2) 无真实答案 → 原卡「思考完成」(纯工具轮等)
|
|
1028
|
+
// 3) token ≤ answerActToken → 原卡直接定稿(日常短答)
|
|
1029
|
+
// 4) token > answerActToken → 原卡思考完成 + 新建答案卡(大答案机制,不可破坏)
|
|
1030
|
+
if (isErrorFinal) {
|
|
1031
|
+
log.info(
|
|
1032
|
+
`[DingTalk][closeStreaming] answerCard 模式:错误定稿原卡(len=${finalText.length}),不建答案卡、不定格思考完成`,
|
|
1033
|
+
);
|
|
1034
|
+
try {
|
|
1035
|
+
await flushCardStream(finalText, cardSnapshot as any);
|
|
1036
|
+
} catch (e: any) {
|
|
1037
|
+
log.warn(`[DingTalk][closeStreaming] 错误终稿 flush 失败(继续 FINISH):${e?.message || e}`);
|
|
1038
|
+
}
|
|
1039
|
+
await finishAICard(
|
|
1040
|
+
cardSnapshot as any,
|
|
1041
|
+
finalText,
|
|
1042
|
+
account.config as DingtalkConfig,
|
|
1043
|
+
log,
|
|
1044
|
+
undefined,
|
|
1045
|
+
undefined,
|
|
1046
|
+
conversationId,
|
|
1047
|
+
);
|
|
1048
|
+
} else if (!hadRealAnswer) {
|
|
1049
|
+
// 无真实答案:原卡定格「思考完成」(纯工具轮 / 被静默的 NO_REPLY 等)
|
|
960
1050
|
log.info(`[DingTalk][closeStreaming] answerCard 模式:无真实答案,仅定格原卡思考完成(不建答案卡)`);
|
|
961
1051
|
await finalizeOriginalToDone();
|
|
962
1052
|
} else if (answerTokens <= answerActToken) {
|
|
963
|
-
//
|
|
1053
|
+
// 小答案:单卡定稿(不新建答案卡)—— 与错误路径分离,保证阈值逻辑独立
|
|
964
1054
|
log.info(`[DingTalk][closeStreaming] answerCard 模式:答案约 ${answerTokens} token ≤ ${answerActToken},原卡直接定稿(不建答案卡)`);
|
|
965
1055
|
try {
|
|
966
1056
|
await flushCardStream(finalText, cardSnapshot as any);
|
|
@@ -977,7 +1067,7 @@ export function createDingtalkReplyDispatcher(params: CreateDingtalkReplyDispatc
|
|
|
977
1067
|
conversationId,
|
|
978
1068
|
);
|
|
979
1069
|
} else {
|
|
980
|
-
// 大答案:原卡思考完成 +
|
|
1070
|
+
// 大答案:原卡思考完成 + 新建答案卡投全文(双卡机制,保留,勿改阈值语义)
|
|
981
1071
|
log.info(`[DingTalk][closeStreaming] answerCard 模式:答案约 ${answerTokens} token > ${answerActToken},原卡思考完成 + 新建答案卡(模板=${answerTplId})`);
|
|
982
1072
|
// 注意:此处故意不把 finalText flush 到原卡,避免长文在原卡慢速流式,再被思考完成盖掉
|
|
983
1073
|
await finalizeOriginalToDone();
|
|
@@ -1064,16 +1154,109 @@ export function createDingtalkReplyDispatcher(params: CreateDingtalkReplyDispatc
|
|
|
1064
1154
|
}
|
|
1065
1155
|
};
|
|
1066
1156
|
|
|
1157
|
+
/**
|
|
1158
|
+
* 统一错误定稿:模型/dispatch 异常时把中文提示写到 AI Card 并 FINISHED。
|
|
1159
|
+
* 供 dispatcher onError 与 message-handler catch(同步 throw)共用。
|
|
1160
|
+
* 不依赖 OpenClaw 返回的 replyOptions.onError(SDK 根本不导出该字段)。
|
|
1161
|
+
*/
|
|
1162
|
+
const finalizeDispatchError = async (
|
|
1163
|
+
error: unknown,
|
|
1164
|
+
kind: string = "invoke",
|
|
1165
|
+
): Promise<void> => {
|
|
1166
|
+
const errorMsg = String(error);
|
|
1167
|
+
log.error(`[DingTalk][finalizeDispatchError] kind=${kind} failed: ${errorMsg}`);
|
|
1168
|
+
|
|
1169
|
+
// 确保卡片已就绪(错误可能在 startStreaming 之前发生)
|
|
1170
|
+
await startStreaming();
|
|
1171
|
+
|
|
1172
|
+
const errorText =
|
|
1173
|
+
matchModelErrorText(errorMsg, { includeCatchAll: true }) ??
|
|
1174
|
+
"⚠️ 模型请求异常,请稍后重试";
|
|
1175
|
+
log.warn(`[DingTalk][finalizeDispatchError] 错误提示: ${errorText.slice(0, 80)}`);
|
|
1176
|
+
|
|
1177
|
+
// 错误文案覆盖既有内容,保证 closeStreaming 定稿的是异常提示而非空/占位
|
|
1178
|
+
pendingErrorText = errorText;
|
|
1179
|
+
accumulatedText = errorText;
|
|
1180
|
+
|
|
1181
|
+
// 卡片还活着 → closeStreaming 会用 accumulatedText 定稿。
|
|
1182
|
+
// 卡片已关 → 跳过,避免对同一张卡重复 finish。
|
|
1183
|
+
// 仅当会话未关闭且卡片引用丢失时恢复 preCreatedCard。
|
|
1184
|
+
if (!currentCardTarget && !sessionClosed) {
|
|
1185
|
+
if (preCreatedCard) {
|
|
1186
|
+
try {
|
|
1187
|
+
currentCardTarget = preCreatedCard;
|
|
1188
|
+
if (!isDirect) {
|
|
1189
|
+
registerActiveCard(conversationId, preCreatedCard);
|
|
1190
|
+
}
|
|
1191
|
+
log.info(
|
|
1192
|
+
`[DingTalk][finalizeDispatchError] 复用即时建卡显示错误(cardInstanceId=${preCreatedCard.cardInstanceId})`,
|
|
1193
|
+
);
|
|
1194
|
+
} catch (cardErr: any) {
|
|
1195
|
+
log.warn(
|
|
1196
|
+
`[DingTalk][finalizeDispatchError] 复用即时建卡失败:${cardErr.message},降级发送普通消息`,
|
|
1197
|
+
);
|
|
1198
|
+
try {
|
|
1199
|
+
await sendMessage(
|
|
1200
|
+
account.config as DingtalkConfig,
|
|
1201
|
+
sessionWebhook,
|
|
1202
|
+
accumulatedText,
|
|
1203
|
+
{ useMarkdown: false, log: params.runtime.log },
|
|
1204
|
+
);
|
|
1205
|
+
} catch (sendErr: any) {
|
|
1206
|
+
log.error(
|
|
1207
|
+
`[DingTalk][finalizeDispatchError] 降级发送普通消息失败:${sendErr.message}`,
|
|
1208
|
+
);
|
|
1209
|
+
}
|
|
1210
|
+
}
|
|
1211
|
+
} else {
|
|
1212
|
+
log.warn(`[DingTalk][finalizeDispatchError] 无可用 AI Card,降级发送普通消息`);
|
|
1213
|
+
try {
|
|
1214
|
+
await sendMessage(
|
|
1215
|
+
account.config as DingtalkConfig,
|
|
1216
|
+
sessionWebhook,
|
|
1217
|
+
accumulatedText,
|
|
1218
|
+
{ useMarkdown: false, log: params.runtime.log },
|
|
1219
|
+
);
|
|
1220
|
+
} catch (sendErr: any) {
|
|
1221
|
+
log.error(
|
|
1222
|
+
`[DingTalk][finalizeDispatchError] 降级发送普通消息失败:${sendErr.message}`,
|
|
1223
|
+
);
|
|
1224
|
+
}
|
|
1225
|
+
}
|
|
1226
|
+
}
|
|
1227
|
+
|
|
1228
|
+
await closeStreaming();
|
|
1229
|
+
};
|
|
1230
|
+
|
|
1067
1231
|
const { dispatcher, replyOptions, markDispatchIdle } =
|
|
1068
1232
|
core.channel.reply.createReplyDispatcherWithTyping({
|
|
1069
1233
|
...prefixOptions,
|
|
1070
1234
|
humanDelay: core.channel.reply.resolveHumanDelayConfig(cfg, agentId),
|
|
1235
|
+
// OpenClaw 官方钩子:静默/空包在 deliver 前被丢掉时回调(含群聊 fail-before-reply → NO_REPLY)。
|
|
1236
|
+
// 此时 payload 仍带 rawError/isError,必须在此捕获,否则 closeStreaming 会空定稿成「思考完成」。
|
|
1237
|
+
onSkip: (payload: unknown, info: { kind?: string; reason?: string }) => {
|
|
1238
|
+
const raw = readPayloadRawError(payload);
|
|
1239
|
+
const isErr = Boolean((payload as { isError?: unknown } | null)?.isError);
|
|
1240
|
+
// 仅拦截「失败类」静默:isError / rawError。
|
|
1241
|
+
// 模型主动 NO_REPLY(无错误)保持静默——不在此伪造错误文案,避免群聊刷屏。
|
|
1242
|
+
// 此时 earlyCard 仍会由 onIdle→closeStreaming 收尾(思考完成 / 无文本),不会永久转圈。
|
|
1243
|
+
if (!raw && !isErr) {
|
|
1244
|
+
// 模型主动 NO_REPLY:静默,不打日志
|
|
1245
|
+
return;
|
|
1246
|
+
}
|
|
1247
|
+
// 失败类静默:保留一行关键 warn(避免与 closeStreaming 重复刷堆栈)
|
|
1248
|
+
log.warn(
|
|
1249
|
+
`[DingTalk][onSkip] 群聊静默失败 kind=${info?.kind} reason=${info?.reason} rawLen=${raw.length}`,
|
|
1250
|
+
);
|
|
1251
|
+
captureErrorFromPayload(payload, `onSkip:${info?.kind ?? "?"}:${info?.reason ?? "?"}`);
|
|
1252
|
+
},
|
|
1071
1253
|
onReplyStart: () => {
|
|
1072
1254
|
log.info(`[DingTalk][onReplyStart] 开始回复,流式 enabled=${streamingEnabled}`);
|
|
1073
1255
|
// 每次 onReplyStart 都是全新的回复周期,清空去重集合 + 标记认定状态
|
|
1074
1256
|
deliveredFinalTexts.clear();
|
|
1075
1257
|
finalMarkedText = null;
|
|
1076
1258
|
lastAnswerText = "";
|
|
1259
|
+
pendingErrorText = null;
|
|
1077
1260
|
processMarkerLogged = false;
|
|
1078
1261
|
finalMarkerLogged = false;
|
|
1079
1262
|
markerSystemActive = false;
|
|
@@ -1096,6 +1279,7 @@ export function createDingtalkReplyDispatcher(params: CreateDingtalkReplyDispatc
|
|
|
1096
1279
|
// rawError 为空 = 正常回复 / 无原始错误,绝不匹配,避免误伤。
|
|
1097
1280
|
const rawError = readPayloadRawError(payload);
|
|
1098
1281
|
if (rawError) {
|
|
1282
|
+
captureErrorFromPayload(payload, `deliver:${info?.kind ?? "?"}`);
|
|
1099
1283
|
const matchedErrorText = matchModelErrorText(rawError, {
|
|
1100
1284
|
includeCatchAll: true,
|
|
1101
1285
|
});
|
|
@@ -1120,19 +1304,40 @@ export function createDingtalkReplyDispatcher(params: CreateDingtalkReplyDispatc
|
|
|
1120
1304
|
// 工具失败/状态通知 payload:流式时可短暂展示,但绝不计入最终答案(不设 accumulatedText / 不当 final)。
|
|
1121
1305
|
// 修复:dws 等工具调用失败的结果偶发被当成最终答案、提前停渲染。
|
|
1122
1306
|
if (isNonAnswerPayload(payload)) {
|
|
1307
|
+
const isModelErrorFinal =
|
|
1308
|
+
Boolean((payload as any).isError) && info?.kind === "final" && Boolean(text.trim());
|
|
1309
|
+
|
|
1310
|
+
// ✅ 上游模型异常 final:必须写入 accumulatedText,供 closeStreaming 定稿。
|
|
1311
|
+
// 工具失败 (kind=tool) 仍不计入最终答案。
|
|
1312
|
+
if (isModelErrorFinal) {
|
|
1313
|
+
accumulatedText = text;
|
|
1314
|
+
log.info(
|
|
1315
|
+
`[DingTalk][deliver] 将上游异常 final 文本存入 accumulatedText(len=${text.length}),防止 onIdle 覆盖`,
|
|
1316
|
+
);
|
|
1317
|
+
} else if ((payload as any).isError && !accumulatedText.trim() && text.trim()) {
|
|
1318
|
+
accumulatedText = text;
|
|
1319
|
+
log.info(
|
|
1320
|
+
`[DingTalk][deliver] 将上游异常 payload 文本存入 accumulatedText(len=${text.length}),防止 onIdle 覆盖`,
|
|
1321
|
+
);
|
|
1322
|
+
}
|
|
1323
|
+
|
|
1123
1324
|
if (streamingEnabled && currentCardTarget && !asyncMode && finalMarkedText === null) {
|
|
1124
1325
|
try {
|
|
1125
|
-
|
|
1326
|
+
// 模型错误 final 强制刷卡,避免节流丢最后一帧,群聊卡仍停在「正在召唤大模型」
|
|
1327
|
+
await enqueueCardStream(displayClean(text), {
|
|
1328
|
+
force: isModelErrorFinal,
|
|
1329
|
+
});
|
|
1126
1330
|
} catch (e: any) {
|
|
1127
1331
|
if (!isQpsLimitError(e)) log.warn(`[DingTalk][deliver] 状态/错误 payload 写卡失败:${e?.message || e}`);
|
|
1128
1332
|
}
|
|
1129
1333
|
}
|
|
1130
|
-
|
|
1131
|
-
//
|
|
1132
|
-
if (
|
|
1133
|
-
|
|
1134
|
-
|
|
1334
|
+
|
|
1335
|
+
// 模型错误 final:主动定稿,不单靠 onIdle(群聊 throw/无 partial 时 onIdle 可能找不到卡)
|
|
1336
|
+
if (isModelErrorFinal) {
|
|
1337
|
+
log.info(`[DingTalk][deliver] 上游 isError final,主动 closeStreaming 定稿错误卡`);
|
|
1338
|
+
await closeStreaming();
|
|
1135
1339
|
}
|
|
1340
|
+
|
|
1136
1341
|
log.info(`[DingTalk][deliver] 非答案 payload(isError=${(payload as any).isError},isStatusNotice=${(payload as any).isStatusNotice}),仅展示不计入最终答案`);
|
|
1137
1342
|
return;
|
|
1138
1343
|
}
|
|
@@ -1320,68 +1525,11 @@ export function createDingtalkReplyDispatcher(params: CreateDingtalkReplyDispatc
|
|
|
1320
1525
|
}
|
|
1321
1526
|
},
|
|
1322
1527
|
onError: async (error, info) => {
|
|
1323
|
-
|
|
1324
|
-
log.error(`[DingTalk][onError] ${info.kind} reply failed: ${errorMsg}`);
|
|
1528
|
+
log.error(`[DingTalk][onError] ${info.kind} reply failed: ${String(error)}`);
|
|
1325
1529
|
params.runtime.error?.(
|
|
1326
|
-
`dingtalk[${account.accountId}] ${info.kind} reply failed: ${
|
|
1530
|
+
`dingtalk[${account.accountId}] ${info.kind} reply failed: ${String(error)}`
|
|
1327
1531
|
);
|
|
1328
|
-
|
|
1329
|
-
// 确保卡片已就绪(错误可能在 startStreaming 之前发生)
|
|
1330
|
-
await startStreaming();
|
|
1331
|
-
|
|
1332
|
-
// 上游模型异常:按错误类型匹配中文提示(与 deliver 共用完整规则表)
|
|
1333
|
-
const errorText =
|
|
1334
|
-
matchModelErrorText(errorMsg, { includeCatchAll: true }) ??
|
|
1335
|
-
"⚠️ 模型请求异常,请稍后重试";
|
|
1336
|
-
log.warn(`[DingTalk][onError] 错误提示: ${errorText.slice(0, 80)}`);
|
|
1337
|
-
|
|
1338
|
-
// 始终写入 accumulatedText(即使 currentCardTarget 已被 onIdle 清空)
|
|
1339
|
-
if (!accumulatedText.trim()) {
|
|
1340
|
-
accumulatedText = errorText;
|
|
1341
|
-
}
|
|
1342
|
-
|
|
1343
|
-
// 卡片还活着 → closeStreaming 会用 accumulatedText 定稿到卡片。
|
|
1344
|
-
// 卡片已关(onIdle 先触发且已复用 preCreatedCard 关闭了会话)→ 跳过,
|
|
1345
|
-
// 避免对同一张卡片重复调用 closeStreaming 导致钉钉 API 报错。
|
|
1346
|
-
// 仅当会话尚未被关闭且卡片引用已丢失时才尝试恢复卡片引用。
|
|
1347
|
-
if (!currentCardTarget && !sessionClosed) {
|
|
1348
|
-
if (preCreatedCard) {
|
|
1349
|
-
// 即时建卡已创建但 startStreaming 未调用(或失败),直接复用
|
|
1350
|
-
try {
|
|
1351
|
-
currentCardTarget = preCreatedCard as any;
|
|
1352
|
-
if (!isDirect) {
|
|
1353
|
-
registerActiveCard(conversationId, preCreatedCard);
|
|
1354
|
-
}
|
|
1355
|
-
log.info(`[DingTalk][onError] 复用即时建卡显示错误(cardInstanceId=${preCreatedCard.cardInstanceId})`);
|
|
1356
|
-
} catch (cardErr: any) {
|
|
1357
|
-
log.warn(`[DingTalk][onError] 复用即时建卡失败:${cardErr.message},降级发送普通消息`);
|
|
1358
|
-
try {
|
|
1359
|
-
await sendMessage(
|
|
1360
|
-
account.config as DingtalkConfig,
|
|
1361
|
-
sessionWebhook,
|
|
1362
|
-
accumulatedText,
|
|
1363
|
-
{ useMarkdown: false, log: params.runtime.log }
|
|
1364
|
-
);
|
|
1365
|
-
} catch (sendErr: any) {
|
|
1366
|
-
log.error(`[DingTalk][onError] 降级发送普通消息失败:${sendErr.message}`);
|
|
1367
|
-
}
|
|
1368
|
-
}
|
|
1369
|
-
} else {
|
|
1370
|
-
log.warn(`[DingTalk][onError] 卡片已关闭且无即时建卡,降级发送普通消息`);
|
|
1371
|
-
try {
|
|
1372
|
-
await sendMessage(
|
|
1373
|
-
account.config as DingtalkConfig,
|
|
1374
|
-
sessionWebhook,
|
|
1375
|
-
accumulatedText,
|
|
1376
|
-
{ useMarkdown: false, log: params.runtime.log }
|
|
1377
|
-
);
|
|
1378
|
-
} catch (sendErr: any) {
|
|
1379
|
-
log.error(`[DingTalk][onError] 降级发送普通消息失败:${sendErr.message}`);
|
|
1380
|
-
}
|
|
1381
|
-
}
|
|
1382
|
-
}
|
|
1383
|
-
|
|
1384
|
-
await closeStreaming();
|
|
1532
|
+
await finalizeDispatchError(error, info.kind);
|
|
1385
1533
|
typingCallbacks.onIdle?.();
|
|
1386
1534
|
},
|
|
1387
1535
|
onIdle: async () => {
|
|
@@ -1397,6 +1545,7 @@ export function createDingtalkReplyDispatcher(params: CreateDingtalkReplyDispatc
|
|
|
1397
1545
|
|
|
1398
1546
|
// 构建完整的 replyOptions:replyOptions 只包含 onReplyStart、onTypingController、onTypingCleanup
|
|
1399
1547
|
// deliver、onError、onIdle、onCleanup 等回调已经在 createReplyDispatcherWithTyping 的参数中定义
|
|
1548
|
+
// finalizeDispatchError 单独导出:OpenClaw 的 replyOptions 不含 onError,message-handler catch 必须直接调用
|
|
1400
1549
|
return {
|
|
1401
1550
|
dispatcher,
|
|
1402
1551
|
replyOptions: {
|
|
@@ -1532,5 +1681,7 @@ export function createDingtalkReplyDispatcher(params: CreateDingtalkReplyDispatc
|
|
|
1532
1681
|
},
|
|
1533
1682
|
markDispatchIdle,
|
|
1534
1683
|
getAsyncModeResponse: () => asyncModeFullResponse,
|
|
1684
|
+
/** 同步 dispatch 异常时由 message-handler catch 调用,统一 finish 错误卡 */
|
|
1685
|
+
finalizeDispatchError,
|
|
1535
1686
|
};
|
|
1536
1687
|
}
|
package/src/services/media.ts
CHANGED
|
@@ -12,6 +12,7 @@ import FormData from 'form-data';
|
|
|
12
12
|
import type { DingtalkConfig } from '../types/index.ts';
|
|
13
13
|
import { DINGTALK_OAPI, getOapiAccessToken } from '../utils/index.ts';
|
|
14
14
|
import { dingtalkHttp, dingtalkOapiHttp } from '../utils/http-client.ts';
|
|
15
|
+
import { isDingtalkDebug } from '../utils/logger.ts';
|
|
15
16
|
|
|
16
17
|
|
|
17
18
|
/** 文本文件扩展名 */
|
|
@@ -109,9 +110,11 @@ export function stripNonImageOccurrencesOfTargets(
|
|
|
109
110
|
from = idx + variant.length;
|
|
110
111
|
continue;
|
|
111
112
|
}
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
113
|
+
if (isDingtalkDebug()) {
|
|
114
|
+
console.log(
|
|
115
|
+
`[DingTalk][MediaIdTrace] strip 非图重复 target | idx=${idx} remove=${variant.slice(0, 100)}`,
|
|
116
|
+
);
|
|
117
|
+
}
|
|
115
118
|
if (!stripped.includes(original)) stripped.push(original);
|
|
116
119
|
out = out.slice(0, idx) + out.slice(idx + variant.length);
|
|
117
120
|
// 清掉残留的空「下载链接:」类前缀行由调用方整理;此处只去 target
|
|
@@ -127,7 +130,7 @@ export function stripNonImageOccurrencesOfTargets(
|
|
|
127
130
|
.replace(/[::]\s*$/gm, "")
|
|
128
131
|
.trim();
|
|
129
132
|
|
|
130
|
-
if (stripped.length) {
|
|
133
|
+
if (stripped.length && isDingtalkDebug()) {
|
|
131
134
|
console.log(
|
|
132
135
|
`[DingTalk][MediaIdTrace] strip 完成 | stripped=${stripped.length} bodyLen=${out.length}`,
|
|
133
136
|
);
|
|
@@ -225,9 +228,8 @@ export interface UploadResult {
|
|
|
225
228
|
}
|
|
226
229
|
|
|
227
230
|
/**
|
|
228
|
-
*
|
|
229
|
-
*
|
|
230
|
-
* 决策路径(匹配/exists/上传成败/残留)务必 always-on,方便查灰图。
|
|
231
|
+
* 本地图诊断日志。
|
|
232
|
+
* 默认静默:无图/空扫描不再刷屏。仅在上传/失败/灰图风险,或 debug: true 时输出。
|
|
231
233
|
*/
|
|
232
234
|
function logLocalImage(log: any, step: string, detail?: string, verbose = false) {
|
|
233
235
|
const line = detail
|
|
@@ -241,6 +243,19 @@ function logLocalImage(log: any, step: string, detail?: string, verbose = false)
|
|
|
241
243
|
}
|
|
242
244
|
return;
|
|
243
245
|
}
|
|
246
|
+
const interesting =
|
|
247
|
+
isDingtalkDebug() ||
|
|
248
|
+
/失败|灰图|残留|上传成功|上传失败|下载成功|下载失败|重试|remote|Remote/i.test(
|
|
249
|
+
`${step} ${detail || ""}`,
|
|
250
|
+
);
|
|
251
|
+
if (!interesting) {
|
|
252
|
+
try {
|
|
253
|
+
log?.debug?.(line);
|
|
254
|
+
} catch {
|
|
255
|
+
// ignore
|
|
256
|
+
}
|
|
257
|
+
return;
|
|
258
|
+
}
|
|
244
259
|
console.log(line);
|
|
245
260
|
}
|
|
246
261
|
|
|
@@ -282,11 +297,12 @@ export function logMediaIdTrace(
|
|
|
282
297
|
// sampleImageMsg 的 photoURL 就是裸 mediaId,不算异常
|
|
283
298
|
const isImageMsg = /type=image|sampleImage|photoURL/i.test(`${stage} ${extra || ""}`);
|
|
284
299
|
const suspiciousBare = isImageMsg ? [] : bareIds;
|
|
300
|
+
// 默认静默:仅 force、灰图风险(裸 mediaId / 残留本地路径)、或 debug: true
|
|
285
301
|
const always =
|
|
286
302
|
force ||
|
|
303
|
+
isDingtalkDebug() ||
|
|
287
304
|
suspiciousBare.length > 0 ||
|
|
288
|
-
residualLocal.length > 0
|
|
289
|
-
/API前|完成|:out|:in|after-process|processImages/i.test(stage);
|
|
305
|
+
residualLocal.length > 0;
|
|
290
306
|
if (!always) return;
|
|
291
307
|
|
|
292
308
|
const preview = text.replace(/\n/g, "\\n").slice(0, 200);
|
|
@@ -556,9 +572,11 @@ async function downloadRemoteUrlToTemp(url: string): Promise<string | null> {
|
|
|
556
572
|
`dingtalk-md-remote-${Date.now()}-${Math.random().toString(36).slice(2, 8)}${ext}`,
|
|
557
573
|
);
|
|
558
574
|
fs.writeFileSync(tmp, buf);
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
575
|
+
if (isDingtalkDebug()) {
|
|
576
|
+
console.log(
|
|
577
|
+
`[DingTalk][MediaIdTrace] 远程图下载成功 | bytes=${buf.length} tmp=${tmp}`,
|
|
578
|
+
);
|
|
579
|
+
}
|
|
562
580
|
return tmp;
|
|
563
581
|
} catch (e: any) {
|
|
564
582
|
console.warn(`[DingTalk][MediaIdTrace] 远程图下载异常 | ${e?.message || e}`);
|
|
@@ -599,8 +617,8 @@ function replaceOutsideImageTargets(
|
|
|
599
617
|
* - 本地:正文重复的本地路径换成 down.dingtalk.com 可点链接(仍同一条消息)
|
|
600
618
|
* - **不再拆第二条 text 消息**
|
|
601
619
|
*
|
|
602
|
-
*
|
|
603
|
-
* -
|
|
620
|
+
* 日志约定(默认静默;配置 debug: true 全量):
|
|
621
|
+
* - 有图上传/失败/灰图残留才 console;无图路径不刷屏
|
|
604
622
|
*/
|
|
605
623
|
export async function processImagesForOutbound(
|
|
606
624
|
content: string,
|
|
@@ -613,16 +631,19 @@ export async function processImagesForOutbound(
|
|
|
613
631
|
`contentLen=${content?.length ?? 0} hasToken=${!!oapiToken}`,
|
|
614
632
|
);
|
|
615
633
|
if (!oapiToken) {
|
|
616
|
-
|
|
617
|
-
|
|
634
|
+
// 有 MD 图却无 token 才告警(否则无图路径全静默)
|
|
635
|
+
if (/!\[[^\]]*\]\([^)]+\)/.test(content || "")) {
|
|
636
|
+
logLocalImage(log, "processImagesForOutbound 跳过", "无 oapiToken(本地路径无法上传→必灰图)");
|
|
637
|
+
logMediaIdTrace("processImagesForOutbound:skip-no-token", content);
|
|
638
|
+
}
|
|
618
639
|
return { text: content || "", followUpUrls: [] };
|
|
619
640
|
}
|
|
620
641
|
if (!content || typeof content !== "string") {
|
|
621
|
-
logLocalImage(log, "processImagesForOutbound 跳过", "content 为空");
|
|
642
|
+
logLocalImage(log, "processImagesForOutbound 跳过", "content 为空", true);
|
|
622
643
|
return { text: content || "", followUpUrls: [] };
|
|
623
644
|
}
|
|
624
645
|
|
|
625
|
-
logMediaIdTrace("processImagesForOutbound:in", content
|
|
646
|
+
logMediaIdTrace("processImagesForOutbound:in", content);
|
|
626
647
|
|
|
627
648
|
const codeRanges = getMarkdownCodeRanges(content);
|
|
628
649
|
if (codeRanges.length > 0) {
|
|
@@ -795,14 +816,26 @@ export async function processImagesForOutbound(
|
|
|
795
816
|
})
|
|
796
817
|
.map((m) => (m[2] || "").trim());
|
|
797
818
|
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
?
|
|
804
|
-
: "
|
|
805
|
-
|
|
819
|
+
// 有实际上传/跳过/残留时记一行;纯文本无图完成则静默
|
|
820
|
+
if (uploadCount > 0 || localTried > 0 || remoteTried > 0 || residual.length > 0) {
|
|
821
|
+
logLocalImage(
|
|
822
|
+
log,
|
|
823
|
+
residual.length
|
|
824
|
+
? "processImagesForOutbound 完成(有残留)"
|
|
825
|
+
: "processImagesForOutbound 上传成功",
|
|
826
|
+
`uploads=${uploadCount} localTried=${localTried} remoteTried=${remoteTried} skippedCode=${skippedCode} skippedOther=${skippedOther} outLen=${result.length}` +
|
|
827
|
+
(residual.length
|
|
828
|
+
? ` ⚠️residualLocal=${residual.length}:${residual.slice(0, 3).join("|")}`
|
|
829
|
+
: " residualLocal=0"),
|
|
830
|
+
);
|
|
831
|
+
} else {
|
|
832
|
+
logLocalImage(
|
|
833
|
+
log,
|
|
834
|
+
"processImagesForOutbound 完成",
|
|
835
|
+
`uploads=0 outLen=${result.length}`,
|
|
836
|
+
true,
|
|
837
|
+
);
|
|
838
|
+
}
|
|
806
839
|
if (residual.length) {
|
|
807
840
|
logLocalImage(
|
|
808
841
|
log,
|
|
@@ -810,7 +843,7 @@ export async function processImagesForOutbound(
|
|
|
810
843
|
residual.join(" | "),
|
|
811
844
|
);
|
|
812
845
|
}
|
|
813
|
-
logMediaIdTrace("processImagesForOutbound:out", result
|
|
846
|
+
logMediaIdTrace("processImagesForOutbound:out", result);
|
|
814
847
|
|
|
815
848
|
return { text: result, followUpUrls: [] };
|
|
816
849
|
}
|
|
@@ -12,6 +12,8 @@
|
|
|
12
12
|
* 内存 LRU + TTL,进程重启后清空(可接受)。
|
|
13
13
|
*/
|
|
14
14
|
|
|
15
|
+
import { isDingtalkDebug } from "../../utils/logger.ts";
|
|
16
|
+
|
|
15
17
|
export type CardContentCacheEntry = {
|
|
16
18
|
text: string;
|
|
17
19
|
at: number;
|
|
@@ -83,12 +85,15 @@ export function rememberCardContent(params: {
|
|
|
83
85
|
byKey.set(normalizeKey("convRef", `${cid}|${ref}`), entry);
|
|
84
86
|
}
|
|
85
87
|
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
88
|
+
// 成功写入默认静默;需要排查时设 debug: true
|
|
89
|
+
if (isDingtalkDebug()) {
|
|
90
|
+
try {
|
|
91
|
+
console.log(
|
|
92
|
+
`[DingTalk][CardCache] 已缓存 | outTrack=${params.outTrackId || "-"} conv=${params.conversationId || "-"} len=${text.length}`,
|
|
93
|
+
);
|
|
94
|
+
} catch {
|
|
95
|
+
// ignore
|
|
96
|
+
}
|
|
92
97
|
}
|
|
93
98
|
}
|
|
94
99
|
|