@jeik/dingtalk-connector 0.8.21-fix45 → 0.8.21-fix46

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 CHANGED
@@ -5,6 +5,23 @@ All notable changes to this project will be documented in this file.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
+ ## [0.8.21-fix46] - 2026-07-20
9
+
10
+ ### 诊断 / Diagnostics
11
+ - ✨ **message 本地 MD 图灰图排查日志** — 恢复并加强 always-on `[DingTalk][LocalImage]` / `[MediaIdTrace]`:
12
+ - `sendTextToDingTalk` 入口 / 处理后 / API前
13
+ - `processImagesForOutbound` 扫描每张 `![]`(kind=local|http|mediaId、inCode、exists、size)
14
+ - 本地上传成败 / 远程下载上传 / `residualLocal` 残留告警
15
+ - MediaIdTrace 固定字段:`mediaIdCount` / `mdImgs` / `residualLocal` / preview
16
+
17
+ 逻辑仍基于 fix45(图+下载同泡、只认 `![]`、代码块跳过)。
18
+
19
+ ### 安装 / Install
20
+ ```bash
21
+ npx -y @jeik/dingtalk-connector install --force
22
+ openclaw gateway restart
23
+ ```
24
+
8
25
  ## [0.8.21-fix45] - 2026-07-20
9
26
 
10
27
  生产稳定版 / Production-stable community release。
package/README.en.md CHANGED
@@ -4,7 +4,7 @@
4
4
  <p>Community maintained fork of the official <strong>v0.8.20</strong> release, tracking and fixing bugs the official team hasn't addressed.<br/>
5
5
  Identical to the official release in functionality — only community-critical fixes applied.</p>
6
6
 
7
- <p><strong>Current published release: <a href="https://www.npmjs.com/package/@jeik/dingtalk-connector">@jeik/dingtalk-connector</a> v0.8.21-fix45</strong> (production-stable; install: <code>npx -y @jeik/dingtalk-connector install</code>).</p>
7
+ <p><strong>Current published release: <a href="https://www.npmjs.com/package/@jeik/dingtalk-connector">@jeik/dingtalk-connector</a> v0.8.21-fix46</strong> (production-stable; install: <code>npx -y @jeik/dingtalk-connector install</code>).</p>
8
8
 
9
9
  <p>
10
10
  <a href="https://www.npmjs.com/package/@jeik/dingtalk-connector"><img src="https://img.shields.io/npm/v/@jeik/dingtalk-connector.svg?style=flat&colorA=18181B&colorB=28CF8D" alt="npm version" /></a>
@@ -23,21 +23,23 @@
23
23
 
24
24
  ## 🔧 Recent Updates
25
25
 
26
- ### 🚀 v0.8.21-fix45 · 2026-07-20 (current)
26
+ ### 🚀 v0.8.21-fix46 · 2026-07-20 (current)
27
27
 
28
- **Theme: image + download link in one bubble · only `![]` · quote card cache**
28
+ **Theme: always-on local MD image diagnostics (behavior same as fix45)**
29
29
 
30
30
  | | Change |
31
31
  |--|--------|
32
- | 📷 **Image + download URL** | `![](http)` → upload mediaId; **original download URL stays in the same message** |
33
- | ✅ **Only `![]()`** | No bare-path scan; code-block paths not uploaded / no false grey-image warn |
34
- | 📎 **Quoted AI cards** | Cache final card text for quote backfill |
35
- | 🌐 **message media** | `media: "https://..."` download-then-upload |
32
+ | 🔍 **LocalImage logs** | message path: scan / exists / upload / residual |
33
+ | 🔍 **MediaIdTrace** | `mdImgs`, `residualLocal`, API-before preview |
36
34
 
37
35
  ```bash
38
36
  npx -y @jeik/dingtalk-connector install --force && openclaw gateway restart
39
37
  ```
40
38
 
39
+ ### 📦 v0.8.21-fix45 · 2026-07-20
40
+
41
+ Image + download link one bubble · only `![]` · quote cache · residual false-positive fix.
42
+
41
43
  ### 📦 v0.8.21-fix38 · 2026-07-20
42
44
 
43
45
  Remote `media` download-then-upload.
package/README.md CHANGED
@@ -4,7 +4,7 @@
4
4
  <p>基于官方 <strong>v0.8.20</strong> 的社区维护版本,由社区持续跟进修复官方无暇处理的 Bug。<br/>
5
5
  功能与官方完全一致,拥有最快的修复速度,及时合并官方pr和个人发现的bug和社区急需的 Bug。</p>
6
6
 
7
- <p><strong>当前发布版:<a href="https://www.npmjs.com/package/@jeik/dingtalk-connector">@jeik/dingtalk-connector</a> v0.8.21-fix45</strong>(稳定生产可用;一键安装:`npx -y @jeik/dingtalk-connector install`;本地 tgz:`openclaw plugins install ./jeik-dingtalk-connector-0.8.21-fix45.tgz --force`)</p>
7
+ <p><strong>当前发布版:<a href="https://www.npmjs.com/package/@jeik/dingtalk-connector">@jeik/dingtalk-connector</a> v0.8.21-fix46</strong>(稳定生产可用;一键安装:`npx -y @jeik/dingtalk-connector install`;本地 tgz:`openclaw plugins install ./jeik-dingtalk-connector-0.8.21-fix46.tgz --force`)</p>
8
8
 
9
9
  <p>
10
10
  <a href="https://www.npmjs.com/package/@jeik/dingtalk-connector"><img src="https://img.shields.io/npm/v/@jeik/dingtalk-connector.svg?style=flat&colorA=18181B&colorB=28CF8D" alt="npm version" /></a>
@@ -23,21 +23,23 @@
23
23
 
24
24
  ## 🔧 最近更新
25
25
 
26
- ### 🚀 v0.8.21-fix45 · 2026-07-20(当前)
26
+ ### 🚀 v0.8.21-fix46 · 2026-07-20(当前)
27
27
 
28
- **主题:图 + 下载链接单气泡 · 只认 `![]` · 引用卡片缓存**
28
+ **主题:本地 MD 图灰图诊断日志(逻辑同 fix45)**
29
29
 
30
30
  | | 改动 |
31
31
  |--|------|
32
- | 📷 **图 + 下载链** | `![](http)` 上传为 mediaId;**下载链接原 URL 留在同一条消息**(不被钉钉改成 `@lADP`) |
33
- | ✅ **只处理 `![]()`** | 不扫裸路径;代码块内路径不上传、不误报灰图 |
34
- | 📎 **引用 AI 卡** | 定稿缓存正文,引用时回填(`CardCache`) |
35
- | 🌐 **message media** | `media: "https://..."` 下载再上传 |
32
+ | 🔍 **LocalImage 日志** | message 路径 always-on:扫描 / exists / 上传成败 / residual |
33
+ | 🔍 **MediaIdTrace** | `mdImgs`、`residualLocal`、API前 preview,方便对灰图 |
36
34
 
37
35
  ```bash
38
36
  npx -y @jeik/dingtalk-connector install --force && openclaw gateway restart
39
37
  ```
40
38
 
39
+ ### 📦 v0.8.21-fix45 · 2026-07-20
40
+
41
+ 图 + 下载链接单气泡 · 只认 `![]` · 引用卡片缓存 · residual 误报修复。
42
+
41
43
  ### 📦 v0.8.21-fix38 · 2026-07-20
42
44
 
43
45
  message 远程 `media` 下载上传。
@@ -64,7 +66,7 @@ message 远程 `media` 下载上传。
64
66
  | 2026-06-28 | 上线 npm `@jeik/dingtalk-connector`;过程消息提前定稿修复 |
65
67
  | 2026-05 | MD 直链/本地图;多轮刷屏;4.29+ 无文本输出;WebSocket 幻影重连 |
66
68
 
67
- 完整说明:[CHANGELOG.md](CHANGELOG.md) · [FIXES.md](FIXES.md) · [Release fix45](docs/RELEASE_NOTES_V0.8.21-fix45.md)
69
+ 完整说明:[CHANGELOG.md](CHANGELOG.md) · [FIXES.md](FIXES.md) · [Release fix46](docs/RELEASE_NOTES_V0.8.21-fix46.md)
68
70
 
69
71
  ---
70
72
 
@@ -23,7 +23,7 @@ var entry_bundled_default = defineBundledChannelEntry({
23
23
  exportName: "setDingtalkRuntime"
24
24
  },
25
25
  async registerFull(api) {
26
- const { registerGatewayMethods } = await import("./gateway-methods-b4qQpdOB.mjs");
26
+ const { registerGatewayMethods } = await import("./gateway-methods-BTByJFxo.mjs");
27
27
  registerGatewayMethods(api);
28
28
  }
29
29
  });
@@ -1,2 +1,2 @@
1
- import { t as registerGatewayMethods } from "./gateway-methods-CCh-LutW.mjs";
1
+ import { t as registerGatewayMethods } from "./gateway-methods-CkBI4__L.mjs";
2
2
  export { registerGatewayMethods };
@@ -1,7 +1,7 @@
1
1
  import { a as resolveDingtalkAccount, t as listDingtalkAccountIds } from "./accounts-BAzdqkAV.mjs";
2
2
  import { t as dingtalkHttp } from "./http-client-DFWZgO1n.mjs";
3
3
  import { r as getAccessToken, t as DINGTALK_API } from "./utils-CIfI_3Jh.mjs";
4
- import { c as prepareMultiBotMentions, d as finishAICard, i as sendProactive, s as buildBotMentionTable } from "./messaging-CpOPEOI8.mjs";
4
+ import { c as prepareMultiBotMentions, d as finishAICard, i as sendProactive, s as buildBotMentionTable } from "./messaging-ngF20yqM.mjs";
5
5
  import { c as getUnionId, d as recallEmotionReply } from "./utils-legacy-CALCPP1t.mjs";
6
6
  //#region src/docs.ts
7
7
  var DingtalkDocsClient = class {
package/dist/index.mjs CHANGED
@@ -1,5 +1,5 @@
1
- import { a as initDingtalkPluginConfigSchema, i as dingtalkPlugin, n as setDingtalkRuntime } from "./runtime-Dj8jSHjE.mjs";
2
- import { t as registerGatewayMethods } from "./gateway-methods-CCh-LutW.mjs";
1
+ import { a as initDingtalkPluginConfigSchema, i as dingtalkPlugin, n as setDingtalkRuntime } from "./runtime-I36vhOzx.mjs";
2
+ import { t as registerGatewayMethods } from "./gateway-methods-CkBI4__L.mjs";
3
3
  //#region index.ts
4
4
  /**
5
5
  * 检测同一 plugin id 在多个路径被加载的情况。
@@ -1,2 +1,2 @@
1
- import { _ as toLocalPath, d as processLocalImages, f as processRawMediaPaths, h as sendFileProactive, o as hasResidualLocalMdImagesOutsideCode, p as processVideoMarkers } from "./media-_qA1Qjt_.mjs";
1
+ import { _ as toLocalPath, d as processLocalImages, f as processRawMediaPaths, h as sendFileProactive, o as hasResidualLocalMdImagesOutsideCode, p as processVideoMarkers } from "./media-BMNRjseJ.mjs";
2
2
  export { hasResidualLocalMdImagesOutsideCode, processLocalImages, processRawMediaPaths, processVideoMarkers, sendFileProactive, toLocalPath };
@@ -68,8 +68,9 @@ function toLocalPath(raw) {
68
68
  return filePath;
69
69
  }
70
70
  /**
71
- * 本地图诊断:默认只 console 一行,不再 log.warn(避免 [sendTextToDingTalk] 双前缀刷屏)。
72
- * verbose 步骤(文件可读/realpath 等)仅 debug 时通过 log 输出。
71
+ * 本地图诊断:默认 console.log 一行(不走 log.warn,避免 [sendTextToDingTalk] 双前缀)。
72
+ * verbose=true 的细节(realpath 等)仅在 debug 时输出。
73
+ * 决策路径(匹配/exists/上传成败/残留)务必 always-on,方便查灰图。
73
74
  */
74
75
  function logLocalImage(log, step, detail, verbose = false) {
75
76
  const line = detail ? `[DingTalk][LocalImage] ${step} | ${detail}` : `[DingTalk][LocalImage] ${step}`;
@@ -81,21 +82,36 @@ function logLocalImage(log, step, detail, verbose = false) {
81
82
  }
82
83
  console.log(line);
83
84
  }
84
- /** 钉钉 mediaId 形态 */
85
+ /** 钉钉 mediaId 形态(oapi 返回常带 @) */
85
86
  const MEDIA_ID_RE = /@[A-Za-z0-9_-]{8,}/g;
87
+ function classifyMdImgTarget(target) {
88
+ const t = (target || "").trim();
89
+ if (!t) return "other";
90
+ if (t.startsWith("@") && !t.includes("/") && !t.includes("\\")) return "mediaId";
91
+ if (looksLikeRemoteUrl(t) || /^https?:\/\//i.test(t)) return "http";
92
+ if (isLocalImageRef(t)) return "local";
93
+ return "other";
94
+ }
86
95
  /**
87
- * 精简 MediaId 追踪:默认只在关键节点 / 异常时打一行。
88
- * force=true 或发现「裸 mediaId」时才输出。
96
+ * MediaId 追踪:关键节点 / force / 裸 mediaId 时输出。
97
+ * 固定字段:mediaIdCount / ids / BARE_ids / mdImgs / httpCount / residualLocal / preview
98
+ * (灰图排查:看 mdImgs 是 path:本地 还是 mediaId:@xxx,以及 residualLocal)
89
99
  */
90
100
  function logMediaIdTrace(stage, content, extra, force = false) {
91
101
  const text = typeof content === "string" ? content : "";
92
102
  const ids = [...new Set([...text.matchAll(MEDIA_ID_RE)].map((m) => m[0]))];
93
- const inMd = new Set([...text.matchAll(LOCAL_IMAGE_RE)].map((m) => (m[2] || "").trim()));
103
+ const mdTargets = [...text.matchAll(LOCAL_IMAGE_RE)].map((m) => (m[2] || "").trim());
104
+ const inMd = new Set(mdTargets);
94
105
  const bareIds = ids.filter((id) => !inMd.has(id));
106
+ const mdImgs = mdTargets.slice(0, 8).map((t) => {
107
+ return `${classifyMdImgTarget(t)}:${t.length > 100 ? `${t.slice(0, 100)}…` : t}`;
108
+ });
109
+ const residualLocal = mdTargets.filter((t) => isLocalImageRef(t));
110
+ const httpCount = (text.match(/https?:\/\//gi) || []).length;
95
111
  const suspiciousBare = /type=image|sampleImage|photoURL/i.test(`${stage} ${extra || ""}`) ? [] : bareIds;
96
- if (!force && suspiciousBare.length === 0 && !stage.includes("API前") && !stage.includes("完成")) return;
97
- const preview = text.replace(/\n/g, "\\n").slice(0, 160);
98
- console.log(`[DingTalk][MediaIdTrace] ${stage} | len=${text.length}` + (ids.length ? ` mediaIds=${ids.length}` : "") + (suspiciousBare.length ? ` ⚠️BARE=${suspiciousBare.join(",")}` : "") + (extra ? ` ${extra}` : "") + ` | ${preview}`);
112
+ if (!(force || suspiciousBare.length > 0 || residualLocal.length > 0 || /API前|完成|:out|:in|after-process|processImages/i.test(stage))) return;
113
+ const preview = text.replace(/\n/g, "\\n").slice(0, 200);
114
+ console.log(`[DingTalk][MediaIdTrace] ${stage} | len=${text.length} mediaIdCount=${ids.length}` + (ids.length ? ` ids=[${ids.slice(0, 4).join(",")}]` : "") + ` BARE_ids=${suspiciousBare.length ? suspiciousBare.join(",") : "无"} mdImgs=[${mdImgs.length ? mdImgs.join(" || ") : "-"}] httpCount=${httpCount}` + (residualLocal.length ? ` residualLocal=${residualLocal.length}:${residualLocal.slice(0, 3).map((p) => p.slice(0, 80)).join("|")}` : " residualLocal=0") + (extra ? ` ${extra}` : "") + ` preview="${preview}"`);
99
115
  }
100
116
  /** Markdown 代码区域(围栏 ``` 与行内 `...`),其中的路径不应上传 */
101
117
  function getMarkdownCodeRanges(content) {
@@ -124,21 +140,21 @@ async function uploadMediaToDingTalk(filePath, mediaType, oapiToken, maxSize = 2
124
140
  const tag = `uploadMedia type=${mediaType}`;
125
141
  try {
126
142
  let absPath = toLocalPath(filePath);
127
- logLocalImage(log, `${tag} 开始`, `path=${absPath}`, true);
143
+ logLocalImage(log, `${tag} 开始`, `path=${absPath}`);
128
144
  if (!fs.existsSync(absPath)) {
129
145
  logLocalImage(log, `${tag} 失败:文件不存在`, absPath);
130
146
  return null;
131
147
  }
132
148
  try {
133
149
  const real = fs.realpathSync(absPath);
134
- if (real !== absPath) logLocalImage(log, `${tag} realpath`, `${absPath} → ${real}`, true);
150
+ if (real !== absPath) logLocalImage(log, `${tag} realpath`, `${absPath} → ${real}`);
135
151
  absPath = real;
136
152
  } catch (e) {
137
153
  logLocalImage(log, `${tag} realpath跳过`, e?.message || String(e), true);
138
154
  }
139
155
  const stats = fs.statSync(absPath);
140
156
  const fileSizeMB = (stats.size / (1024 * 1024)).toFixed(2);
141
- logLocalImage(log, `${tag} 文件可读`, `size=${fileSizeMB}MB`, true);
157
+ logLocalImage(log, `${tag} 文件可读`, `size=${fileSizeMB}MB mode=${stats.mode}`);
142
158
  const effectiveMax = mediaType === "image" ? Math.min(maxSize, 10 * 1024 * 1024) : mediaType === "voice" ? Math.min(maxSize, 2 * 1024 * 1024) : maxSize;
143
159
  if (stats.size > effectiveMax) {
144
160
  const maxSizeMB = (effectiveMax / (1024 * 1024)).toFixed(0);
@@ -197,17 +213,26 @@ async function uploadMediaToDingTalk(filePath, mediaType, oapiToken, maxSize = 2
197
213
  */
198
214
  async function uploadLocalImageWithFallback(filePath, oapiToken, log) {
199
215
  const absPath = toLocalPath(filePath);
200
- if (!fs.existsSync(absPath)) {
216
+ const exists = fs.existsSync(absPath);
217
+ logLocalImage(log, "fallback 入口", `path=${absPath} exists=${exists}`);
218
+ if (!exists) {
201
219
  logLocalImage(log, "fallback 失败:文件不存在", absPath);
202
220
  return null;
203
221
  }
222
+ logLocalImage(log, "fallback 第1次:直接上传", absPath);
204
223
  let result = await uploadMediaToDingTalk(absPath, "image", oapiToken, 10 * 1024 * 1024, log);
205
- if (result?.mediaId) return result;
224
+ if (result?.mediaId) {
225
+ logLocalImage(log, "fallback 第1次成功", result.mediaId);
226
+ return result;
227
+ }
206
228
  const ext = path.extname(absPath) || ".jpg";
207
229
  const tmp = path.join(os.tmpdir(), `dingtalk-img-${Date.now()}-${Math.random().toString(36).slice(2, 8)}${ext.toLowerCase()}`);
208
230
  try {
209
231
  fs.copyFileSync(absPath, tmp);
232
+ logLocalImage(log, "fallback 第2次:tmp重试", `${absPath} → ${tmp}`);
210
233
  result = await uploadMediaToDingTalk(tmp, "image", oapiToken, 10 * 1024 * 1024, log);
234
+ if (result?.mediaId) logLocalImage(log, "fallback 第2次成功", result.mediaId);
235
+ else logLocalImage(log, "fallback 第2次仍失败", tmp);
211
236
  return result;
212
237
  } catch (err) {
213
238
  logLocalImage(log, "fallback 拷贝/重试异常", `${absPath} err=${err?.message || err}`);
@@ -278,51 +303,109 @@ function replaceOutsideImageTargets(content, original, replacement) {
278
303
  * (钉钉改写发生在「![] 与正文共用同一 http」时;![] 改成 mediaId 后即可同泡共存)
279
304
  * - 本地:正文重复的本地路径换成 down.dingtalk.com 可点链接(仍同一条消息)
280
305
  * - **不再拆第二条 text 消息**
306
+ *
307
+ * 日志约定(always-on,查灰图必看):
308
+ * - 开始 / 扫描每张 ![] / 本地 exists / 上传成败 / 远程下载 / 残留 / out
281
309
  */
282
310
  async function processImagesForOutbound(content, oapiToken, log) {
283
- if (!oapiToken || !content || typeof content !== "string") return {
284
- text: content || "",
285
- followUpUrls: []
286
- };
311
+ logLocalImage(log, "processImagesForOutbound 开始", `contentLen=${content?.length ?? 0} hasToken=${!!oapiToken}`);
312
+ if (!oapiToken) {
313
+ logLocalImage(log, "processImagesForOutbound 跳过", "无 oapiToken(本地路径无法上传→必灰图)");
314
+ logMediaIdTrace("processImagesForOutbound:skip-no-token", content, void 0, true);
315
+ return {
316
+ text: content || "",
317
+ followUpUrls: []
318
+ };
319
+ }
320
+ if (!content || typeof content !== "string") {
321
+ logLocalImage(log, "processImagesForOutbound 跳过", "content 为空");
322
+ return {
323
+ text: content || "",
324
+ followUpUrls: []
325
+ };
326
+ }
327
+ logMediaIdTrace("processImagesForOutbound:in", content, void 0, true);
287
328
  const codeRanges = getMarkdownCodeRanges(content);
329
+ if (codeRanges.length > 0) logLocalImage(log, "processImagesForOutbound 代码区域", `count=${codeRanges.length}(围栏/行内 code 内的图路径将跳过)`);
288
330
  const mdMatches = [...content.matchAll(LOCAL_IMAGE_RE)];
331
+ const scanPreview = mdMatches.map((m, i) => {
332
+ const p = (m[2] || "").trim();
333
+ const inCode = m.index !== void 0 && isIndexInCodeRange(m.index, codeRanges);
334
+ const kind = classifyMdImgTarget(p);
335
+ return `#${i + 1} kind=${kind} inCode=${inCode} path=${p.slice(0, 120)}`;
336
+ });
337
+ logLocalImage(log, "processImagesForOutbound 扫描 ![]()", scanPreview.length ? scanPreview.join(" || ") : "正文中没有任何 markdown 图片语法");
289
338
  let result = content;
290
339
  /** 本地路径 → 可点 downloadUrl(仅替换正文中的裸路径,不拆消息) */
291
340
  const localPathToDl = /* @__PURE__ */ new Map();
292
341
  let uploadCount = 0;
342
+ let localTried = 0;
343
+ let remoteTried = 0;
344
+ let skippedCode = 0;
345
+ let skippedOther = 0;
293
346
  for (const match of mdMatches) {
294
- if (match.index !== void 0 && isIndexInCodeRange(match.index, codeRanges)) continue;
347
+ if (match.index !== void 0 && isIndexInCodeRange(match.index, codeRanges)) {
348
+ skippedCode++;
349
+ logLocalImage(log, "processImagesForOutbound 跳过代码块内", (match[2] || "").trim().slice(0, 120));
350
+ continue;
351
+ }
295
352
  const [fullMatch, alt, rawPath] = match;
296
353
  const cleanPath = (rawPath || "").replace(/\\ /g, " ").trim();
297
354
  if (!cleanPath) continue;
298
355
  if (isLocalImageRef(cleanPath)) {
356
+ localTried++;
299
357
  const absPath = toLocalPath(cleanPath);
358
+ let exists = false;
359
+ let sizeMB = "-";
360
+ try {
361
+ exists = fs.existsSync(absPath);
362
+ if (exists) sizeMB = (fs.statSync(absPath).size / (1024 * 1024)).toFixed(2);
363
+ } catch (e) {
364
+ logLocalImage(log, "processImagesForOutbound 本地 stat 异常", e?.message || String(e));
365
+ }
366
+ logLocalImage(log, "processImagesForOutbound 处理本地图", `alt=${alt || "-"} raw=${cleanPath.slice(0, 120)} abs=${absPath} exists=${exists} sizeMB=${sizeMB}`);
367
+ if (!exists) {
368
+ result = result.split(fullMatch).join(`📷 *图片文件不存在(${path.basename(absPath)})*`);
369
+ logLocalImage(log, "processImagesForOutbound 本地文件不存在→失败提示", absPath);
370
+ continue;
371
+ }
300
372
  try {
301
373
  const uploadResult = await uploadLocalImageWithFallback(absPath, oapiToken, log);
302
374
  if (uploadResult?.mediaId) {
303
- result = result.split(fullMatch).join(`![${alt || path.basename(absPath)}](${uploadResult.mediaId})`);
375
+ const replacement = `![${alt || path.basename(absPath)}](${uploadResult.mediaId})`;
376
+ result = result.split(fullMatch).join(replacement);
304
377
  uploadCount++;
305
378
  if (uploadResult.downloadUrl) {
306
379
  localPathToDl.set(cleanPath, uploadResult.downloadUrl);
307
380
  localPathToDl.set(absPath, uploadResult.downloadUrl);
308
381
  }
309
- } else result = result.split(fullMatch).join(`📷 *图片上传失败(${path.basename(absPath)})*`);
310
- } catch {
382
+ logLocalImage(log, "processImagesForOutbound 本地→mediaId 成功", `${absPath} → ${uploadResult.mediaId}`);
383
+ } else {
384
+ result = result.split(fullMatch).join(`📷 *图片上传失败(${path.basename(absPath)})*`);
385
+ logLocalImage(log, "processImagesForOutbound 本地上传失败→失败提示", absPath);
386
+ }
387
+ } catch (err) {
311
388
  result = result.split(fullMatch).join(`📷 *图片上传异常(${path.basename(toLocalPath(cleanPath))})*`);
389
+ logLocalImage(log, "processImagesForOutbound 本地上传异常", `${absPath} err=${err?.message || err}`);
312
390
  }
313
391
  continue;
314
392
  }
315
393
  if (looksLikeRemoteUrl(cleanPath)) {
394
+ remoteTried++;
395
+ logLocalImage(log, "processImagesForOutbound 远程图", cleanPath.slice(0, 160));
316
396
  let tmp = null;
317
397
  try {
318
398
  tmp = await downloadRemoteUrlToTemp(cleanPath);
319
- if (!tmp) continue;
399
+ if (!tmp) {
400
+ logLocalImage(log, "processImagesForOutbound 远程下载失败(保留原![]URL)", cleanPath.slice(0, 120));
401
+ continue;
402
+ }
320
403
  const uploadResult = await uploadLocalImageWithFallback(tmp, oapiToken, log);
321
404
  if (uploadResult?.mediaId) {
322
405
  result = result.split(fullMatch).join(`![${alt || "image"}](${uploadResult.mediaId})`);
323
406
  uploadCount++;
324
- console.log(`[DingTalk][LocalImage] ![]远程→mediaId 下载链保留原URL | ${uploadResult.mediaId}`);
325
- }
407
+ logLocalImage(log, "processImagesForOutbound 远程→mediaId 下载链保留原URL", `${cleanPath.slice(0, 80)} → ${uploadResult.mediaId}`);
408
+ } else logLocalImage(log, "processImagesForOutbound 远程上传失败(保留原![]URL)", cleanPath.slice(0, 120));
326
409
  } catch (err) {
327
410
  logLocalImage(log, "远程图处理失败", err?.message || String(err));
328
411
  } finally {
@@ -330,11 +413,23 @@ async function processImagesForOutbound(content, oapiToken, log) {
330
413
  fs.unlinkSync(tmp);
331
414
  } catch {}
332
415
  }
416
+ continue;
333
417
  }
418
+ skippedOther++;
419
+ logLocalImage(log, "processImagesForOutbound 跳过非本地非远程", `kind=${classifyMdImgTarget(cleanPath)} path=${cleanPath.slice(0, 120)}`);
420
+ }
421
+ for (const [p, dl] of localPathToDl) {
422
+ const before = result;
423
+ result = replaceOutsideImageTargets(result, p, dl);
424
+ if (before !== result) logLocalImage(log, "processImagesForOutbound 正文裸本地路径→downloadUrl", `${p.slice(0, 80)} → ${dl}`);
334
425
  }
335
- for (const [p, dl] of localPathToDl) result = replaceOutsideImageTargets(result, p, dl);
336
- console.log(`[DingTalk][LocalImage] processImagesForOutbound 完成 | uploads=${uploadCount} outLen=${result.length}`);
337
- logMediaIdTrace("processImagesForOutbound:out", result);
426
+ const residual = [...result.matchAll(LOCAL_IMAGE_RE)].filter((m) => {
427
+ if (m.index !== void 0 && isIndexInCodeRange(m.index, getMarkdownCodeRanges(result))) return false;
428
+ return isLocalImageRef((m[2] || "").trim());
429
+ }).map((m) => (m[2] || "").trim());
430
+ logLocalImage(log, "processImagesForOutbound 完成", `uploads=${uploadCount} localTried=${localTried} remoteTried=${remoteTried} skippedCode=${skippedCode} skippedOther=${skippedOther} outLen=${result.length}` + (residual.length ? ` ⚠️residualLocal=${residual.length}:${residual.slice(0, 3).join("|")}` : " residualLocal=0"));
431
+ if (residual.length) logLocalImage(log, "processImagesForOutbound ⚠️仍有代码块外本地路径未替换(钉钉必灰图)", residual.join(" | "));
432
+ logMediaIdTrace("processImagesForOutbound:out", result, void 0, true);
338
433
  return {
339
434
  text: result,
340
435
  followUpUrls: []
@@ -1,9 +1,9 @@
1
1
  import { a as resolveDingtalkAccount } from "./accounts-BAzdqkAV.mjs";
2
- import { r as CHANNEL_ID, t as getDingtalkRuntime } from "./runtime-Dj8jSHjE.mjs";
2
+ import { r as CHANNEL_ID, t as getDingtalkRuntime } from "./runtime-I36vhOzx.mjs";
3
3
  import { n as createLoggerFromConfig } from "./logger-mZ9OSbmD.mjs";
4
4
  import { t as dingtalkHttp } from "./http-client-DFWZgO1n.mjs";
5
5
  import { i as getOapiAccessToken } from "./utils-CIfI_3Jh.mjs";
6
- import { S as finalClean, _ as FINAL_TAG, a as sendTextMessage, b as estimateTokens, d as finishAICard, g as unregisterActiveCard, h as streamAICard, i as sendProactive, m as registerActiveCard, p as isQpsLimitError, r as sendMessage, t as sendMarkdownMessage, u as createAICardForTarget, v as PROCESS_TAG, x as extractFinal, y as displayClean } from "./messaging-CpOPEOI8.mjs";
6
+ import { S as finalClean, _ as FINAL_TAG, a as sendTextMessage, b as estimateTokens, d as finishAICard, g as unregisterActiveCard, h as streamAICard, i as sendProactive, m as registerActiveCard, p as isQpsLimitError, r as sendMessage, t as sendMarkdownMessage, u as createAICardForTarget, v as PROCESS_TAG, x as extractFinal, y as displayClean } from "./messaging-ngF20yqM.mjs";
7
7
  import { a as QUEUE_BUSY_ACK_PHRASES, n as normalizeSlashCommand, t as buildSessionContext } from "./session-DJ4jYqPv.mjs";
8
8
  import { d as recallEmotionReply, o as getAccessToken, r as addEmotionReply, s as getOapiAccessToken$1, t as DINGTALK_API } from "./utils-legacy-CALCPP1t.mjs";
9
9
  import "./chunk-upload-BBQgGtcZ.mjs";
@@ -62,7 +62,7 @@ function resolveAgentWorkspaceDir(cfg, agentId) {
62
62
  * 直接复用 media.ts 权威实现,避免 reply 路径与 message 路径两套逻辑分叉。
63
63
  */
64
64
  async function processLocalImages(content, oapiToken, log) {
65
- const { processLocalImages: impl } = await import("./media-ClfWY5Cw.mjs");
65
+ const { processLocalImages: impl } = await import("./media-9LvaywGZ.mjs");
66
66
  return impl(content, oapiToken, log);
67
67
  }
68
68
  //#endregion
@@ -581,14 +581,14 @@ function createDingtalkReplyDispatcher(params) {
581
581
  finalText = await processLocalImages(finalText, oapiToken, log);
582
582
  if (beforeImg !== finalText) console.log(`[DingTalk][LocalImage] closeStreaming 图片处理 | ${beforeImg.length}→${finalText.length}`);
583
583
  try {
584
- const { hasResidualLocalMdImagesOutsideCode } = await import("./media-ClfWY5Cw.mjs");
584
+ const { hasResidualLocalMdImagesOutsideCode } = await import("./media-9LvaywGZ.mjs");
585
585
  if (hasResidualLocalMdImagesOutsideCode(finalText)) console.warn(`[DingTalk][LocalImage] closeStreaming 定稿后仍有代码块外的本地 MD 图未上传,钉钉可能灰图`);
586
586
  } catch {}
587
587
  finalText = await processVideoMarkers(finalText, "", account.config, oapiToken, log, true, target);
588
588
  finalText = await processAudioMarkers(finalText, "", account.config, oapiToken, log, true, target);
589
589
  finalText = await uploadAndReplaceFileMarkers(finalText, "", account.config, oapiToken, log, true, target);
590
590
  log.info(`[DingTalk][closeStreaming] 准备调用 processRawMediaPaths`);
591
- const { processRawMediaPaths } = await import("./media-ClfWY5Cw.mjs");
591
+ const { processRawMediaPaths } = await import("./media-9LvaywGZ.mjs");
592
592
  finalText = await processRawMediaPaths(finalText, account.config, oapiToken, log, target);
593
593
  log.info(`[DingTalk][closeStreaming] processRawMediaPaths 处理完成`);
594
594
  } else log.warn(`[DingTalk][closeStreaming] oapiToken 为空,跳过媒体处理`);
@@ -744,7 +744,7 @@ function createDingtalkReplyDispatcher(params) {
744
744
  const oapiToken = await getOapiAccessToken(account.config);
745
745
  if (oapiToken) {
746
746
  log.info(`[DingTalk][deliver] 检测到 final 响应,准备处理裸露文件路径`);
747
- const { processRawMediaPaths } = await import("./media-ClfWY5Cw.mjs");
747
+ const { processRawMediaPaths } = await import("./media-9LvaywGZ.mjs");
748
748
  text = await processRawMediaPaths(text, account.config, oapiToken, log, target);
749
749
  log.info(`[DingTalk][deliver] 裸露文件路径处理完成`);
750
750
  }
@@ -2184,7 +2184,7 @@ async function handleDingTalkMessageInternal(params) {
2184
2184
  finalText = await processVideoMarkers(finalText, "", config, oapiToken, log, true, mediaTarget);
2185
2185
  finalText = await processAudioMarkers(finalText, "", config, oapiToken, log, true, mediaTarget);
2186
2186
  finalText = await uploadAndReplaceFileMarkers(finalText, "", config, oapiToken, log, true, mediaTarget);
2187
- const { processRawMediaPaths } = await import("./media-ClfWY5Cw.mjs");
2187
+ const { processRawMediaPaths } = await import("./media-9LvaywGZ.mjs");
2188
2188
  finalText = await processRawMediaPaths(finalText, config, oapiToken, log, mediaTarget);
2189
2189
  }
2190
2190
  const textToSend = finalText.trim() || "✅ 任务执行完成(无文本输出)";
@@ -1,4 +1,4 @@
1
- import { c as logMediaIdTrace, d as processLocalImages, u as processImagesForOutbound, y as uploadMediaToDingTalk } from "./media-_qA1Qjt_.mjs";
1
+ import { c as logMediaIdTrace, d as processLocalImages, u as processImagesForOutbound, y as uploadMediaToDingTalk } from "./media-BMNRjseJ.mjs";
2
2
  import { n as createLoggerFromConfig } from "./logger-mZ9OSbmD.mjs";
3
3
  import { t as dingtalkHttp } from "./http-client-DFWZgO1n.mjs";
4
4
  import { i as getOapiAccessToken, r as getAccessToken, t as DINGTALK_API } from "./utils-CIfI_3Jh.mjs";
@@ -848,7 +848,7 @@ async function resolveMediaSourceToLocalFile(mediaUrl, mediaLocalRoots, log) {
848
848
  cleanup: tmp
849
849
  };
850
850
  }
851
- const { toLocalPath } = await import("./media-ClfWY5Cw.mjs");
851
+ const { toLocalPath } = await import("./media-9LvaywGZ.mjs");
852
852
  const _fs = await import("fs");
853
853
  const _path = await import("path");
854
854
  let resolved = toLocalPath(mediaUrl);
@@ -883,14 +883,22 @@ async function sendTextToDingTalk(params) {
883
883
  };
884
884
  }
885
885
  const targetParam = resolveOutboundTarget(target);
886
+ console.log(`[DingTalk][LocalImage] sendTextToDingTalk 入口 | target=${target} textLen=${text?.length ?? 0}`);
887
+ logMediaIdTrace("sendTextToDingTalk:in", text, `target=${target}`, true);
888
+ const beforeLen = text?.length ?? 0;
886
889
  try {
887
890
  const oapiToken = await getOapiAccessToken(config);
888
- if (oapiToken && text) text = (await processImagesForOutbound(text, oapiToken, log)).text;
891
+ console.log(`[DingTalk][LocalImage] sendTextToDingTalk 入口 | target=${target} textLen=${beforeLen} hasToken=${!!oapiToken}`);
892
+ if (oapiToken && text) {
893
+ text = (await processImagesForOutbound(text, oapiToken, log)).text;
894
+ console.log(`[DingTalk][LocalImage] sendTextToDingTalk 处理后 | ${beforeLen}→${text.length} 字`);
895
+ logMediaIdTrace("sendTextToDingTalk:after-process", text, void 0, true);
896
+ } else if (!oapiToken) console.warn(`[DingTalk][LocalImage] sendTextToDingTalk 无 oapiToken,跳过图片上传(本地 MD 图会灰)`);
889
897
  } catch (err) {
890
898
  console.warn(`[DingTalk][LocalImage] sendTextToDingTalk 图片处理失败: ${err?.message || err}`);
891
899
  }
892
900
  const msgType = shouldSendAsMarkdown(text) ? "markdown" : "text";
893
- logMediaIdTrace("sendTextToDingTalk:API前", text, `msgType=${msgType}`);
901
+ logMediaIdTrace("sendTextToDingTalk:API前", text, `msgType=${msgType}`, true);
894
902
  return sendProactive(config, targetParam, text, {
895
903
  msgType,
896
904
  replyToId,
@@ -1113,7 +1121,7 @@ async function sendMediaToDingTalk(params) {
1113
1121
  const fileName = mediaUrl.split("/").filter(Boolean).pop()?.split("?")[0] || "file";
1114
1122
  if (mediaType === "video") {
1115
1123
  const videoMarker = `[DINGTALK_VIDEO]{"path":"${resolvedMediaUrl}"}[/DINGTALK_VIDEO]`;
1116
- const { processVideoMarkers } = await import("./media-ClfWY5Cw.mjs");
1124
+ const { processVideoMarkers } = await import("./media-9LvaywGZ.mjs");
1117
1125
  await processVideoMarkers(videoMarker, "", config, oapiToken, console, true, targetParam);
1118
1126
  return {
1119
1127
  ok: true,
@@ -1132,7 +1140,7 @@ async function sendMediaToDingTalk(params) {
1132
1140
  fileName,
1133
1141
  fileType: ext && ext.length < 12 ? ext : "file"
1134
1142
  };
1135
- const { sendFileProactive } = await import("./media-ClfWY5Cw.mjs");
1143
+ const { sendFileProactive } = await import("./media-9LvaywGZ.mjs");
1136
1144
  await sendFileProactive(config, targetParam, fileInfo, uploadResult.mediaId, log);
1137
1145
  return {
1138
1146
  ok: true,
@@ -1,9 +1,9 @@
1
- import { b as __exportAll } from "./media-_qA1Qjt_.mjs";
1
+ import { b as __exportAll } from "./media-BMNRjseJ.mjs";
2
2
  import { a as resolveDingtalkAccount, c as addWildcardAllowFrom, d as hasConfiguredSecretInput, f as normalizeAccountId, l as createDefaultChannelRuntimeState, m as resolveDefaultGroupPolicy, o as resolveDingtalkCredentials, p as resolveAllowlistProviderRuntimeGroupPolicy, r as resolveDefaultDingtalkAccountId, s as DEFAULT_ACCOUNT_ID, t as listDingtalkAccountIds, u as formatDocsLink } from "./accounts-BAzdqkAV.mjs";
3
3
  import { t as createLogger } from "./logger-mZ9OSbmD.mjs";
4
4
  import { t as dingtalkHttp } from "./http-client-DFWZgO1n.mjs";
5
5
  import "./utils-CIfI_3Jh.mjs";
6
- import { S as finalClean, f as getActiveCardForConversation, n as sendMediaToDingTalk, o as sendTextToDingTalk } from "./messaging-CpOPEOI8.mjs";
6
+ import { S as finalClean, f as getActiveCardForConversation, n as sendMediaToDingTalk, o as sendTextToDingTalk } from "./messaging-ngF20yqM.mjs";
7
7
  import { createRequire } from "node:module";
8
8
  import { z, z as z$1 } from "zod";
9
9
  //#region src/secret-input.ts
@@ -930,7 +930,7 @@ async function monitorDingtalkProvider(opts = {}) {
930
930
  const log = createLogger(cfg.channels?.["dingtalk-connector"]?.debug ?? false);
931
931
  const [accountsModule, monitorAccountModule, monitorSingleModule] = await Promise.all([
932
932
  import("./accounts-BQptOmgB.mjs"),
933
- import("./message-handler-BKmyuozN.mjs"),
933
+ import("./message-handler-CytB34ZJ.mjs"),
934
934
  import("./connection-DHHFFNQJ.mjs")
935
935
  ]);
936
936
  const { resolveDingtalkAccount, listEnabledDingtalkAccounts } = accountsModule;
@@ -0,0 +1,45 @@
1
+ # Release Notes — v0.8.21-fix46(2026-07-20)
2
+
3
+ 生产稳定版 / Production-stable community build of `@jeik/dingtalk-connector`。
4
+
5
+ 在 **fix45** 行为不变的基础上,补强 **message 本地 MD 图** 诊断日志,方便排查灰图。
6
+
7
+ ## 安装 / Install
8
+
9
+ ```bash
10
+ npx -y @jeik/dingtalk-connector install --force
11
+ openclaw gateway restart
12
+ ```
13
+
14
+ 本地 tgz:
15
+
16
+ ```bash
17
+ openclaw plugins install ./jeik-dingtalk-connector-0.8.21-fix46.tgz --force
18
+ openclaw gateway restart
19
+ ```
20
+
21
+ ## 本版要点 / Highlights
22
+
23
+ | 领域 | 说明 |
24
+ |------|------|
25
+ | 诊断日志 | always-on 本地图流水线:匹配 → exists → 上传 → API 前 payload → residual |
26
+ | MediaIdTrace | `mdImgs=[local:…\|mediaId:…]`、`residualLocal`、preview |
27
+ | 业务逻辑 | **与 fix45 相同**(图+下载同泡、只认 `![]`、代码块跳过、CardCache) |
28
+
29
+ ## 查灰图时看哪些行
30
+
31
+ ```text
32
+ [DingTalk][LocalImage] sendTextToDingTalk 入口 | ...
33
+ [DingTalk][LocalImage] processImagesForOutbound 扫描 ![]() | #1 kind=local exists=...
34
+ [DingTalk][LocalImage] processImagesForOutbound 本地→mediaId 成功 | ... → @lADP...
35
+ [DingTalk][MediaIdTrace] sendTextToDingTalk:API前 | mdImgs=[mediaId:@lADP...] residualLocal=0
36
+ ```
37
+
38
+ - `kind=local` 但 `exists=false` → 路径在 gateway 机上不存在
39
+ - 上传失败 → 会看到失败提示 / fallback 第2次
40
+ - API前仍是 `local:/tmp/...` 或 `residualLocal>0` → 未换成 mediaId
41
+ - API前已是 `mediaId:@lADP...` 仍灰 → 偏钉钉渲染/账号侧
42
+
43
+ ## 升级
44
+
45
+ 从 fix45 直接 `--force` 安装即可。
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jeik/dingtalk-connector",
3
- "version": "0.8.21-fix45",
3
+ "version": "0.8.21-fix46",
4
4
  "description": "OpenClaw DingTalk channel plugin (fork) | 钉钉 OpenClaw 渠道插件(fork 自官方,含标记/卡片渲染修复)",
5
5
  "type": "module",
6
6
  "exports": {
@@ -211,8 +211,9 @@ export interface UploadResult {
211
211
  }
212
212
 
213
213
  /**
214
- * 本地图诊断:默认只 console 一行,不再 log.warn(避免 [sendTextToDingTalk] 双前缀刷屏)。
215
- * verbose 步骤(文件可读/realpath 等)仅 debug 时通过 log 输出。
214
+ * 本地图诊断:默认 console.log 一行(不走 log.warn,避免 [sendTextToDingTalk] 双前缀)。
215
+ * verbose=true 的细节(realpath 等)仅在 debug 时输出。
216
+ * 决策路径(匹配/exists/上传成败/残留)务必 always-on,方便查灰图。
216
217
  */
217
218
  function logLocalImage(log: any, step: string, detail?: string, verbose = false) {
218
219
  const line = detail
@@ -229,12 +230,22 @@ function logLocalImage(log: any, step: string, detail?: string, verbose = false)
229
230
  console.log(line);
230
231
  }
231
232
 
232
- /** 钉钉 mediaId 形态 */
233
+ /** 钉钉 mediaId 形态(oapi 返回常带 @) */
233
234
  const MEDIA_ID_RE = /@[A-Za-z0-9_-]{8,}/g;
234
235
 
236
+ function classifyMdImgTarget(target: string): "mediaId" | "http" | "local" | "other" {
237
+ const t = (target || "").trim();
238
+ if (!t) return "other";
239
+ if (t.startsWith("@") && !t.includes("/") && !t.includes("\\")) return "mediaId";
240
+ if (looksLikeRemoteUrl(t) || /^https?:\/\//i.test(t)) return "http";
241
+ if (isLocalImageRef(t)) return "local";
242
+ return "other";
243
+ }
244
+
235
245
  /**
236
- * 精简 MediaId 追踪:默认只在关键节点 / 异常时打一行。
237
- * force=true 或发现「裸 mediaId」时才输出。
246
+ * MediaId 追踪:关键节点 / force / 裸 mediaId 时输出。
247
+ * 固定字段:mediaIdCount / ids / BARE_ids / mdImgs / httpCount / residualLocal / preview
248
+ * (灰图排查:看 mdImgs 是 path:本地 还是 mediaId:@xxx,以及 residualLocal)
238
249
  */
239
250
  export function logMediaIdTrace(
240
251
  stage: string,
@@ -244,23 +255,42 @@ export function logMediaIdTrace(
244
255
  ): void {
245
256
  const text = typeof content === "string" ? content : "";
246
257
  const ids = [...new Set([...text.matchAll(MEDIA_ID_RE)].map((m) => m[0]))];
247
- const inMd = new Set(
248
- [...text.matchAll(LOCAL_IMAGE_RE)].map((m) => (m[2] || "").trim()),
249
- );
258
+ const mdTargets = [...text.matchAll(LOCAL_IMAGE_RE)].map((m) => (m[2] || "").trim());
259
+ const inMd = new Set(mdTargets);
250
260
  const bareIds = ids.filter((id) => !inMd.has(id));
261
+ const mdImgs = mdTargets.slice(0, 8).map((t) => {
262
+ const kind = classifyMdImgTarget(t);
263
+ const short = t.length > 100 ? `${t.slice(0, 100)}…` : t;
264
+ return `${kind}:${short}`;
265
+ });
266
+ const residualLocal = mdTargets.filter((t) => isLocalImageRef(t));
267
+ const httpCount = (text.match(/https?:\/\//gi) || []).length;
251
268
  // sampleImageMsg 的 photoURL 就是裸 mediaId,不算异常
252
269
  const isImageMsg = /type=image|sampleImage|photoURL/i.test(`${stage} ${extra || ""}`);
253
270
  const suspiciousBare = isImageMsg ? [] : bareIds;
254
- if (!force && suspiciousBare.length === 0 && !stage.includes("API前") && !stage.includes("完成")) {
255
- return;
256
- }
257
- const preview = text.replace(/\n/g, "\\n").slice(0, 160);
271
+ const always =
272
+ force ||
273
+ suspiciousBare.length > 0 ||
274
+ residualLocal.length > 0 ||
275
+ /API前|完成|:out|:in|after-process|processImages/i.test(stage);
276
+ if (!always) return;
277
+
278
+ const preview = text.replace(/\n/g, "\\n").slice(0, 200);
258
279
  console.log(
259
280
  `[DingTalk][MediaIdTrace] ${stage} | len=${text.length}` +
260
- (ids.length ? ` mediaIds=${ids.length}` : "") +
261
- (suspiciousBare.length ? ` ⚠️BARE=${suspiciousBare.join(",")}` : "") +
281
+ ` mediaIdCount=${ids.length}` +
282
+ (ids.length ? ` ids=[${ids.slice(0, 4).join(",")}]` : "") +
283
+ ` BARE_ids=${suspiciousBare.length ? suspiciousBare.join(",") : "无"}` +
284
+ ` mdImgs=[${mdImgs.length ? mdImgs.join(" || ") : "-"}]` +
285
+ ` httpCount=${httpCount}` +
286
+ (residualLocal.length
287
+ ? ` residualLocal=${residualLocal.length}:${residualLocal
288
+ .slice(0, 3)
289
+ .map((p) => p.slice(0, 80))
290
+ .join("|")}`
291
+ : " residualLocal=0") +
262
292
  (extra ? ` ${extra}` : "") +
263
- ` | ${preview}`,
293
+ ` preview="${preview}"`,
264
294
  );
265
295
  }
266
296
 
@@ -298,7 +328,8 @@ export async function uploadMediaToDingTalk(
298
328
  const tag = `uploadMedia type=${mediaType}`;
299
329
  try {
300
330
  let absPath = toLocalPath(filePath);
301
- logLocalImage(log, `${tag} 开始`, `path=${absPath}`, true);
331
+ // 上传起止 always-on:灰图时必须能看到是否走到 OAPI、文件是否可读
332
+ logLocalImage(log, `${tag} 开始`, `path=${absPath}`);
302
333
 
303
334
  if (!fs.existsSync(absPath)) {
304
335
  logLocalImage(log, `${tag} 失败:文件不存在`, absPath);
@@ -309,7 +340,7 @@ export async function uploadMediaToDingTalk(
309
340
  try {
310
341
  const real = fs.realpathSync(absPath);
311
342
  if (real !== absPath) {
312
- logLocalImage(log, `${tag} realpath`, `${absPath} → ${real}`, true);
343
+ logLocalImage(log, `${tag} realpath`, `${absPath} → ${real}`);
313
344
  }
314
345
  absPath = real;
315
346
  } catch (e: any) {
@@ -319,7 +350,7 @@ export async function uploadMediaToDingTalk(
319
350
  // 检查文件大小
320
351
  const stats = fs.statSync(absPath);
321
352
  const fileSizeMB = (stats.size / (1024 * 1024)).toFixed(2);
322
- logLocalImage(log, `${tag} 文件可读`, `size=${fileSizeMB}MB`, true);
353
+ logLocalImage(log, `${tag} 文件可读`, `size=${fileSizeMB}MB mode=${stats.mode}`);
323
354
 
324
355
  // 钉钉 image 类型官方上限约 10MB;调用方若传入更大 limit 也按类型收紧
325
356
  const effectiveMax =
@@ -429,13 +460,17 @@ export async function uploadLocalImageWithFallback(
429
460
  log?: any,
430
461
  ): Promise<UploadResult | null> {
431
462
  const absPath = toLocalPath(filePath);
432
- if (!fs.existsSync(absPath)) {
463
+ const exists = fs.existsSync(absPath);
464
+ logLocalImage(log, "fallback 入口", `path=${absPath} exists=${exists}`);
465
+ if (!exists) {
433
466
  logLocalImage(log, "fallback 失败:文件不存在", absPath);
434
467
  return null;
435
468
  }
436
469
 
470
+ logLocalImage(log, "fallback 第1次:直接上传", absPath);
437
471
  let result = await uploadMediaToDingTalk(absPath, "image", oapiToken, 10 * 1024 * 1024, log);
438
472
  if (result?.mediaId) {
473
+ logLocalImage(log, "fallback 第1次成功", result.mediaId);
439
474
  return result;
440
475
  }
441
476
 
@@ -447,7 +482,13 @@ export async function uploadLocalImageWithFallback(
447
482
  );
448
483
  try {
449
484
  fs.copyFileSync(absPath, tmp);
485
+ logLocalImage(log, "fallback 第2次:tmp重试", `${absPath} → ${tmp}`);
450
486
  result = await uploadMediaToDingTalk(tmp, "image", oapiToken, 10 * 1024 * 1024, log);
487
+ if (result?.mediaId) {
488
+ logLocalImage(log, "fallback 第2次成功", result.mediaId);
489
+ } else {
490
+ logLocalImage(log, "fallback 第2次仍失败", tmp);
491
+ }
451
492
  return result;
452
493
  } catch (err: any) {
453
494
  logLocalImage(
@@ -543,68 +584,158 @@ function replaceOutsideImageTargets(
543
584
  * (钉钉改写发生在「![] 与正文共用同一 http」时;![] 改成 mediaId 后即可同泡共存)
544
585
  * - 本地:正文重复的本地路径换成 down.dingtalk.com 可点链接(仍同一条消息)
545
586
  * - **不再拆第二条 text 消息**
587
+ *
588
+ * 日志约定(always-on,查灰图必看):
589
+ * - 开始 / 扫描每张 ![] / 本地 exists / 上传成败 / 远程下载 / 残留 / out
546
590
  */
547
591
  export async function processImagesForOutbound(
548
592
  content: string,
549
593
  oapiToken: string | null,
550
594
  log?: any,
551
595
  ): Promise<ProcessImagesOutboundResult> {
552
- if (!oapiToken || !content || typeof content !== "string") {
596
+ logLocalImage(
597
+ log,
598
+ "processImagesForOutbound 开始",
599
+ `contentLen=${content?.length ?? 0} hasToken=${!!oapiToken}`,
600
+ );
601
+ if (!oapiToken) {
602
+ logLocalImage(log, "processImagesForOutbound 跳过", "无 oapiToken(本地路径无法上传→必灰图)");
603
+ logMediaIdTrace("processImagesForOutbound:skip-no-token", content, undefined, true);
604
+ return { text: content || "", followUpUrls: [] };
605
+ }
606
+ if (!content || typeof content !== "string") {
607
+ logLocalImage(log, "processImagesForOutbound 跳过", "content 为空");
553
608
  return { text: content || "", followUpUrls: [] };
554
609
  }
555
610
 
611
+ logMediaIdTrace("processImagesForOutbound:in", content, undefined, true);
612
+
556
613
  const codeRanges = getMarkdownCodeRanges(content);
614
+ if (codeRanges.length > 0) {
615
+ logLocalImage(
616
+ log,
617
+ "processImagesForOutbound 代码区域",
618
+ `count=${codeRanges.length}(围栏/行内 code 内的图路径将跳过)`,
619
+ );
620
+ }
621
+
557
622
  const mdMatches = [...content.matchAll(LOCAL_IMAGE_RE)];
623
+ const scanPreview = mdMatches.map((m, i) => {
624
+ const p = (m[2] || "").trim();
625
+ const inCode = m.index !== undefined && isIndexInCodeRange(m.index, codeRanges);
626
+ const kind = classifyMdImgTarget(p);
627
+ return `#${i + 1} kind=${kind} inCode=${inCode} path=${p.slice(0, 120)}`;
628
+ });
629
+ logLocalImage(
630
+ log,
631
+ "processImagesForOutbound 扫描 ![]()",
632
+ scanPreview.length ? scanPreview.join(" || ") : "正文中没有任何 markdown 图片语法",
633
+ );
634
+
558
635
  let result = content;
559
636
  /** 本地路径 → 可点 downloadUrl(仅替换正文中的裸路径,不拆消息) */
560
637
  const localPathToDl = new Map<string, string>();
561
638
  let uploadCount = 0;
639
+ let localTried = 0;
640
+ let remoteTried = 0;
641
+ let skippedCode = 0;
642
+ let skippedOther = 0;
562
643
 
563
644
  for (const match of mdMatches) {
564
- if (match.index !== undefined && isIndexInCodeRange(match.index, codeRanges)) continue;
645
+ if (match.index !== undefined && isIndexInCodeRange(match.index, codeRanges)) {
646
+ skippedCode++;
647
+ logLocalImage(
648
+ log,
649
+ "processImagesForOutbound 跳过代码块内",
650
+ (match[2] || "").trim().slice(0, 120),
651
+ );
652
+ continue;
653
+ }
565
654
  const [fullMatch, alt, rawPath] = match;
566
655
  const cleanPath = (rawPath || "").replace(/\\ /g, " ").trim();
567
656
  if (!cleanPath) continue;
568
657
 
569
658
  if (isLocalImageRef(cleanPath)) {
659
+ localTried++;
570
660
  const absPath = toLocalPath(cleanPath);
661
+ let exists = false;
662
+ let sizeMB = "-";
663
+ try {
664
+ exists = fs.existsSync(absPath);
665
+ if (exists) {
666
+ sizeMB = (fs.statSync(absPath).size / (1024 * 1024)).toFixed(2);
667
+ }
668
+ } catch (e: any) {
669
+ logLocalImage(log, "processImagesForOutbound 本地 stat 异常", e?.message || String(e));
670
+ }
671
+ logLocalImage(
672
+ log,
673
+ "processImagesForOutbound 处理本地图",
674
+ `alt=${alt || "-"} raw=${cleanPath.slice(0, 120)} abs=${absPath} exists=${exists} sizeMB=${sizeMB}`,
675
+ );
676
+ if (!exists) {
677
+ result = result
678
+ .split(fullMatch)
679
+ .join(`📷 *图片文件不存在(${path.basename(absPath)})*`);
680
+ logLocalImage(log, "processImagesForOutbound 本地文件不存在→失败提示", absPath);
681
+ continue;
682
+ }
571
683
  try {
572
684
  const uploadResult = await uploadLocalImageWithFallback(absPath, oapiToken, log);
573
685
  if (uploadResult?.mediaId) {
574
- result = result
575
- .split(fullMatch)
576
- .join(`![${alt || path.basename(absPath)}](${uploadResult.mediaId})`);
686
+ const replacement = `![${alt || path.basename(absPath)}](${uploadResult.mediaId})`;
687
+ result = result.split(fullMatch).join(replacement);
577
688
  uploadCount++;
578
689
  if (uploadResult.downloadUrl) {
579
690
  localPathToDl.set(cleanPath, uploadResult.downloadUrl);
580
691
  localPathToDl.set(absPath, uploadResult.downloadUrl);
581
692
  }
693
+ logLocalImage(
694
+ log,
695
+ "processImagesForOutbound 本地→mediaId 成功",
696
+ `${absPath} → ${uploadResult.mediaId}`,
697
+ );
582
698
  } else {
583
699
  result = result
584
700
  .split(fullMatch)
585
701
  .join(`📷 *图片上传失败(${path.basename(absPath)})*`);
702
+ logLocalImage(log, "processImagesForOutbound 本地上传失败→失败提示", absPath);
586
703
  }
587
- } catch {
704
+ } catch (err: any) {
588
705
  result = result
589
706
  .split(fullMatch)
590
707
  .join(`📷 *图片上传异常(${path.basename(toLocalPath(cleanPath))})*`);
708
+ logLocalImage(
709
+ log,
710
+ "processImagesForOutbound 本地上传异常",
711
+ `${absPath} err=${err?.message || err}`,
712
+ );
591
713
  }
592
714
  continue;
593
715
  }
594
716
 
595
717
  if (looksLikeRemoteUrl(cleanPath)) {
718
+ remoteTried++;
719
+ logLocalImage(log, "processImagesForOutbound 远程图", cleanPath.slice(0, 160));
596
720
  let tmp: string | null = null;
597
721
  try {
598
722
  tmp = await downloadRemoteUrlToTemp(cleanPath);
599
- if (!tmp) continue;
723
+ if (!tmp) {
724
+ logLocalImage(log, "processImagesForOutbound 远程下载失败(保留原![]URL)", cleanPath.slice(0, 120));
725
+ continue;
726
+ }
600
727
  const uploadResult = await uploadLocalImageWithFallback(tmp, oapiToken, log);
601
728
  if (uploadResult?.mediaId) {
602
729
  // 只改 ![] 为 mediaId;正文「下载链接:http://…」保持原 URL,同一条消息
603
730
  result = result.split(fullMatch).join(`![${alt || "image"}](${uploadResult.mediaId})`);
604
731
  uploadCount++;
605
- console.log(
606
- `[DingTalk][LocalImage] ![]远程→mediaId 下载链保留原URL | ${uploadResult.mediaId}`,
732
+ logLocalImage(
733
+ log,
734
+ "processImagesForOutbound 远程→mediaId 下载链保留原URL",
735
+ `${cleanPath.slice(0, 80)} → ${uploadResult.mediaId}`,
607
736
  );
737
+ } else {
738
+ logLocalImage(log, "processImagesForOutbound 远程上传失败(保留原![]URL)", cleanPath.slice(0, 120));
608
739
  }
609
740
  } catch (err: any) {
610
741
  logLocalImage(log, "远程图处理失败", err?.message || String(err));
@@ -617,18 +748,55 @@ export async function processImagesForOutbound(
617
748
  }
618
749
  }
619
750
  }
751
+ continue;
620
752
  }
753
+
754
+ skippedOther++;
755
+ logLocalImage(
756
+ log,
757
+ "processImagesForOutbound 跳过非本地非远程",
758
+ `kind=${classifyMdImgTarget(cleanPath)} path=${cleanPath.slice(0, 120)}`,
759
+ );
621
760
  }
622
761
 
623
762
  // 本地路径若在「下载链接」等处再出现:换成可点 downloadUrl(仍同一气泡)
624
763
  for (const [p, dl] of localPathToDl) {
764
+ const before = result;
625
765
  result = replaceOutsideImageTargets(result, p, dl);
766
+ if (before !== result) {
767
+ logLocalImage(
768
+ log,
769
+ "processImagesForOutbound 正文裸本地路径→downloadUrl",
770
+ `${p.slice(0, 80)} → ${dl}`,
771
+ );
772
+ }
626
773
  }
627
774
 
628
- console.log(
629
- `[DingTalk][LocalImage] processImagesForOutbound 完成 | uploads=${uploadCount} outLen=${result.length}`,
775
+ const residual = [...result.matchAll(LOCAL_IMAGE_RE)]
776
+ .filter((m) => {
777
+ if (m.index !== undefined && isIndexInCodeRange(m.index, getMarkdownCodeRanges(result))) {
778
+ return false;
779
+ }
780
+ return isLocalImageRef((m[2] || "").trim());
781
+ })
782
+ .map((m) => (m[2] || "").trim());
783
+
784
+ logLocalImage(
785
+ log,
786
+ "processImagesForOutbound 完成",
787
+ `uploads=${uploadCount} localTried=${localTried} remoteTried=${remoteTried} skippedCode=${skippedCode} skippedOther=${skippedOther} outLen=${result.length}` +
788
+ (residual.length
789
+ ? ` ⚠️residualLocal=${residual.length}:${residual.slice(0, 3).join("|")}`
790
+ : " residualLocal=0"),
630
791
  );
631
- logMediaIdTrace("processImagesForOutbound:out", result);
792
+ if (residual.length) {
793
+ logLocalImage(
794
+ log,
795
+ "processImagesForOutbound ⚠️仍有代码块外本地路径未替换(钉钉必灰图)",
796
+ residual.join(" | "),
797
+ );
798
+ }
799
+ logMediaIdTrace("processImagesForOutbound:out", result, undefined, true);
632
800
 
633
801
  return { text: result, followUpUrls: [] };
634
802
  }
@@ -842,12 +842,29 @@ export async function sendTextToDingTalk(params: {
842
842
 
843
843
  const targetParam = resolveOutboundTarget(target);
844
844
 
845
+ console.log(
846
+ `[DingTalk][LocalImage] sendTextToDingTalk 入口 | target=${target} textLen=${text?.length ?? 0}`,
847
+ );
848
+ logMediaIdTrace("sendTextToDingTalk:in", text, `target=${target}`, true);
849
+
845
850
  // 图片:![] 上传为 mediaId;下载链接原 URL 留在同一条 markdown(不拆消息)
851
+ const beforeLen = text?.length ?? 0;
846
852
  try {
847
853
  const oapiToken = await getOapiAccessToken(config);
854
+ console.log(
855
+ `[DingTalk][LocalImage] sendTextToDingTalk 入口 | target=${target} textLen=${beforeLen} hasToken=${!!oapiToken}`,
856
+ );
848
857
  if (oapiToken && text) {
849
858
  const processed = await processImagesForOutbound(text, oapiToken, log);
850
859
  text = processed.text;
860
+ console.log(
861
+ `[DingTalk][LocalImage] sendTextToDingTalk 处理后 | ${beforeLen}→${text.length} 字`,
862
+ );
863
+ logMediaIdTrace("sendTextToDingTalk:after-process", text, undefined, true);
864
+ } else if (!oapiToken) {
865
+ console.warn(
866
+ `[DingTalk][LocalImage] sendTextToDingTalk 无 oapiToken,跳过图片上传(本地 MD 图会灰)`,
867
+ );
851
868
  }
852
869
  } catch (err: any) {
853
870
  console.warn(
@@ -856,7 +873,7 @@ export async function sendTextToDingTalk(params: {
856
873
  }
857
874
 
858
875
  const msgType: DingTalkMsgType = shouldSendAsMarkdown(text) ? "markdown" : "text";
859
- logMediaIdTrace("sendTextToDingTalk:API前", text, `msgType=${msgType}`);
876
+ logMediaIdTrace("sendTextToDingTalk:API前", text, `msgType=${msgType}`, true);
860
877
 
861
878
  return sendProactive(config, targetParam, text, {
862
879
  msgType,