@jeik/dingtalk-connector 0.8.28 → 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 (40) hide show
  1. package/CHANGELOG.md +30 -31
  2. package/README.en.md +36 -71
  3. package/README.md +46 -74
  4. package/dist/{background-work-Cxb4b-91.mjs → background-work-DkaSDqWk.mjs} +12 -1
  5. package/dist/card-content-cache-C7-XHhHw.mjs +2 -0
  6. package/dist/{card-content-cache-bDQFz2u8.mjs → card-content-cache-DT_OYThh.mjs} +15 -1
  7. package/dist/chunk-upload-BKM1y4Ba.mjs +2 -0
  8. package/dist/{chunk-upload-BE71drPg.mjs → chunk-upload-CIU5G_ay.mjs} +1 -1
  9. package/dist/{common-BZiVgd3A.mjs → common-BfIYs1yC.mjs} +3 -3
  10. package/dist/{common-DgL28gCZ.mjs → common-C0Oaf0z-.mjs} +1 -1
  11. package/dist/{connection-CZ7ZoN5_.mjs → connection-obtrPkj4.mjs} +56 -65
  12. package/dist/entry-bundled.mjs +1 -1
  13. package/dist/{gateway-methods-kNX3qvqw.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-B1usz1wb.mjs → message-handler-BrO2i7tg.mjs} +26 -30
  21. package/dist/{messaging-DVS3UKOp.mjs → messaging-CK0Wz3Ze.mjs} +18 -20
  22. package/dist/{runtime-A6G7WAWn.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 +4 -4
  25. package/docs/RELEASE_NOTES_V0.8.29.md +57 -0
  26. package/openclaw.plugin.json +9 -5
  27. package/package.json +1 -1
  28. package/src/config/schema.ts +2 -1
  29. package/src/core/connection.ts +90 -113
  30. package/src/core/message-handler.ts +24 -16
  31. package/src/reply-dispatcher.ts +14 -20
  32. package/src/services/media.ts +60 -27
  33. package/src/services/messaging/card-content-cache.ts +11 -6
  34. package/src/services/messaging.ts +52 -38
  35. package/src/utils/background-work.ts +3 -1
  36. package/src/utils/logger.ts +23 -2
  37. package/dist/card-content-cache-c4Ls96eJ.mjs +0 -2
  38. package/dist/chunk-upload-PwhogwfE.mjs +0 -2
  39. package/dist/gateway-methods-BkI1gKaC.mjs +0 -2
  40. package/dist/logger-BmJkQkm1.mjs +0 -2
@@ -114,7 +114,7 @@ function printConnectionNoticeOnce(): void {
114
114
  _connectionNoticePrinted = true;
115
115
  console.log(
116
116
  "[dingtalk-connector] ℹ️ 上游 dingtalk-stream 噪音已过滤;" +
117
- "须 OPEN+REGISTERED 才报 ready,REGISTERED 超时会强制重连(禁止假 online)。" +
117
+ "OPEN 成功即可上线,REGISTERED 尽量等待;若环境不推 REGISTERED,首条消息会确认订阅。" +
118
118
  "正常运行不应出现 ≤30s 周期性硬重连。",
119
119
  );
120
120
  }
@@ -135,7 +135,9 @@ export async function monitorSingleAccount(
135
135
  const log = runtime?.log;
136
136
 
137
137
  // 创建 debug logger(仅在 debug 模式下输出 info/debug 日志)
138
- const { createLoggerFromConfig } = await import('../utils/logger');
138
+ // 配置 debug: true 时同步打开图片/引用/队列详细诊断(无需环境变量)
139
+ const { createLoggerFromConfig, setDingtalkDebug } = await import('../utils/logger');
140
+ if (account.config?.debug) setDingtalkDebug(true);
139
141
  const logger = createLoggerFromConfig(account.config, `DingTalk:${accountId}`);
140
142
 
141
143
  // 验证凭据是否存在
@@ -288,15 +290,18 @@ export async function monitorSingleAccount(
288
290
  }
289
291
 
290
292
  /**
291
- * 等待钉钉 Stream 真正可收消息。
293
+ * 等待钉钉 Stream 可服务。
292
294
  *
293
- * dingtalk-stream 的 client.connect() 在创建 WebSocket 后立刻 resolve,
294
- * **不等** socket open,更不等服务端 SYSTEM/REGISTERED。
295
+ * - OPEN:必须成功,否则 throw
296
+ * - REGISTERED:尽量等待;超时则 **降级 OPEN 上线**(不 throw)
295
297
  *
296
- * 顺序:socket OPEN → SYSTEM topic=REGISTERED(client.registered=true)
297
- * 任一步失败均 throw,禁止 OPEN-only 假 ready。
298
+ * 原因(生产日志):部分环境 socket 已 OPEN 且 CALLBACK 能收消息,
299
+ * 但长期收不到 SYSTEM/REGISTERED 或 client.registered 不被置 true。
300
+ * 若硬性失败会触发网关 auto-restart 风暴,而旧连接/半连接其实仍在处理消息。
298
301
  */
299
- async function waitForStreamReady(timeoutMs = STREAM_READY_TIMEOUT_MS): Promise<void> {
302
+ async function waitForStreamReady(timeoutMs = STREAM_READY_TIMEOUT_MS): Promise<{
303
+ registered: boolean;
304
+ }> {
300
305
  const started = Date.now();
301
306
 
302
307
  // 1) 等 WebSocket OPEN
@@ -332,12 +337,12 @@ export async function monitorSingleAccount(
332
337
  }
333
338
  }
334
339
 
335
- // 2) 等服务端 REGISTERED(订阅生效,此后 CALLBACK 才会推到本连接)
340
+ // 2) 等服务端 REGISTERED(可选;超时降级)
336
341
  if ((client as any).registered === true) {
337
342
  logger.info(
338
343
  `✅ Stream 已就绪(OPEN + REGISTERED),耗时 ${Date.now() - started}ms`,
339
344
  );
340
- return;
345
+ return { registered: true };
341
346
  }
342
347
 
343
348
  const registered = await new Promise<boolean>((resolve) => {
@@ -367,7 +372,7 @@ export async function monitorSingleAccount(
367
372
  return;
368
373
  }
369
374
  } catch {
370
- // ignore parse errors
375
+ // ignore
371
376
  }
372
377
  if ((client as any).registered === true) {
373
378
  finish(true);
@@ -387,13 +392,15 @@ export async function monitorSingleAccount(
387
392
  logger.info(
388
393
  `✅ Stream 已就绪(OPEN + REGISTERED),耗时 ${Date.now() - started}ms`,
389
394
  );
390
- return;
395
+ return { registered: true };
391
396
  }
392
397
 
393
- // 禁止 OPEN-only 假 ready:未 REGISTERED 一律失败,由上层强制重连
394
- throw new Error(
395
- `SYSTEM/REGISTERED 超时(${timeoutMs}ms):socket 已 OPEN 但订阅未生效,禁止报 connected`,
398
+ // 降级:OPEN 成功即可上线。许多环境不推 REGISTERED 或检测不到,但仍可收 CALLBACK。
399
+ logger.warn(
400
+ `⚠️ 未观察到 SYSTEM/REGISTERED(${timeoutMs}ms),socket 已 OPEN → 降级上线。` +
401
+ ` 收到首条机器人消息后会记为「订阅 empirically 生效」。`,
396
402
  );
403
+ return { registered: false };
397
404
  }
398
405
 
399
406
  /**
@@ -409,22 +416,20 @@ export async function monitorSingleAccount(
409
416
  }
410
417
 
411
418
  /**
412
- * 单次:connect + 挂 listener + 等 OPEN/REGISTERED。
413
- * 失败 throw,不报 connected。
419
+ * 单次:connect + 挂 listener + 等 OPEN(REGISTERED 可选)。
420
+ * 仅 OPEN 失败 throw;REGISTERED 超时则降级成功。
414
421
  */
415
- async function connectAndWaitRegistered(): Promise<void> {
422
+ async function connectAndWaitReady(): Promise<{ registered: boolean }> {
416
423
  await client.connect();
417
424
  attachSocketLifecycleListeners();
418
- await waitForStreamReady(STREAM_READY_TIMEOUT_MS);
419
- if ((client as any).registered !== true) {
420
- // 双保险:wait 已要求 registered,此处再断言
421
- throw new Error("connect 后 client.registered 仍为 false");
422
- }
425
+ return await waitForStreamReady(STREAM_READY_TIMEOUT_MS);
423
426
  }
424
427
 
425
428
  /**
426
- * 直到 REGISTERED 成功或次数用尽。
427
- * 用于初次启动与硬重连:绝不在未订阅时对外 connected=true。
429
+ * 建立可服务连接。
430
+ * - 优先拿到 REGISTERED
431
+ * - OPEN 成功即可上线(避免环境不推 REGISTERED 时 restart 风暴)
432
+ * - 仅在 OPEN 都失败时多轮重试
428
433
  */
429
434
  async function ensureRegisteredConnection(
430
435
  maxAttempts = REGISTERED_CONNECT_MAX_ATTEMPTS,
@@ -432,12 +437,12 @@ export async function monitorSingleAccount(
432
437
  let lastError: Error | undefined;
433
438
  for (let attempt = 1; attempt <= maxAttempts; attempt++) {
434
439
  if (isStopped) {
435
- throw new Error("连接已停止,中止 REGISTERED 等待");
440
+ throw new Error("连接已停止,中止 Stream 建立");
436
441
  }
437
442
  try {
438
443
  if (attempt > 1) {
439
444
  logger.warn(
440
- `🔄 REGISTERED 未就绪,强制重连 ${attempt}/${maxAttempts}…`,
445
+ `🔄 Stream 建立重试 ${attempt}/${maxAttempts}…`,
441
446
  );
442
447
  try {
443
448
  if ((client as any).socket) {
@@ -450,27 +455,28 @@ export async function monitorSingleAccount(
450
455
  await new Promise((r) => setTimeout(r, delay));
451
456
  } else {
452
457
  logger.info(
453
- `⏳ 建立 Stream 并等待 REGISTERED(单次超时 ${STREAM_READY_TIMEOUT_MS}ms,最多 ${maxAttempts} 次)…`,
458
+ `⏳ 建立 Stream(OPEN 必须;REGISTERED 尽量等待 ${STREAM_READY_TIMEOUT_MS}ms)…`,
454
459
  );
455
460
  }
456
461
 
457
- await connectAndWaitRegistered();
458
- noteSocketAlive("registered-ok");
462
+ const ready = await connectAndWaitReady();
463
+ noteSocketAlive(ready.registered ? "registered-ok" : "open-degraded");
459
464
  connectionEstablishedTime = Date.now();
460
465
  logger.info(
461
- `✅ 订阅已生效 registered=true(attempt ${attempt}/${maxAttempts}, ` +
466
+ `✅ Stream 可服务(attempt ${attempt}/${maxAttempts}, ` +
467
+ `registered=${ready.registered || Boolean((client as any).registered)}, ` +
462
468
  `socket=${(client as any).socket?.readyState})`,
463
469
  );
464
470
  return;
465
471
  } catch (err: any) {
466
472
  lastError = err instanceof Error ? err : new Error(String(err));
467
473
  logger.warn(
468
- `⚠️ Stream 就绪失败 attempt=${attempt}/${maxAttempts}: ${lastError.message}`,
474
+ `⚠️ Stream 建立失败 attempt=${attempt}/${maxAttempts}: ${lastError.message}`,
469
475
  );
470
476
  }
471
477
  }
472
478
  throw new Error(
473
- `钉钉 Stream 在 ${maxAttempts} 次尝试后仍未 REGISTERED,拒绝假 connected。` +
479
+ `钉钉 Stream 在 ${maxAttempts} 次尝试后仍无法 OPEN。` +
474
480
  ` 最后错误: ${lastError?.message || "unknown"}`,
475
481
  );
476
482
  }
@@ -784,31 +790,27 @@ export async function monitorSingleAccount(
784
790
  lastMessageTime = Date.now();
785
791
  // 能收到 CALLBACK 说明链路活着——立刻刷新,打断「软超时→硬重连」误判
786
792
  noteSocketAlive("inbound-callback");
793
+ // 部分环境收不到 SYSTEM/REGISTERED,但 CALLBACK 已通:记为 empirically 订阅成功
794
+ if ((client as any).registered !== true) {
795
+ (client as any).registered = true;
796
+ logger.info(
797
+ `✅ 收到机器人 CALLBACK,视为订阅已生效(此前可能未观察到 SYSTEM/REGISTERED)`,
798
+ );
799
+ }
787
800
 
788
801
  // 收到消息时,向框架报告 lastInboundAt(用于 UI 显示 "Last inbound")
789
802
  onStatusChange?.({ lastInboundAt: Date.now() });
790
803
 
791
804
  const messageId = res.headers?.messageId;
792
- const timestamp = new Date().toISOString();
793
-
794
- // ===== 第一步:记录原始消息接收 =====
795
- logger.info(`\n========== 收到新消息 ==========`);
796
- logger.info(`时间:${timestamp}`);
797
- logger.info(`MessageId: ${messageId || "N/A"}`);
798
- logger.info(`Headers: ${JSON.stringify(res.headers || {})}`);
799
- logger.info(`Data 长度:${res.data?.length || 0} 字符`);
800
-
801
- // ⚠️ 不要在解析/入队前 ACK。
802
- // 旧逻辑「立刻 socketCallBackResponse」:若随后去重误杀、解析失败、或入队前进程重连,
803
- // 钉钉认为已送达,不再重推 → 网关 UI 永远收不到。
804
- // 正确:入队成功后再 ACK;失败则不 ACK,让钉钉 ~60s 内重投。
805
+
806
+ // 入队成功后再 ACK;失败不 ACK 以便钉钉重投
805
807
  let acked = false;
806
808
  const ackMessage = (why: string) => {
807
809
  if (acked || !messageId) return;
808
810
  try {
809
811
  (client as any).socketCallBackResponse(messageId, { success: true });
810
812
  acked = true;
811
- logger.info(`✅ 已确认回调 messageId=${messageId} (${why})`);
813
+ logger.debug(`ACK messageId=${messageId} (${why})`);
812
814
  } catch (ackErr: any) {
813
815
  logger.warn(`确认回调失败: ${ackErr?.message || ackErr}`);
814
816
  }
@@ -817,80 +819,62 @@ export async function monitorSingleAccount(
817
819
  markMessageProcessingStart();
818
820
 
819
821
  try {
820
- // 解析消息数据
821
822
  let data;
822
823
  try {
823
824
  data = JSON.parse(res.data);
824
825
  } catch (parseError: any) {
825
- logger.error('Failed to parse response data as JSON:', {
826
- error: parseError instanceof Error ? parseError.message : String(parseError),
827
- rawData: typeof res.data === 'string'
828
- ? res.data.substring(0, 500)
829
- : res.data,
830
- dataType: typeof res.data,
831
- });
832
- // 解析失败:不 ACK,允许钉钉重投
826
+ logger.error(
827
+ `JSON 解析失败: ${parseError instanceof Error ? parseError.message : String(parseError)}`,
828
+ );
833
829
  throw new Error(
834
- `Invalid JSON response from DingTalk API. ` +
835
- `Error: ${parseError instanceof Error ? parseError.message : String(parseError)}. ` +
836
- `Raw data (first 100 chars): ${String(res.data).substring(0, 100)}`
830
+ `Invalid JSON from DingTalk: ${parseError instanceof Error ? parseError.message : String(parseError)}`,
837
831
  );
838
832
  }
839
833
 
840
- // ===== 第二步:记录解析后的消息详情 =====
841
- logger.info(`\n----- 消息详情 -----`);
842
- logger.info(`消息类型:${data.msgtype || "unknown"}`);
843
- logger.info(
844
- `会话类型:${data.conversationType === "1" ? "DM (单聊)" : data.conversationType === "2" ? "Group (群聊)" : data.conversationType}`,
845
- );
846
- logger.info(
847
- `发送者:${data.senderNick || "unknown"} (${data.senderStaffId || data.senderId || "unknown"})`,
848
- );
849
- logger.info(`会话 ID: ${data.conversationId || "N/A"}`);
850
- logger.info(`消息 ID: ${data.msgId || "N/A"}`);
851
- logger.info(
852
- `SessionWebhook: ${data.sessionWebhook ? "已提供" : "未提供"}`,
853
- );
834
+ const businessMsgId = data.msgId;
835
+ const convType =
836
+ data.conversationType === "1"
837
+ ? "DM"
838
+ : data.conversationType === "2"
839
+ ? "Group"
840
+ : String(data.conversationType ?? "?");
841
+ const preview =
842
+ (data.text?.content || "").trim().slice(0, 80) ||
843
+ data.msgtype ||
844
+ "(no text)";
845
+
846
+ // 一行摘要(debug 模式才打 Headers/全字段)
854
847
  logger.info(
855
- `RobotCode: ${data.robotCode || account.config?.clientId || "N/A"}`,
848
+ `📩 入站 ${convType} from=${data.senderNick || data.senderStaffId || data.senderId || "?"} ` +
849
+ `msg=${businessMsgId || messageId || "-"} preview=${JSON.stringify(preview)}`,
856
850
  );
851
+ logger.debug(`Headers: ${JSON.stringify(res.headers || {})}`);
852
+ logger.debug(`字段: ${Object.keys(data).join(",")}`);
853
+
854
+ // BotIdentity 每个进程每个账号只打一次(配置多机器人时用)
857
855
  if (data.chatbotUserId || data.chatbotCorpId) {
858
- console.log(
859
- `[DingTalk:${accountId}] [BotIdentity] accountId=${accountId} chatbotUserId=${data.chatbotUserId || "N/A"} chatbotCorpId=${data.chatbotCorpId || "N/A"}`,
860
- );
856
+ const key = `${accountId}:${data.chatbotUserId || ""}:${data.chatbotCorpId || ""}`;
857
+ if (!(globalThis as any).__dingtalkBotIdentityLogged) {
858
+ (globalThis as any).__dingtalkBotIdentityLogged = new Set<string>();
859
+ }
860
+ const seen = (globalThis as any).__dingtalkBotIdentityLogged as Set<string>;
861
+ if (!seen.has(key)) {
862
+ seen.add(key);
863
+ console.log(
864
+ `[DingTalk:${accountId}] [BotIdentity] chatbotUserId=${data.chatbotUserId || "N/A"} chatbotCorpId=${data.chatbotCorpId || "N/A"}`,
865
+ );
866
+ }
861
867
  }
862
868
 
863
- const businessMsgId = data.msgId;
864
-
865
- // 双层去重:协议 messageId + 业务 msgId(须在解析后一次完成)
866
869
  if (checkAndMarkDingtalkMessage(accountId, messageId, businessMsgId)) {
867
870
  processedCount++;
868
- // 重复投递:ACK 掉避免钉钉无限重推
869
871
  ackMessage("duplicate");
870
872
  logger.warn(
871
- `⚠️ 检测到重复消息,跳过:protocol=${messageId || "-"} business=${businessMsgId || "-"} (${processedCount}/${receivedCount})`,
873
+ `⚠️ 重复消息已跳过 protocol=${messageId || "-"} business=${businessMsgId || "-"}`,
872
874
  );
873
- logger.info(`========== 消息处理结束(重复) ==========\n`);
874
875
  return;
875
876
  }
876
877
 
877
- let contentPreview = "N/A";
878
- if (data.text?.content) {
879
- contentPreview =
880
- data.text.content.length > 100
881
- ? data.text.content.substring(0, 100) + "..."
882
- : data.text.content;
883
- } else if (data.content) {
884
- contentPreview =
885
- JSON.stringify(data.content).substring(0, 100) + "...";
886
- }
887
- logger.info(`消息内容预览:${contentPreview}`);
888
- logger.info(`完整数据字段:${Object.keys(data).join(", ")}`);
889
- logger.info(`----- 消息详情结束 -----\n`);
890
-
891
- // ===== 第三步:入队处理 =====
892
- logger.info(`🚀 开始处理消息...`);
893
-
894
878
  await messageHandler({
895
879
  accountId,
896
880
  config: account.config,
@@ -901,29 +885,22 @@ export async function monitorSingleAccount(
901
885
  cfg: clawdbotConfig,
902
886
  });
903
887
 
904
- // 入队/受理成功后再 ACK(handleDingTalkMessage 返回表示已进 session 队列)
905
888
  ackMessage("enqueued");
906
889
  noteSocketAlive("after-enqueue");
907
890
 
908
891
  processedCount++;
909
- logger.info(`✅ 消息处理完成 (${processedCount}/${receivedCount})`);
910
- logger.info(`========== 消息处理结束(成功) ==========\n`);
892
+ logger.info(`✅ 入队完成 (${processedCount}/${receivedCount})`);
911
893
  } catch (error: any) {
912
894
  processedCount++;
913
- const errorMsg = `❌ 处理消息异常 (${processedCount}/${receivedCount}): ${error?.message || "未知错误"}`;
914
- const errorStack = error?.stack || "无堆栈信息";
915
-
916
- logger.error(errorMsg);
917
- logger.error(`错误堆栈:\n${errorStack}`);
918
-
919
- // 未成功入队:故意不 ACK,让钉钉重投(最多约 60s)
895
+ logger.error(
896
+ `❌ 入站处理失败 (${processedCount}/${receivedCount}): ${error?.message || "未知错误"}`,
897
+ );
898
+ if (error?.stack) logger.debug(error.stack);
920
899
  if (!acked) {
921
900
  logger.warn(
922
- `⚠️ 未 ACK messageId=${messageId || "N/A"},等待钉钉重投(避免网关永久丢消息)`,
901
+ `⚠️ 未 ACK messageId=${messageId || "N/A"},等待钉钉重投`,
923
902
  );
924
903
  }
925
-
926
- logger.info(`========== 消息处理结束(失败) ==========\n`);
927
904
  } finally {
928
905
  markMessageProcessingEnd();
929
906
  }
@@ -66,7 +66,7 @@ import { createDingtalkReplyDispatcher, matchModelErrorText } from "../reply-dis
66
66
  import { normalizeSlashCommand } from "../utils/session.ts";
67
67
  import { getDingtalkRuntime } from "../runtime.ts";
68
68
  import { dingtalkHttp } from '../utils/http-client.ts';
69
- import { createLoggerFromConfig } from '../utils/index.ts';
69
+ import { createLoggerFromConfig, isDingtalkDebug } from '../utils/index.ts';
70
70
  import { trackBackgroundWork } from "../utils/background-work.ts";
71
71
  import * as fs from 'fs';
72
72
  import * as path from 'path';
@@ -358,11 +358,13 @@ function extractQuotedMsgText(
358
358
  allowConversationRecent: false,
359
359
  }) || '';
360
360
  if (cardText) {
361
- console.log(
362
- `[DingTalk][Quote] interactiveCard 已从 CardCache 精确回填 | ids=${ids.join(',') || '-'} len=${cardText.length}`,
363
- );
364
- } else {
365
- // 强制 dump 载荷,便于确认钉钉到底给了哪些字段
361
+ if (isDingtalkDebug()) {
362
+ console.log(
363
+ `[DingTalk][Quote] interactiveCard 已从 CardCache 精确回填 | ids=${ids.join(',') || '-'} len=${cardText.length}`,
364
+ );
365
+ }
366
+ } else if (isDingtalkDebug()) {
367
+ // 调试:dump 载荷确认钉钉给了哪些字段
366
368
  const dump = JSON.stringify(repliedMsg ?? {}).slice(0, 800);
367
369
  console.log(
368
370
  `[DingTalk][Quote] interactiveCard 无正文且缓存未命中(未使用会话最近兜底) | conv=${conversationId || '-'} keys=${Object.keys(contentObj || {}).join(',')} repliedMsg=${dump}`,
@@ -398,26 +400,32 @@ function extractQuotedMsgText(
398
400
  });
399
401
  if (cached) {
400
402
  bodyText = cached;
401
- console.log(
402
- `[DingTalk][Quote] msgType=${msgType} 已从 CardCache 精确回填 | ids=${ids.join(',') || '-'} len=${cached.length}`,
403
- );
403
+ if (isDingtalkDebug()) {
404
+ console.log(
405
+ `[DingTalk][Quote] msgType=${msgType} 已从 CardCache 精确回填 | ids=${ids.join(',') || '-'} len=${cached.length}`,
406
+ );
407
+ }
404
408
  } else if (
405
409
  /card|interactive/i.test(String(msgType || "")) ||
406
410
  ids.length > 0
407
411
  ) {
408
412
  bodyText = "钉钉卡片消息。";
409
- console.log(
410
- `[DingTalk][Quote] 未知卡片类引用 msgType=${msgType} 已简化占位 | keys=${Object.keys(contentObj || {}).join(',')}`,
411
- );
412
- } else {
413
+ if (isDingtalkDebug()) {
414
+ console.log(
415
+ `[DingTalk][Quote] 未知卡片类引用 msgType=${msgType} 已简化占位 | keys=${Object.keys(contentObj || {}).join(',')}`,
416
+ );
417
+ }
418
+ } else if (isDingtalkDebug()) {
413
419
  console.log(
414
420
  `[DingTalk][Quote] 未知引用类型 msgType=${msgType} keys=${Object.keys(contentObj || {}).join(',')} dump=${JSON.stringify(repliedMsg ?? {}).slice(0, 600)}`,
415
421
  );
416
422
  }
417
423
  } catch {
418
- console.log(
419
- `[DingTalk][Quote] 未知引用类型 msgType=${msgType} keys=${Object.keys(contentObj || {}).join(',')}`,
420
- );
424
+ if (isDingtalkDebug()) {
425
+ console.log(
426
+ `[DingTalk][Quote] 未知引用类型 msgType=${msgType} keys=${Object.keys(contentObj || {}).join(',')}`,
427
+ );
428
+ }
421
429
  }
422
430
  }
423
431
  break;
@@ -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";
@@ -877,30 +877,26 @@ export function createDingtalkReplyDispatcher(params: CreateDingtalkReplyDispatc
877
877
  ? { type: 'user', userId: senderId }
878
878
  : { type: 'group', openConversationId: conversationId };
879
879
 
880
- // 始终输出(不依赖 debug),便于排查本地图灰图
881
- console.log(
882
- `[DingTalk][LocalImage] closeStreaming 开始媒体处理 | target=${JSON.stringify(target)} hasToken=${!!oapiToken} textLen=${finalText.length}`,
883
- );
884
- // 预览正文中是否含 ![](
880
+ // 无图路径默认静默;有 MD 图或处理后变化才记一行(灰图风险仍 warn)
885
881
  const mdImgCount = (finalText.match(/!\[[^\]]*\]\([^)]+\)/g) || []).length;
886
- console.log(
887
- `[DingTalk][LocalImage] closeStreaming 正文 markdown 图数量=${mdImgCount} preview=${JSON.stringify(finalText.slice(0, 200))}`,
888
- );
889
-
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
+
890
888
  if (oapiToken) {
891
- // 处理本地图片(含 /mnt 共享盘;失败会 /tmp 重试,避免留下本地路径灰图)
892
889
  const beforeImg = finalText;
893
890
  finalText = await processLocalImages(finalText, oapiToken, log);
894
891
  if (beforeImg !== finalText) {
895
- console.log(
896
- `[DingTalk][LocalImage] closeStreaming 图片处理 | ${beforeImg.length}→${finalText.length}`,
892
+ log.info(
893
+ `[DingTalk][LocalImage] closeStreaming 图片已处理 | ${beforeImg.length}→${finalText.length}`,
897
894
  );
898
895
  }
899
- // 仅统计代码块外的本地 ![]——示例 JSON/参数说明里的路径不算灰图风险
900
896
  try {
901
897
  const { hasResidualLocalMdImagesOutsideCode } = await import("./services/media.ts");
902
898
  if (hasResidualLocalMdImagesOutsideCode(finalText)) {
903
- console.warn(
899
+ log.warn(
904
900
  `[DingTalk][LocalImage] closeStreaming 定稿后仍有代码块外的本地 MD 图未上传,钉钉可能灰图`,
905
901
  );
906
902
  }
@@ -938,7 +934,6 @@ export function createDingtalkReplyDispatcher(params: CreateDingtalkReplyDispatc
938
934
  );
939
935
 
940
936
  // ✅ 处理裸露的本地文件路径(绕过 OpenClaw SDK 的 bug)
941
- log.info(`[DingTalk][closeStreaming] 准备调用 processRawMediaPaths`);
942
937
  const { processRawMediaPaths } = await import('./services/media');
943
938
  finalText = await processRawMediaPaths(
944
939
  finalText,
@@ -1246,13 +1241,12 @@ export function createDingtalkReplyDispatcher(params: CreateDingtalkReplyDispatc
1246
1241
  // 模型主动 NO_REPLY(无错误)保持静默——不在此伪造错误文案,避免群聊刷屏。
1247
1242
  // 此时 earlyCard 仍会由 onIdle→closeStreaming 收尾(思考完成 / 无文本),不会永久转圈。
1248
1243
  if (!raw && !isErr) {
1249
- log.info(
1250
- `[DingTalk][onSkip] kind=${info?.kind} reason=${info?.reason} 非错误静默(NO_REPLY/empty),跳过错误捕获`,
1251
- );
1244
+ // 模型主动 NO_REPLY:静默,不打日志
1252
1245
  return;
1253
1246
  }
1247
+ // 失败类静默:保留一行关键 warn(避免与 closeStreaming 重复刷堆栈)
1254
1248
  log.warn(
1255
- `[DingTalk][onSkip] kind=${info?.kind} reason=${info?.reason} isError=${isErr} rawLen=${raw.length}(群聊静默失败常见)`,
1249
+ `[DingTalk][onSkip] 群聊静默失败 kind=${info?.kind} reason=${info?.reason} rawLen=${raw.length}`,
1256
1250
  );
1257
1251
  captureErrorFromPayload(payload, `onSkip:${info?.kind ?? "?"}:${info?.reason ?? "?"}`);
1258
1252
  },