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

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,29 @@ 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-fix45] - 2026-07-20
9
+
10
+ 生产稳定版 / Production-stable community release。
11
+
12
+ ### 修复 / Fixed
13
+ - 🐛 **message 图 + 下载链接被改成 mediaId** — 钉钉 `sampleMarkdown` 在「`![](http)` 与正文同一 URL」时会把下载位改写成 `@lADP…`。现:`![](http)` **下载上传 → mediaId**;**下载链接原 URL 留在同一条消息**(不拆第二条气泡)。
14
+ - 🐛 **只处理明确 `![]()`** — 删除裸路径自动扫描(与代码块误转 mediaId 同类根因)。
15
+ - 🐛 **closeStreaming 误报「仍含本地路径 MD 图」** — 代码块/示例 JSON 里的 `/tmp/...` 不再告警。
16
+ - 🐛 **引用 AI 卡片无正文** — 定稿缓存 `outTrackId → 正文`,引用时按会话回填(`CardCache`)。
17
+ - 🐛 **message `media=https://...`** — 远程 media 先下载再上传(fix38)。
18
+
19
+ ### 日志 / Logs
20
+ - 精简 `[DingTalk][LocalImage]` / `[MediaIdTrace]`(去掉双前缀刷屏,保留 API 前关键行)。
21
+
22
+ ### 安装 / Install
23
+ ```bash
24
+ npx -y @jeik/dingtalk-connector install --force
25
+ openclaw gateway restart
26
+ ```
27
+
28
+ ### 从 fix38~fix44 升级
29
+ 直接 `--force` 装 fix45 即可,包含上述全部修复。
30
+
8
31
  ## [0.8.21-fix38] - 2026-07-20
9
32
 
10
33
  ### 修复 / Fixed
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-fix38</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-fix45</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,20 +23,25 @@
23
23
 
24
24
  ## 🔧 Recent Updates
25
25
 
26
- ### 🚀 v0.8.21-fix38 · 2026-07-20 (current)
26
+ ### 🚀 v0.8.21-fix45 · 2026-07-20 (current)
27
27
 
28
- **Theme: message tool remote https media**
28
+ **Theme: image + download link in one bubble · only `![]` · quote card cache**
29
29
 
30
30
  | | Change |
31
31
  |--|--------|
32
- | 🌐 **Remote media** | `media: "https://..."` is **downloaded then uploaded** (no more local-path “file not found”) |
33
- | 🖼️ **Extensionless hosts** | e.g. `picsum.photos/seed/...` treated as **image** |
34
- | 🛟 **Fallback** | photoURL direct send if download/re-upload fails; logs `[DingTalk][RemoteMedia]` |
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 |
35
36
 
36
37
  ```bash
37
38
  npx -y @jeik/dingtalk-connector install --force && openclaw gateway restart
38
39
  ```
39
40
 
41
+ ### 📦 v0.8.21-fix38 · 2026-07-20
42
+
43
+ Remote `media` download-then-upload.
44
+
40
45
  ### 📦 v0.8.21-fix37 · 2026-07-20
41
46
 
42
47
  **Theme: local images (incl. `/mnt`) · message media layout · diagnostics**
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-fix38</strong>(稳定生产可用;一键安装:`npx -y @jeik/dingtalk-connector install`;本地 tgz:`openclaw plugins install ./jeik-dingtalk-connector-0.8.21-fix38.tgz --force`)</p>
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>
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,20 +23,25 @@
23
23
 
24
24
  ## 🔧 最近更新
25
25
 
26
- ### 🚀 v0.8.21-fix38 · 2026-07-20(当前)
26
+ ### 🚀 v0.8.21-fix45 · 2026-07-20(当前)
27
27
 
28
- **主题:message 工具远程 https 媒体**
28
+ **主题:图 + 下载链接单气泡 · 只认 `![]` · 引用卡片缓存**
29
29
 
30
30
  | | 改动 |
31
31
  |--|------|
32
- | 🌐 **远程 media** | `media: "https://..."` **先下载再上传**,不再当本地路径报「文件不存在」 |
33
- | 🖼️ **无扩展名图床** | 如 `picsum.photos/seed/...` 默认按 **image**(不再误判 file) |
34
- | 🛟 **兜底** | 下载/再上传失败时尝试 photoURL 直链;日志前缀 `[DingTalk][RemoteMedia]` |
32
+ | 📷 **图 + 下载链** | `![](http)` 上传为 mediaId;**下载链接原 URL 留在同一条消息**(不被钉钉改成 `@lADP`) |
33
+ | ✅ **只处理 `![]()`** | 不扫裸路径;代码块内路径不上传、不误报灰图 |
34
+ | 📎 **引用 AI 卡** | 定稿缓存正文,引用时回填(`CardCache`) |
35
+ | 🌐 **message media** | `media: "https://..."` 下载再上传 |
35
36
 
36
37
  ```bash
37
38
  npx -y @jeik/dingtalk-connector install --force && openclaw gateway restart
38
39
  ```
39
40
 
41
+ ### 📦 v0.8.21-fix38 · 2026-07-20
42
+
43
+ message 远程 `media` 下载上传。
44
+
40
45
  ### 📦 v0.8.21-fix37 · 2026-07-20
41
46
 
42
47
  **主题:本地图(含 `/mnt`)· message 文图策略 · 可观测日志**
@@ -59,7 +64,7 @@ npx -y @jeik/dingtalk-connector install --force && openclaw gateway restart
59
64
  | 2026-06-28 | 上线 npm `@jeik/dingtalk-connector`;过程消息提前定稿修复 |
60
65
  | 2026-05 | MD 直链/本地图;多轮刷屏;4.29+ 无文本输出;WebSocket 幻影重连 |
61
66
 
62
- 完整说明:[CHANGELOG.md](CHANGELOG.md) · [FIXES.md](FIXES.md) · [Release fix38](docs/RELEASE_NOTES_V0.8.21-fix38.md)
67
+ 完整说明:[CHANGELOG.md](CHANGELOG.md) · [FIXES.md](FIXES.md) · [Release fix45](docs/RELEASE_NOTES_V0.8.21-fix45.md)
63
68
 
64
69
  ---
65
70
 
@@ -0,0 +1,106 @@
1
+ //#region src/services/messaging/card-content-cache.ts
2
+ const MAX_ENTRIES = 300;
3
+ /** 默认保留 7 天(引用历史卡片) */
4
+ const DEFAULT_TTL_MS = 10080 * 60 * 1e3;
5
+ const byKey = /* @__PURE__ */ new Map();
6
+ /** 每个会话最近若干条(新→旧) */
7
+ const byConversation = /* @__PURE__ */ new Map();
8
+ function pruneExpired(now = Date.now(), ttlMs = DEFAULT_TTL_MS) {
9
+ for (const [k, v] of byKey) if (now - v.at > ttlMs) byKey.delete(k);
10
+ if (byKey.size <= MAX_ENTRIES) return;
11
+ const sorted = [...byKey.entries()].sort((a, b) => a[1].at - b[1].at);
12
+ const drop = sorted.length - MAX_ENTRIES;
13
+ for (let i = 0; i < drop; i++) byKey.delete(sorted[i][0]);
14
+ }
15
+ function normalizeKey(kind, id) {
16
+ return `${kind}:${id.trim()}`;
17
+ }
18
+ /**
19
+ * 记住一张卡的终稿正文
20
+ */
21
+ function rememberCardContent(params) {
22
+ const text = (params.text || "").trim();
23
+ if (!text) return;
24
+ const entry = {
25
+ text,
26
+ at: Date.now(),
27
+ conversationId: params.conversationId,
28
+ outTrackId: params.outTrackId,
29
+ msgId: params.msgId
30
+ };
31
+ pruneExpired();
32
+ if (params.outTrackId) byKey.set(normalizeKey("outTrack", params.outTrackId), entry);
33
+ if (params.msgId) byKey.set(normalizeKey("msgId", params.msgId), entry);
34
+ if (params.conversationId) {
35
+ const cid = params.conversationId;
36
+ const list = byConversation.get(cid) || [];
37
+ const ref = params.outTrackId || params.msgId || `t${entry.at}`;
38
+ const next = [ref, ...list.filter((x) => x !== ref)].slice(0, 20);
39
+ byConversation.set(cid, next);
40
+ byKey.set(normalizeKey("convRef", `${cid}|${ref}`), entry);
41
+ }
42
+ try {
43
+ console.log(`[DingTalk][CardCache] 已缓存 | outTrack=${params.outTrackId || "-"} conv=${params.conversationId || "-"} len=${text.length}`);
44
+ } catch {}
45
+ }
46
+ /**
47
+ * 从引用载荷里可能出现的字段收集候选 id
48
+ */
49
+ function collectCardLookupIds(repliedMsg, contentObj) {
50
+ const ids = [];
51
+ const push = (v) => {
52
+ if (typeof v === "string" && v.trim()) ids.push(v.trim());
53
+ };
54
+ push(contentObj?.outTrackId);
55
+ push(contentObj?.outTrackID);
56
+ push(contentObj?.cardInstanceId);
57
+ push(contentObj?.cardInstanceID);
58
+ push(contentObj?.trackId);
59
+ push(contentObj?.bizId);
60
+ push(contentObj?.cardBizId);
61
+ push(contentObj?.instanceId);
62
+ push(repliedMsg?.outTrackId);
63
+ push(repliedMsg?.cardInstanceId);
64
+ push(repliedMsg?.msgId);
65
+ push(repliedMsg?.msgID);
66
+ push(repliedMsg?.messageId);
67
+ const nests = [
68
+ contentObj?.cardData,
69
+ contentObj?.data,
70
+ contentObj?.bizData
71
+ ];
72
+ for (const n of nests) {
73
+ if (!n || typeof n !== "object") continue;
74
+ push(n.outTrackId);
75
+ push(n.cardInstanceId);
76
+ push(n.trackId);
77
+ }
78
+ return [...new Set(ids)];
79
+ }
80
+ /**
81
+ * 查找缓存正文
82
+ */
83
+ function lookupCardContent(params) {
84
+ pruneExpired();
85
+ const ids = params.ids || [];
86
+ for (const id of ids) {
87
+ const a = byKey.get(normalizeKey("outTrack", id));
88
+ if (a?.text) return a.text;
89
+ const b = byKey.get(normalizeKey("msgId", id));
90
+ if (b?.text) return b.text;
91
+ }
92
+ if (params.allowConversationRecent !== false && params.conversationId) {
93
+ const list = byConversation.get(params.conversationId) || [];
94
+ for (const ref of list) {
95
+ const e = byKey.get(normalizeKey("outTrack", ref)) || byKey.get(normalizeKey("msgId", ref)) || byKey.get(normalizeKey("convRef", `${params.conversationId}|${ref}`));
96
+ if (e?.text && e.text !== "✅ 思考完成") return e.text;
97
+ }
98
+ for (const ref of list) {
99
+ const e = byKey.get(normalizeKey("outTrack", ref)) || byKey.get(normalizeKey("msgId", ref)) || byKey.get(normalizeKey("convRef", `${params.conversationId}|${ref}`));
100
+ if (e?.text) return e.text;
101
+ }
102
+ }
103
+ return null;
104
+ }
105
+ //#endregion
106
+ export { lookupCardContent as n, rememberCardContent as r, collectCardLookupIds as t };
@@ -0,0 +1,2 @@
1
+ import { r as rememberCardContent } from "./card-content-cache-B3aDF41h.mjs";
2
+ export { rememberCardContent };
@@ -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-CxMLTfaY.mjs");
26
+ const { registerGatewayMethods } = await import("./gateway-methods-b4qQpdOB.mjs");
27
27
  registerGatewayMethods(api);
28
28
  }
29
29
  });
@@ -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-Dl8eoTP3.mjs";
4
+ import { c as prepareMultiBotMentions, d as finishAICard, i as sendProactive, s as buildBotMentionTable } from "./messaging-CpOPEOI8.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 {
@@ -0,0 +1,2 @@
1
+ import { t as registerGatewayMethods } from "./gateway-methods-CCh-LutW.mjs";
2
+ export { registerGatewayMethods };
package/dist/index.mjs CHANGED
@@ -1,5 +1,5 @@
1
- import { a as initDingtalkPluginConfigSchema, i as dingtalkPlugin, n as setDingtalkRuntime } from "./runtime-CHszjL7M.mjs";
2
- import { t as registerGatewayMethods } from "./gateway-methods-DiWw4D-z.mjs";
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";
3
3
  //#region index.ts
4
4
  /**
5
5
  * 检测同一 plugin id 在多个路径被加载的情况。
@@ -0,0 +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";
2
+ export { hasResidualLocalMdImagesOutsideCode, processLocalImages, processRawMediaPaths, processVideoMarkers, sendFileProactive, toLocalPath };
@@ -30,13 +30,20 @@ const IMAGE_EXTENSIONS = /\.(png|jpg|jpeg|gif|bmp|webp|tiff|svg)$/i;
30
30
  * 注意:旧版只匹配 /tmp|/home|/root 等前缀,**漏掉 /mnt 共享盘**,导致 message 工具 MD 灰图。
31
31
  */
32
32
  const LOCAL_IMAGE_RE = /!\[([^\]]*)\]\(([^)]+)\)/g;
33
- /** 纯文本里的绝对本地图片路径(含 /mnt 共享盘) */
34
- const BARE_IMAGE_PATH_RE = /`?((?:\/(?:tmp|var|private|Users|home|root|mnt|opt|data|Volumes)\/[^\s`'",)]+|[A-Za-z]:[\\/][^\s`'",)]+)\.(?:png|jpg|jpeg|gif|bmp|webp|tiff|svg))`?/gi;
33
+ /** 是否像远程 URL 或其截断片段(禁止当本地路径上传) */
34
+ function looksLikeRemoteUrl(raw) {
35
+ const p = (raw || "").trim();
36
+ if (!p) return false;
37
+ if (/^https?:\/\//i.test(p)) return true;
38
+ if (/^[a-z]:\/\//i.test(p) && !/^[a-z]:[\\/][^/\\]/i.test(p)) return true;
39
+ if (p.includes("://")) return true;
40
+ return false;
41
+ }
35
42
  /** 判断 markdown 图片 target 是否为需要上传的本地路径(非 http(s)/mediaId) */
36
43
  function isLocalImageRef(rawPath) {
37
44
  const p = (rawPath || "").trim();
38
45
  if (!p) return false;
39
- if (/^https?:\/\//i.test(p)) return false;
46
+ if (looksLikeRemoteUrl(p) || /^https?:\/\//i.test(p)) return false;
40
47
  if (p.startsWith("@") && !p.includes("/") && !p.includes("\\")) return false;
41
48
  if (p.startsWith("file://") || p.startsWith("MEDIA:") || p.startsWith("attachment://")) return true;
42
49
  if (p.startsWith("/") || /^[A-Za-z]:[\\/]/.test(p)) {
@@ -61,15 +68,34 @@ function toLocalPath(raw) {
61
68
  return filePath;
62
69
  }
63
70
  /**
64
- * 本地图诊断日志:始终打到 stdout(不依赖 config.debug)。
65
- * 同时尽量走 log.warn,便于 gateway 文件日志收集。
71
+ * 本地图诊断:默认只 console 一行,不再 log.warn(避免 [sendTextToDingTalk] 双前缀刷屏)。
72
+ * verbose 步骤(文件可读/realpath 等)仅 debug 时通过 log 输出。
66
73
  */
67
- function logLocalImage(log, step, detail) {
74
+ function logLocalImage(log, step, detail, verbose = false) {
68
75
  const line = detail ? `[DingTalk][LocalImage] ${step} | ${detail}` : `[DingTalk][LocalImage] ${step}`;
76
+ if (verbose) {
77
+ try {
78
+ log?.debug?.(line);
79
+ } catch {}
80
+ return;
81
+ }
69
82
  console.log(line);
70
- try {
71
- log?.warn?.(line);
72
- } catch {}
83
+ }
84
+ /** 钉钉 mediaId 形态 */
85
+ const MEDIA_ID_RE = /@[A-Za-z0-9_-]{8,}/g;
86
+ /**
87
+ * 精简 MediaId 追踪:默认只在关键节点 / 异常时打一行。
88
+ * force=true 或发现「裸 mediaId」时才输出。
89
+ */
90
+ function logMediaIdTrace(stage, content, extra, force = false) {
91
+ const text = typeof content === "string" ? content : "";
92
+ 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()));
94
+ const bareIds = ids.filter((id) => !inMd.has(id));
95
+ 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}`);
73
99
  }
74
100
  /** Markdown 代码区域(围栏 ``` 与行内 `...`),其中的路径不应上传 */
75
101
  function getMarkdownCodeRanges(content) {
@@ -98,21 +124,21 @@ async function uploadMediaToDingTalk(filePath, mediaType, oapiToken, maxSize = 2
98
124
  const tag = `uploadMedia type=${mediaType}`;
99
125
  try {
100
126
  let absPath = toLocalPath(filePath);
101
- logLocalImage(log, `${tag} 开始`, `path=${absPath}`);
127
+ logLocalImage(log, `${tag} 开始`, `path=${absPath}`, true);
102
128
  if (!fs.existsSync(absPath)) {
103
129
  logLocalImage(log, `${tag} 失败:文件不存在`, absPath);
104
130
  return null;
105
131
  }
106
132
  try {
107
133
  const real = fs.realpathSync(absPath);
108
- if (real !== absPath) logLocalImage(log, `${tag} realpath`, `${absPath} → ${real}`);
134
+ if (real !== absPath) logLocalImage(log, `${tag} realpath`, `${absPath} → ${real}`, true);
109
135
  absPath = real;
110
136
  } catch (e) {
111
- logLocalImage(log, `${tag} realpath跳过`, e?.message || String(e));
137
+ logLocalImage(log, `${tag} realpath跳过`, e?.message || String(e), true);
112
138
  }
113
139
  const stats = fs.statSync(absPath);
114
140
  const fileSizeMB = (stats.size / (1024 * 1024)).toFixed(2);
115
- logLocalImage(log, `${tag} 文件可读`, `size=${fileSizeMB}MB mode=${stats.mode}`);
141
+ logLocalImage(log, `${tag} 文件可读`, `size=${fileSizeMB}MB`, true);
116
142
  const effectiveMax = mediaType === "image" ? Math.min(maxSize, 10 * 1024 * 1024) : mediaType === "voice" ? Math.min(maxSize, 2 * 1024 * 1024) : maxSize;
117
143
  if (stats.size > effectiveMax) {
118
144
  const maxSizeMB = (effectiveMax / (1024 * 1024)).toFixed(0);
@@ -171,26 +197,17 @@ async function uploadMediaToDingTalk(filePath, mediaType, oapiToken, maxSize = 2
171
197
  */
172
198
  async function uploadLocalImageWithFallback(filePath, oapiToken, log) {
173
199
  const absPath = toLocalPath(filePath);
174
- logLocalImage(log, "fallback 入口", `path=${absPath} exists=${fs.existsSync(absPath)}`);
175
200
  if (!fs.existsSync(absPath)) {
176
201
  logLocalImage(log, "fallback 失败:文件不存在", absPath);
177
202
  return null;
178
203
  }
179
- logLocalImage(log, "fallback 第1次:直接上传", absPath);
180
204
  let result = await uploadMediaToDingTalk(absPath, "image", oapiToken, 10 * 1024 * 1024, log);
181
- if (result?.mediaId) {
182
- logLocalImage(log, "fallback 第1次成功", result.mediaId);
183
- return result;
184
- }
205
+ if (result?.mediaId) return result;
185
206
  const ext = path.extname(absPath) || ".jpg";
186
207
  const tmp = path.join(os.tmpdir(), `dingtalk-img-${Date.now()}-${Math.random().toString(36).slice(2, 8)}${ext.toLowerCase()}`);
187
208
  try {
188
209
  fs.copyFileSync(absPath, tmp);
189
- const tmpSize = fs.statSync(tmp).size;
190
- logLocalImage(log, "fallback 第2次:tmp拷贝后上传", `${absPath} → ${tmp} size=${tmpSize}`);
191
210
  result = await uploadMediaToDingTalk(tmp, "image", oapiToken, 10 * 1024 * 1024, log);
192
- if (result?.mediaId) logLocalImage(log, "fallback 第2次成功", result.mediaId);
193
- else logLocalImage(log, "fallback 第2次仍失败", tmp);
194
211
  return result;
195
212
  } catch (err) {
196
213
  logLocalImage(log, "fallback 拷贝/重试异常", `${absPath} err=${err?.message || err}`);
@@ -201,99 +218,144 @@ async function uploadLocalImageWithFallback(filePath, oapiToken, log) {
201
218
  } catch {}
202
219
  }
203
220
  }
221
+ async function downloadRemoteUrlToTemp(url) {
222
+ try {
223
+ const res = await fetch(url, {
224
+ redirect: "follow",
225
+ signal: AbortSignal.timeout(6e4),
226
+ headers: { "User-Agent": "dingtalk-openclaw-connector/0.8.21" }
227
+ });
228
+ if (!res.ok) {
229
+ console.warn(`[DingTalk][MediaIdTrace] 远程图下载失败 HTTP ${res.status} | ${url}`);
230
+ return null;
231
+ }
232
+ const buf = Buffer.from(await res.arrayBuffer());
233
+ if (!buf.length || buf.length > 20 * 1024 * 1024) return null;
234
+ const ct = (res.headers.get("content-type") || "").toLowerCase();
235
+ let ext = ".bin";
236
+ if (ct.includes("png")) ext = ".png";
237
+ else if (ct.includes("jpeg") || ct.includes("jpg")) ext = ".jpg";
238
+ else if (ct.includes("gif")) ext = ".gif";
239
+ else if (ct.includes("webp")) ext = ".webp";
240
+ else {
241
+ const m = (url.split("?")[0] || "").match(/\.(png|jpe?g|gif|webp)$/i);
242
+ if (m) ext = `.${m[1].toLowerCase().replace("jpeg", "jpg")}`;
243
+ else ext = ".jpg";
244
+ }
245
+ const tmp = path.join(os.tmpdir(), `dingtalk-md-remote-${Date.now()}-${Math.random().toString(36).slice(2, 8)}${ext}`);
246
+ fs.writeFileSync(tmp, buf);
247
+ console.log(`[DingTalk][MediaIdTrace] 远程图下载成功 | bytes=${buf.length} tmp=${tmp}`);
248
+ return tmp;
249
+ } catch (e) {
250
+ console.warn(`[DingTalk][MediaIdTrace] 远程图下载异常 | ${e?.message || e}`);
251
+ return null;
252
+ }
253
+ }
204
254
  /**
205
- * 扫描内容中的本地图片路径,上传到钉钉并替换为 media_id(Markdown 可渲染)
206
- *
207
- * - 匹配所有 ![](),本地判定 isLocalImageRef(含 /mnt、中文、& 文件名)
208
- * - 上传失败会 /tmp 拷贝重试;仍失败则去掉本地路径(避免钉钉灰图占位)
209
- * - 关键步骤始终 console.log(不依赖 debug 开关)
255
+ * 把 original 在「非 ![] / 非 ]( 」位置替换为 replacement(同一气泡内保留下载位)。
210
256
  */
211
- async function processLocalImages(content, oapiToken, log) {
212
- logLocalImage(log, "processLocalImages 开始", `contentLen=${content?.length ?? 0} hasToken=${!!oapiToken}`);
213
- if (!oapiToken) {
214
- logLocalImage(log, "processLocalImages 跳过", "无 oapiToken");
215
- return content;
216
- }
217
- if (!content || typeof content !== "string") {
218
- logLocalImage(log, "processLocalImages 跳过", "content 为空");
219
- return content;
257
+ function replaceOutsideImageTargets(content, original, replacement) {
258
+ if (!content.includes(original) || original === replacement) return content;
259
+ let out = content;
260
+ let from = 0;
261
+ while (from < out.length) {
262
+ const idx = out.indexOf(original, from);
263
+ if (idx < 0) break;
264
+ if (out.slice(Math.max(0, idx - 2), idx) === "](") {
265
+ from = idx + original.length;
266
+ continue;
267
+ }
268
+ out = out.slice(0, idx) + replacement + out.slice(idx + original.length);
269
+ from = idx + replacement.length;
220
270
  }
271
+ return out;
272
+ }
273
+ /**
274
+ * 出站图片处理(单气泡):
275
+ * - 只处理 `![](target)`:本地/远程均上传 → `![](mediaId)`
276
+ * - 代码块跳过;不扫裸路径
277
+ * - 远程:![] 改 mediaId 后,正文里「下载链接:http://…」**原样保留在同一条消息**
278
+ * (钉钉改写发生在「![] 与正文共用同一 http」时;![] 改成 mediaId 后即可同泡共存)
279
+ * - 本地:正文重复的本地路径换成 down.dingtalk.com 可点链接(仍同一条消息)
280
+ * - **不再拆第二条 text 消息**
281
+ */
282
+ async function processImagesForOutbound(content, oapiToken, log) {
283
+ if (!oapiToken || !content || typeof content !== "string") return {
284
+ text: content || "",
285
+ followUpUrls: []
286
+ };
221
287
  const codeRanges = getMarkdownCodeRanges(content);
222
- if (codeRanges.length > 0) logLocalImage(log, "processLocalImages 代码区域", `count=${codeRanges.length}(围栏/行内 code 内的图路径将跳过)`);
223
- const allMdPreview = [...content.matchAll(LOCAL_IMAGE_RE)].map((m, i) => {
224
- const p = (m[2] || "").trim();
225
- const inCode = m.index !== void 0 && isIndexInCodeRange(m.index, codeRanges);
226
- return `#${i + 1} local=${isLocalImageRef(p)} inCode=${inCode} path=${p.slice(0, 120)}`;
227
- });
228
- logLocalImage(log, "processLocalImages 扫描 ![]()", allMdPreview.length ? allMdPreview.join(" || ") : "正文中没有任何 markdown 图片语法");
229
- let result = content;
230
288
  const mdMatches = [...content.matchAll(LOCAL_IMAGE_RE)];
231
- const localMd = mdMatches.filter((m) => {
232
- if (!isLocalImageRef(m[2] || "")) return false;
233
- if (m.index !== void 0 && isIndexInCodeRange(m.index, codeRanges)) {
234
- logLocalImage(log, "processLocalImages 跳过代码块内图片", (m[2] || "").slice(0, 120));
235
- return false;
236
- }
237
- return true;
238
- });
239
- if (localMd.length > 0) {
240
- logLocalImage(log, "processLocalImages 待上传本地图", `count=${localMd.length}/${mdMatches.length}`);
241
- for (const match of localMd) {
242
- const [fullMatch, alt, rawPath] = match;
243
- const absPath = toLocalPath(rawPath.replace(/\\ /g, " ").trim());
244
- logLocalImage(log, "processLocalImages 处理一张", `alt=${alt || "-"} raw=${rawPath} abs=${absPath} exists=${fs.existsSync(absPath)}`);
289
+ let result = content;
290
+ /** 本地路径 → 可点 downloadUrl(仅替换正文中的裸路径,不拆消息) */
291
+ const localPathToDl = /* @__PURE__ */ new Map();
292
+ let uploadCount = 0;
293
+ for (const match of mdMatches) {
294
+ if (match.index !== void 0 && isIndexInCodeRange(match.index, codeRanges)) continue;
295
+ const [fullMatch, alt, rawPath] = match;
296
+ const cleanPath = (rawPath || "").replace(/\\ /g, " ").trim();
297
+ if (!cleanPath) continue;
298
+ if (isLocalImageRef(cleanPath)) {
299
+ const absPath = toLocalPath(cleanPath);
245
300
  try {
246
301
  const uploadResult = await uploadLocalImageWithFallback(absPath, oapiToken, log);
247
302
  if (uploadResult?.mediaId) {
248
- const replacement = `![${alt || path.basename(absPath)}](${uploadResult.mediaId})`;
249
- result = result.split(fullMatch).join(replacement);
250
- logLocalImage(log, "processLocalImages 替换成功", `${absPath} → ${uploadResult.mediaId}`);
251
- } else {
252
- const note = `📷 *图片上传失败(${path.basename(absPath)})*`;
253
- result = result.split(fullMatch).join(note);
254
- logLocalImage(log, "processLocalImages 替换为失败提示", absPath);
255
- }
256
- } catch (err) {
257
- const note = `📷 *图片上传异常(${path.basename(absPath)})*`;
258
- result = result.split(fullMatch).join(note);
259
- logLocalImage(log, "processLocalImages 异常", `${absPath} err=${err?.message || err}`);
303
+ result = result.split(fullMatch).join(`![${alt || path.basename(absPath)}](${uploadResult.mediaId})`);
304
+ uploadCount++;
305
+ if (uploadResult.downloadUrl) {
306
+ localPathToDl.set(cleanPath, uploadResult.downloadUrl);
307
+ localPathToDl.set(absPath, uploadResult.downloadUrl);
308
+ }
309
+ } else result = result.split(fullMatch).join(`📷 *图片上传失败(${path.basename(absPath)})*`);
310
+ } catch {
311
+ result = result.split(fullMatch).join(`📷 *图片上传异常(${path.basename(toLocalPath(cleanPath))})*`);
260
312
  }
313
+ continue;
261
314
  }
262
- } else if (mdMatches.length > 0) logLocalImage(log, "processLocalImages 无本地图可传", `全部 ${mdMatches.length} 个视为远程/非本地`);
263
- else logLocalImage(log, "processLocalImages 无 markdown 图片", "无需上传");
264
- const bareOutsideCode = [...result.matchAll(BARE_IMAGE_PATH_RE)].filter((m) => {
265
- if (m.index === void 0) return false;
266
- const idx = m.index;
267
- return !result.slice(Math.max(0, idx - 10), idx).includes("](");
268
- }).filter((m) => {
269
- if (m.index === void 0) return true;
270
- if (isIndexInCodeRange(m.index, codeRanges)) {
271
- logLocalImage(log, "processLocalImages 跳过代码块内纯路径", (m[1] || "").slice(0, 80));
272
- return false;
273
- }
274
- return true;
275
- });
276
- if (bareOutsideCode.length > 0) {
277
- logLocalImage(log, "processLocalImages 纯文本路径", `count=${bareOutsideCode.length}`);
278
- for (const match of bareOutsideCode.reverse()) {
279
- const [fullMatch, rawPath] = match;
280
- const absPath = toLocalPath(rawPath.replace(/\\ /g, " ").trim());
281
- logLocalImage(log, "processLocalImages 纯文本上传", absPath);
315
+ if (looksLikeRemoteUrl(cleanPath)) {
316
+ let tmp = null;
282
317
  try {
283
- const uploadResult = await uploadLocalImageWithFallback(absPath, oapiToken, log);
318
+ tmp = await downloadRemoteUrlToTemp(cleanPath);
319
+ if (!tmp) continue;
320
+ const uploadResult = await uploadLocalImageWithFallback(tmp, oapiToken, log);
284
321
  if (uploadResult?.mediaId) {
285
- const replacement = `![](${uploadResult.mediaId})`;
286
- result = result.slice(0, match.index) + result.slice(match.index).replace(fullMatch, replacement);
287
- logLocalImage(log, "processLocalImages 纯文本替换成功", uploadResult.mediaId);
322
+ result = result.split(fullMatch).join(`![${alt || "image"}](${uploadResult.mediaId})`);
323
+ uploadCount++;
324
+ console.log(`[DingTalk][LocalImage] ![]远程→mediaId 下载链保留原URL | ${uploadResult.mediaId}`);
288
325
  }
289
326
  } catch (err) {
290
- logLocalImage(log, "processLocalImages 纯文本失败", `${absPath} ${err?.message || err}`);
327
+ logLocalImage(log, "远程图处理失败", err?.message || String(err));
328
+ } finally {
329
+ if (tmp) try {
330
+ fs.unlinkSync(tmp);
331
+ } catch {}
291
332
  }
292
333
  }
293
334
  }
294
- const stillLocal = [...result.matchAll(LOCAL_IMAGE_RE)].filter((m) => isLocalImageRef(m[2] || "")).map((m) => m[2]);
295
- logLocalImage(log, "processLocalImages 结束", stillLocal.length ? `仍残留本地路径 ${stillLocal.length} 个: ${stillLocal.join(" | ")}` : `无残留本地路径 outLen=${result.length}`);
296
- return result;
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);
338
+ return {
339
+ text: result,
340
+ followUpUrls: []
341
+ };
342
+ }
343
+ /** 兼容旧调用 */
344
+ async function processLocalImages(content, oapiToken, log) {
345
+ return (await processImagesForOutbound(content, oapiToken, log)).text;
346
+ }
347
+ /**
348
+ * 是否仍有「需要上传却未上传」的本地 MD 图。
349
+ * **代码块 / 行内 code 内的示例路径不算**(否则 AI 举例 `![x](/tmp/a.jpg)` 会误报灰图)。
350
+ */
351
+ function hasResidualLocalMdImagesOutsideCode(content) {
352
+ if (!content) return false;
353
+ const codeRanges = getMarkdownCodeRanges(content);
354
+ for (const m of content.matchAll(LOCAL_IMAGE_RE)) {
355
+ if (m.index !== void 0 && isIndexInCodeRange(m.index, codeRanges)) continue;
356
+ if (isLocalImageRef((m[2] || "").trim())) return true;
357
+ }
358
+ return false;
297
359
  }
298
360
  /**
299
361
  * 提取视频元数据(时长、分辨率)
@@ -712,4 +774,4 @@ async function processRawMediaPaths(content, config, oapiToken, log, target) {
712
774
  return processedContent;
713
775
  }
714
776
  //#endregion
715
- export { __exportAll as _, extractVideoMetadata as a, processLocalImages as c, sendAudioProactive as d, sendFileProactive as f, uploadMediaToDingTalk as g, uploadLocalImageWithFallback as h, VIDEO_MARKER_PATTERN as i, processRawMediaPaths as l, toLocalPath as m, IMAGE_EXTENSIONS as n, extractVideoThumbnail as o, sendVideoProactive as p, LOCAL_IMAGE_RE as r, isLocalImageRef as s, BARE_IMAGE_PATH_RE as t, processVideoMarkers as u };
777
+ export { toLocalPath as _, extractVideoThumbnail as a, __exportAll as b, logMediaIdTrace as c, processLocalImages as d, processRawMediaPaths as f, sendVideoProactive as g, sendFileProactive as h, extractVideoMetadata as i, looksLikeRemoteUrl as l, sendAudioProactive as m, LOCAL_IMAGE_RE as n, hasResidualLocalMdImagesOutsideCode as o, processVideoMarkers as p, VIDEO_MARKER_PATTERN as r, isLocalImageRef as s, IMAGE_EXTENSIONS as t, processImagesForOutbound as u, uploadLocalImageWithFallback as v, uploadMediaToDingTalk as y };