@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.
Files changed (43) hide show
  1. package/CHANGELOG.md +48 -0
  2. package/README.en.md +40 -82
  3. package/README.md +50 -93
  4. package/dist/background-work-DkaSDqWk.mjs +53 -0
  5. package/dist/card-content-cache-C7-XHhHw.mjs +2 -0
  6. package/dist/{card-content-cache-CYUzCaQJ.mjs → card-content-cache-DT_OYThh.mjs} +15 -1
  7. package/dist/chunk-upload-BKM1y4Ba.mjs +2 -0
  8. package/dist/{chunk-upload-BBQgGtcZ.mjs → chunk-upload-CIU5G_ay.mjs} +1 -1
  9. package/dist/{common-DE0gd0Jg.mjs → common-BfIYs1yC.mjs} +3 -3
  10. package/dist/{common-B_A78n8u.mjs → common-C0Oaf0z-.mjs} +1 -1
  11. package/dist/connection-obtrPkj4.mjs +641 -0
  12. package/dist/entry-bundled.mjs +1 -1
  13. package/dist/{gateway-methods--aKsyMs-.mjs → gateway-methods-BdcpfxEZ.mjs} +1 -1
  14. package/dist/gateway-methods-DQ6TwOAr.mjs +2 -0
  15. package/dist/index.mjs +2 -2
  16. package/dist/{logger-mZ9OSbmD.mjs → logger-CnBTcwyq.mjs} +16 -2
  17. package/dist/logger-D8H2IcYz.mjs +2 -0
  18. package/dist/{media-BqpUGCni.mjs → media-DDtX37T2.mjs} +1 -1
  19. package/dist/{media-DnQB2BrL.mjs → media-g8tJtpfU.mjs} +29 -20
  20. package/dist/{message-handler-DQZiKx3i.mjs → message-handler-BrO2i7tg.mjs} +251 -139
  21. package/dist/{messaging-WleKf54h.mjs → messaging-CK0Wz3Ze.mjs} +23 -21
  22. package/dist/{runtime-DzZo6Gvk.mjs → runtime-DbqGRyhI.mjs} +6 -5
  23. package/dist/{utils-Bjh4r_qS.mjs → utils-Bwz2Fls_.mjs} +1 -1
  24. package/docs/RELEASE_NOTES_V0.8.28.md +56 -0
  25. package/docs/RELEASE_NOTES_V0.8.29.md +57 -0
  26. package/openclaw.plugin.json +9 -5
  27. package/package.json +2 -2
  28. package/src/config/schema.ts +2 -1
  29. package/src/core/connection.ts +504 -220
  30. package/src/core/message-handler.ts +197 -102
  31. package/src/reply-dispatcher.ts +244 -93
  32. package/src/services/media.ts +60 -27
  33. package/src/services/messaging/card-content-cache.ts +11 -6
  34. package/src/services/messaging/card.ts +2 -0
  35. package/src/services/messaging.ts +52 -38
  36. package/src/utils/background-work.ts +65 -0
  37. package/src/utils/logger.ts +23 -2
  38. package/dist/card-content-cache-Dwd8HICP.mjs +0 -2
  39. package/dist/chunk-upload-DaLXXZH3.mjs +0 -2
  40. package/dist/connection-fc6B4z4G.mjs +0 -423
  41. package/dist/gateway-methods-BQd4E0Sk.mjs +0 -2
  42. package/dist/logger-BmJkQkm1.mjs +0 -2
  43. /package/dist/{game-xiyou-CAT0ilSj.mjs → game-xiyou-uwsv23AC.mjs} +0 -0
@@ -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
- let currentCardTarget: AICardTarget | null = null;
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
- streamWriteChain = streamWriteChain
549
- .then(() => runWrite(force))
550
- .catch((e) => {
551
- log.warn(`[DingTalk][stream] 队列任务失败(不中断后续):${e?.message || e}`);
552
- });
553
- return streamWriteChain;
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
- // 始终输出(不依赖 debug),便于排查本地图灰图
810
- console.log(
811
- `[DingTalk][LocalImage] closeStreaming 开始媒体处理 | target=${JSON.stringify(target)} hasToken=${!!oapiToken} textLen=${finalText.length}`,
812
- );
813
- // 预览正文中是否含 ![](
880
+ // 无图路径默认静默;有 MD 图或处理后变化才记一行(灰图风险仍 warn)
814
881
  const mdImgCount = (finalText.match(/!\[[^\]]*\]\([^)]+\)/g) || []).length;
815
- console.log(
816
- `[DingTalk][LocalImage] closeStreaming 正文 markdown 图数量=${mdImgCount} preview=${JSON.stringify(finalText.slice(0, 200))}`,
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
- console.log(
825
- `[DingTalk][LocalImage] closeStreaming 图片处理 | ${beforeImg.length}→${finalText.length}`,
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
- console.warn(
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
- if (!hadRealAnswer) {
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
- await enqueueCardStream(displayClean(text));
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
- // ✅ 当上游模型异常通过 deliver(final) 到达时,将错误文本存入 accumulatedText,
1131
- // 防止 onIdle 的兜底覆盖为通用错误文案,并确保 closeStreaming 能读到正确的错误提示。
1132
- if ((payload as any).isError && !accumulatedText.trim() && text.trim()) {
1133
- accumulatedText = text;
1134
- log.info(`[DingTalk][deliver] 将上游异常 payload 文本存入 accumulatedText(len=${text.length}),防止 onIdle 覆盖`);
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
- const errorMsg = String(error);
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: ${errorMsg}`
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
  }
@@ -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
- console.log(
113
- `[DingTalk][MediaIdTrace] strip 非图重复 target | idx=${idx} remove=${variant.slice(0, 100)}`,
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
- * 本地图诊断:默认 console.log 一行(不走 log.warn,避免 [sendTextToDingTalk] 双前缀)。
229
- * verbose=true 的细节(realpath 等)仅在 debug 时输出。
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
- console.log(
560
- `[DingTalk][MediaIdTrace] 远程图下载成功 | bytes=${buf.length} tmp=${tmp}`,
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
- * 日志约定(always-on,查灰图必看):
603
- * - 开始 / 扫描每张 ![] / 本地 exists / 上传成败 / 远程下载 / 残留 / out
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
- logLocalImage(log, "processImagesForOutbound 跳过", "无 oapiToken(本地路径无法上传→必灰图)");
617
- logMediaIdTrace("processImagesForOutbound:skip-no-token", content, undefined, true);
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, undefined, true);
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
- logLocalImage(
799
- log,
800
- "processImagesForOutbound 完成",
801
- `uploads=${uploadCount} localTried=${localTried} remoteTried=${remoteTried} skippedCode=${skippedCode} skippedOther=${skippedOther} outLen=${result.length}` +
802
- (residual.length
803
- ? ` ⚠️residualLocal=${residual.length}:${residual.slice(0, 3).join("|")}`
804
- : " residualLocal=0"),
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, undefined, true);
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
- try {
87
- console.log(
88
- `[DingTalk][CardCache] 已缓存 | outTrack=${params.outTrackId || "-"} conv=${params.conversationId || "-"} len=${text.length}`,
89
- );
90
- } catch {
91
- // ignore
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