@wecom/wecom-openclaw-plugin 1.0.9 → 1.0.10

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/dist/index.cjs.js CHANGED
@@ -3,15 +3,40 @@
3
3
  Object.defineProperty(exports, '__esModule', { value: true });
4
4
 
5
5
  var pluginSdk = require('openclaw/plugin-sdk');
6
+ var os$1 = require('os');
7
+ var path$1 = require('path');
6
8
  var aibotNodeSdk = require('@wecom/aibot-node-sdk');
7
- var path = require('path');
9
+ var fs = require('node:fs/promises');
10
+ var path = require('node:path');
11
+ var os = require('node:os');
12
+ var node_url = require('node:url');
8
13
  var url = require('url');
9
14
  var fileType = require('file-type');
10
- var os = require('node:os');
11
- var path$1 = require('node:path');
12
- var os$1 = require('os');
13
15
 
14
16
  var _documentCurrentScript = typeof document !== 'undefined' ? document.currentScript : null;
17
+ function _interopNamespaceDefault(e) {
18
+ var n = Object.create(null);
19
+ if (e) {
20
+ Object.keys(e).forEach(function (k) {
21
+ if (k !== 'default') {
22
+ var d = Object.getOwnPropertyDescriptor(e, k);
23
+ Object.defineProperty(n, k, d.get ? d : {
24
+ enumerable: true,
25
+ get: function () { return e[k]; }
26
+ });
27
+ }
28
+ });
29
+ }
30
+ n.default = e;
31
+ return Object.freeze(n);
32
+ }
33
+
34
+ var os__namespace$1 = /*#__PURE__*/_interopNamespaceDefault(os$1);
35
+ var path__namespace$1 = /*#__PURE__*/_interopNamespaceDefault(path$1);
36
+ var fs__namespace = /*#__PURE__*/_interopNamespaceDefault(fs);
37
+ var path__namespace = /*#__PURE__*/_interopNamespaceDefault(path);
38
+ var os__namespace = /*#__PURE__*/_interopNamespaceDefault(os);
39
+
15
40
  let runtime = null;
16
41
  function setWeComRuntime(r) {
17
42
  runtime = r;
@@ -23,6 +48,310 @@ function getWeComRuntime() {
23
48
  return runtime;
24
49
  }
25
50
 
51
+ /**
52
+ * openclaw plugin-sdk 高版本方法兼容层
53
+ *
54
+ * 部分方法(如 loadOutboundMediaFromUrl、detectMime、getDefaultMediaLocalRoots)
55
+ * 仅在较新版本的 openclaw plugin-sdk 中才导出。
56
+ *
57
+ * 本模块在加载时一次性探测 SDK 导出,存在则直接 re-export SDK 版本,
58
+ * 不存在则导出 fallback 实现。其他模块统一从本文件导入,无需关心底层兼容细节。
59
+ */
60
+ const _sdkReady = import('openclaw/plugin-sdk')
61
+ .then((sdk) => {
62
+ const exports$1 = {};
63
+ if (typeof sdk.loadOutboundMediaFromUrl === "function") {
64
+ exports$1.loadOutboundMediaFromUrl = sdk.loadOutboundMediaFromUrl;
65
+ }
66
+ if (typeof sdk.detectMime === "function") {
67
+ exports$1.detectMime = sdk.detectMime;
68
+ }
69
+ if (typeof sdk.getDefaultMediaLocalRoots === "function") {
70
+ exports$1.getDefaultMediaLocalRoots = sdk.getDefaultMediaLocalRoots;
71
+ }
72
+ return exports$1;
73
+ })
74
+ .catch(() => {
75
+ // openclaw/plugin-sdk 不可用或版本过低,全部使用 fallback
76
+ return {};
77
+ });
78
+ // ============================================================================
79
+ // detectMime —— 检测 MIME 类型
80
+ // ============================================================================
81
+ const MIME_BY_EXT = {
82
+ ".heic": "image/heic",
83
+ ".heif": "image/heif",
84
+ ".jpg": "image/jpeg",
85
+ ".jpeg": "image/jpeg",
86
+ ".png": "image/png",
87
+ ".webp": "image/webp",
88
+ ".gif": "image/gif",
89
+ ".ogg": "audio/ogg",
90
+ ".mp3": "audio/mpeg",
91
+ ".m4a": "audio/x-m4a",
92
+ ".mp4": "video/mp4",
93
+ ".mov": "video/quicktime",
94
+ ".pdf": "application/pdf",
95
+ ".json": "application/json",
96
+ ".zip": "application/zip",
97
+ ".gz": "application/gzip",
98
+ ".tar": "application/x-tar",
99
+ ".7z": "application/x-7z-compressed",
100
+ ".rar": "application/vnd.rar",
101
+ ".doc": "application/msword",
102
+ ".xls": "application/vnd.ms-excel",
103
+ ".ppt": "application/vnd.ms-powerpoint",
104
+ ".docx": "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
105
+ ".xlsx": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
106
+ ".pptx": "application/vnd.openxmlformats-officedocument.presentationml.presentation",
107
+ ".csv": "text/csv",
108
+ ".txt": "text/plain",
109
+ ".md": "text/markdown",
110
+ ".amr": "audio/amr",
111
+ ".aac": "audio/aac",
112
+ ".wav": "audio/wav",
113
+ ".webm": "video/webm",
114
+ ".avi": "video/x-msvideo",
115
+ ".bmp": "image/bmp",
116
+ ".svg": "image/svg+xml",
117
+ };
118
+ /** 通过 buffer 魔术字节嗅探 MIME 类型(动态导入 file-type,不强依赖) */
119
+ async function sniffMimeFromBuffer(buffer) {
120
+ try {
121
+ const { fileTypeFromBuffer } = await import('file-type');
122
+ const type = await fileTypeFromBuffer(buffer);
123
+ return type?.mime ?? undefined;
124
+ }
125
+ catch {
126
+ return undefined;
127
+ }
128
+ }
129
+ /** fallback 版 detectMime,参考 weclaw/src/media/mime.ts */
130
+ async function detectMimeFallback(opts) {
131
+ const ext = opts.filePath ? path__namespace.extname(opts.filePath).toLowerCase() : undefined;
132
+ const extMime = ext ? MIME_BY_EXT[ext] : undefined;
133
+ const sniffed = opts.buffer ? await sniffMimeFromBuffer(opts.buffer) : undefined;
134
+ const isGeneric = (m) => !m || m === "application/octet-stream" || m === "application/zip";
135
+ if (sniffed && (!isGeneric(sniffed) || !extMime)) {
136
+ return sniffed;
137
+ }
138
+ if (extMime) {
139
+ return extMime;
140
+ }
141
+ const headerMime = opts.headerMime?.split(";")?.[0]?.trim().toLowerCase();
142
+ if (headerMime && !isGeneric(headerMime)) {
143
+ return headerMime;
144
+ }
145
+ if (sniffed) {
146
+ return sniffed;
147
+ }
148
+ if (headerMime) {
149
+ return headerMime;
150
+ }
151
+ return undefined;
152
+ }
153
+ /**
154
+ * 检测 MIME 类型(兼容入口)
155
+ *
156
+ * 支持两种调用签名以兼容不同使用场景:
157
+ * - detectMime(buffer) → 旧式调用
158
+ * - detectMime({ buffer, headerMime, filePath }) → 完整参数
159
+ *
160
+ * 优先使用 SDK 版本,不可用时使用 fallback。
161
+ */
162
+ async function detectMime(bufferOrOpts) {
163
+ const sdk = await _sdkReady;
164
+ const opts = Buffer.isBuffer(bufferOrOpts)
165
+ ? { buffer: bufferOrOpts }
166
+ : bufferOrOpts;
167
+ if (sdk.detectMime) {
168
+ try {
169
+ return await sdk.detectMime(opts);
170
+ }
171
+ catch {
172
+ // SDK detectMime 异常,降级到 fallback
173
+ }
174
+ }
175
+ return detectMimeFallback(opts);
176
+ }
177
+ // ============================================================================
178
+ // loadOutboundMediaFromUrl —— 从 URL/路径加载媒体文件
179
+ // ============================================================================
180
+ /** 安全的本地文件路径校验,参考 weclaw/src/web/media.ts */
181
+ async function assertLocalMediaAllowed(mediaPath, localRoots) {
182
+ if (!localRoots || localRoots.length === 0) {
183
+ throw new Error(`Local media path is not under an allowed directory: ${mediaPath}`);
184
+ }
185
+ let resolved;
186
+ try {
187
+ resolved = await fs__namespace.realpath(mediaPath);
188
+ }
189
+ catch {
190
+ resolved = path__namespace.resolve(mediaPath);
191
+ }
192
+ for (const root of localRoots) {
193
+ let resolvedRoot;
194
+ try {
195
+ resolvedRoot = await fs__namespace.realpath(root);
196
+ }
197
+ catch {
198
+ resolvedRoot = path__namespace.resolve(root);
199
+ }
200
+ if (resolvedRoot === path__namespace.parse(resolvedRoot).root) {
201
+ continue;
202
+ }
203
+ if (resolved === resolvedRoot || resolved.startsWith(resolvedRoot + path__namespace.sep)) {
204
+ return;
205
+ }
206
+ }
207
+ throw new Error(`Local media path is not under an allowed directory: ${mediaPath}`);
208
+ }
209
+ /** 从远程 URL 获取媒体 */
210
+ async function fetchRemoteMedia(url, maxBytes) {
211
+ const res = await fetch(url, { redirect: "follow" });
212
+ if (!res.ok) {
213
+ throw new Error(`Failed to fetch media from ${url}: HTTP ${res.status} ${res.statusText}`);
214
+ }
215
+ const buffer = Buffer.from(await res.arrayBuffer());
216
+ if (maxBytes && buffer.length > maxBytes) {
217
+ throw new Error(`Media from ${url} exceeds max size (${buffer.length} > ${maxBytes})`);
218
+ }
219
+ const headerMime = res.headers.get("content-type")?.split(";")?.[0]?.trim();
220
+ let fileName;
221
+ const disposition = res.headers.get("content-disposition");
222
+ if (disposition) {
223
+ const match = /filename\*?\s*=\s*(?:UTF-8''|")?([^";]+)/i.exec(disposition);
224
+ if (match?.[1]) {
225
+ try {
226
+ fileName = path__namespace.basename(decodeURIComponent(match[1].replace(/["']/g, "").trim()));
227
+ }
228
+ catch {
229
+ fileName = path__namespace.basename(match[1].replace(/["']/g, "").trim());
230
+ }
231
+ }
232
+ }
233
+ if (!fileName) {
234
+ try {
235
+ const parsed = new URL(url);
236
+ const base = path__namespace.basename(parsed.pathname);
237
+ if (base && base.includes("."))
238
+ fileName = base;
239
+ }
240
+ catch { /* ignore */ }
241
+ }
242
+ const contentType = await detectMimeFallback({ buffer, headerMime, filePath: fileName ?? url });
243
+ return { buffer, contentType, fileName };
244
+ }
245
+ /** 展开 ~ 为用户主目录 */
246
+ function resolveUserPath(p) {
247
+ if (p.startsWith("~")) {
248
+ return path__namespace.join(os__namespace.homedir(), p.slice(1));
249
+ }
250
+ return p;
251
+ }
252
+ /** fallback 版 loadOutboundMediaFromUrl,参考 weclaw/src/web/media.ts */
253
+ async function loadOutboundMediaFromUrlFallback(mediaUrl, options = {}) {
254
+ const { maxBytes, mediaLocalRoots } = options;
255
+ // 去除 MEDIA: 前缀
256
+ mediaUrl = mediaUrl.replace(/^\s*MEDIA\s*:\s*/i, "");
257
+ // 处理 file:// URL
258
+ if (mediaUrl.startsWith("file://")) {
259
+ try {
260
+ mediaUrl = node_url.fileURLToPath(mediaUrl);
261
+ }
262
+ catch {
263
+ throw new Error(`Invalid file:// URL: ${mediaUrl}`);
264
+ }
265
+ }
266
+ // 远程 URL
267
+ if (/^https?:\/\//i.test(mediaUrl)) {
268
+ const fetched = await fetchRemoteMedia(mediaUrl, maxBytes);
269
+ return {
270
+ buffer: fetched.buffer,
271
+ contentType: fetched.contentType,
272
+ fileName: fetched.fileName,
273
+ };
274
+ }
275
+ // 展开 ~ 路径
276
+ if (mediaUrl.startsWith("~")) {
277
+ mediaUrl = resolveUserPath(mediaUrl);
278
+ }
279
+ // 本地文件:安全校验
280
+ await assertLocalMediaAllowed(mediaUrl, mediaLocalRoots);
281
+ // 读取本地文件
282
+ let data;
283
+ try {
284
+ const stat = await fs__namespace.stat(mediaUrl);
285
+ if (!stat.isFile()) {
286
+ throw new Error(`Local media path is not a file: ${mediaUrl}`);
287
+ }
288
+ data = await fs__namespace.readFile(mediaUrl);
289
+ }
290
+ catch (err) {
291
+ if (err?.code === "ENOENT") {
292
+ throw new Error(`Local media file not found: ${mediaUrl}`);
293
+ }
294
+ throw err;
295
+ }
296
+ if (maxBytes && data.length > maxBytes) {
297
+ throw new Error(`Local media exceeds max size (${data.length} > ${maxBytes})`);
298
+ }
299
+ const mime = await detectMimeFallback({ buffer: data, filePath: mediaUrl });
300
+ const fileName = path__namespace.basename(mediaUrl) || undefined;
301
+ return {
302
+ buffer: data,
303
+ contentType: mime,
304
+ fileName,
305
+ };
306
+ }
307
+ /**
308
+ * 从 URL 或本地路径加载媒体文件(兼容入口)
309
+ *
310
+ * 优先使用 SDK 版本,不可用时使用 fallback。
311
+ * SDK 版本抛出的业务异常(如 LocalMediaAccessError)会直接透传。
312
+ */
313
+ async function loadOutboundMediaFromUrl(mediaUrl, options = {}) {
314
+ const sdk = await _sdkReady;
315
+ if (sdk.loadOutboundMediaFromUrl) {
316
+ return sdk.loadOutboundMediaFromUrl(mediaUrl, options);
317
+ }
318
+ return loadOutboundMediaFromUrlFallback(mediaUrl, options);
319
+ }
320
+ // ============================================================================
321
+ // getDefaultMediaLocalRoots —— 获取默认媒体本地路径白名单
322
+ // ============================================================================
323
+ /** 解析 openclaw 状态目录 */
324
+ function resolveStateDir$1() {
325
+ const stateOverride = process.env.OPENCLAW_STATE_DIR?.trim() || process.env.CLAWDBOT_STATE_DIR?.trim();
326
+ if (stateOverride)
327
+ return stateOverride;
328
+ return path__namespace.join(os__namespace.homedir(), ".openclaw");
329
+ }
330
+ /**
331
+ * 获取默认媒体本地路径白名单(兼容入口)
332
+ *
333
+ * 优先使用 SDK 版本,不可用时手动构建白名单(与 weclaw/src/media/local-roots.ts 逻辑一致)。
334
+ */
335
+ async function getDefaultMediaLocalRoots() {
336
+ const sdk = await _sdkReady;
337
+ if (sdk.getDefaultMediaLocalRoots) {
338
+ try {
339
+ return sdk.getDefaultMediaLocalRoots();
340
+ }
341
+ catch {
342
+ // SDK 版本异常,降级到 fallback
343
+ }
344
+ }
345
+ // fallback: 手动构建默认白名单
346
+ const stateDir = path__namespace.resolve(resolveStateDir$1());
347
+ return [
348
+ path__namespace.join(stateDir, "media"),
349
+ path__namespace.join(stateDir, "agents"),
350
+ path__namespace.join(stateDir, "workspace"),
351
+ path__namespace.join(stateDir, "sandboxes"),
352
+ ];
353
+ }
354
+
26
355
  /**
27
356
  * 企业微信渠道常量定义
28
357
  */
@@ -88,9 +417,9 @@ const MCP_GET_CONFIG_CMD = "aibot_get_mcp_config";
88
417
  /** MCP 配置拉取超时时间(毫秒) */
89
418
  const MCP_CONFIG_FETCH_TIMEOUT_MS = 15000;
90
419
  const __filename$1 = url.fileURLToPath((typeof document === 'undefined' ? require('u' + 'rl').pathToFileURL(__filename).href : (_documentCurrentScript && _documentCurrentScript.tagName.toUpperCase() === 'SCRIPT' && _documentCurrentScript.src || new URL('index.cjs.js', document.baseURI).href)));
91
- const __dirname$1 = path.dirname(__filename$1);
420
+ const __dirname$1 = path$1.dirname(__filename$1);
92
421
  const PLUGIN_JSON_FILENAME = "openclaw.plugin.json";
93
- path.resolve(path.join(__dirname$1, "../", PLUGIN_JSON_FILENAME));
422
+ path$1.resolve(path$1.join(__dirname$1, "../", PLUGIN_JSON_FILENAME));
94
423
  // ============================================================================
95
424
  // 默认值
96
425
  // ============================================================================
@@ -98,6 +427,19 @@ path.resolve(path.join(__dirname$1, "../", PLUGIN_JSON_FILENAME));
98
427
  const DEFAULT_MEDIA_MAX_MB = 5;
99
428
  /** 文本分块大小上限 */
100
429
  const TEXT_CHUNK_LIMIT = 4000;
430
+ // ============================================================================
431
+ // 媒体上传相关常量
432
+ // ============================================================================
433
+ /** 图片大小上限(字节):10MB */
434
+ const IMAGE_MAX_BYTES = 10 * 1024 * 1024;
435
+ /** 视频大小上限(字节):10MB */
436
+ const VIDEO_MAX_BYTES = 10 * 1024 * 1024;
437
+ /** 语音大小上限(字节):2MB */
438
+ const VOICE_MAX_BYTES = 2 * 1024 * 1024;
439
+ /** 文件大小上限(字节):20MB */
440
+ const FILE_MAX_BYTES = 20 * 1024 * 1024;
441
+ /** 文件绝对上限(字节):超过此值无法发送,等于 FILE_MAX_BYTES */
442
+ const ABSOLUTE_MAX_BYTES = FILE_MAX_BYTES;
101
443
 
102
444
  /**
103
445
  * 企业微信消息内容解析模块
@@ -239,12 +581,12 @@ async function sendWeComReply(params) {
239
581
  }
240
582
  const streamId = existingStreamId || aibotNodeSdk.generateReqId("stream");
241
583
  if (!wsClient.isConnected) {
242
- runtime.error?.(`[WeCom] WSClient not connected, cannot send reply`);
584
+ runtime.error?.(`[wecom] WSClient not connected, cannot send reply`);
243
585
  throw new Error("WSClient not connected");
244
586
  }
245
587
  // 使用 SDK 的 replyStream 方法发送消息,带超时保护
246
588
  await withTimeout(wsClient.replyStream(frame, streamId, text, finish), REPLY_SEND_TIMEOUT_MS, `Reply send timed out (streamId=${streamId})`);
247
- runtime.log?.(`[WeCom] Sent reply: streamId=${streamId}, finish=${finish}`);
589
+ runtime.log?.(`[plugin -> server] streamId=${streamId}, finish=${finish}, text=${text}`);
248
590
  return streamId;
249
591
  }
250
592
 
@@ -285,7 +627,7 @@ async function downloadAndSaveImages(params) {
285
627
  const mediaList = [];
286
628
  for (const imageUrl of imageUrls) {
287
629
  try {
288
- runtime.log?.(`[WeCom] Downloading image from: ${imageUrl}`);
630
+ runtime.log?.(`[wecom] Downloading image: url=${imageUrl}`);
289
631
  const mediaMaxMb = config.agents?.defaults?.mediaMaxMb ?? DEFAULT_MEDIA_MAX_MB;
290
632
  const maxBytes = mediaMaxMb * 1024 * 1024;
291
633
  let imageBuffer;
@@ -298,26 +640,26 @@ async function downloadAndSaveImages(params) {
298
640
  imageBuffer = result.buffer;
299
641
  originalFilename = result.filename;
300
642
  imageContentType = await detectImageContentType(imageBuffer);
301
- runtime.log?.(`[WeCom] Image downloaded via SDK: size=${imageBuffer.length}, contentType=${imageContentType}${originalFilename ? `, filename=${originalFilename}` : ""}`);
643
+ runtime.log?.(`[wecom] Image downloaded: size=${imageBuffer.length}, contentType=${imageContentType}, filename=${originalFilename ?? '(none)'}`);
302
644
  }
303
645
  catch (sdkError) {
304
646
  // 如果 SDK 方法失败,回退到原有方式(带超时保护)
305
- runtime.log?.(`[WeCom] SDK download failed, falling back to manual download: ${String(sdkError)}`);
647
+ runtime.log?.(`[wecom] SDK download failed, fallback: ${String(sdkError)}`);
306
648
  const fetched = await withTimeout(core.channel.media.fetchRemoteMedia({ url: imageUrl }), IMAGE_DOWNLOAD_TIMEOUT_MS, `Manual image download timed out: ${imageUrl}`);
307
- runtime.log?.(`[WeCom] Image fetched: contentType=${fetched.contentType}, size=${fetched.buffer.length}, first4Bytes=${fetched.buffer.slice(0, 4).toString("hex")}`);
649
+ runtime.log?.(`[wecom] Image fetched: contentType=${fetched.contentType}, size=${fetched.buffer.length}`);
308
650
  imageBuffer = fetched.buffer;
309
651
  imageContentType = fetched.contentType ?? "application/octet-stream";
310
652
  const isValidImage = await isImageBuffer(fetched.buffer);
311
653
  if (!isValidImage) {
312
- runtime.log?.(`[WeCom] WARN: Image does not appear to be a valid image format`);
654
+ runtime.log?.(`[wecom] WARN: Downloaded data is not a valid image format`);
313
655
  }
314
656
  }
315
657
  const saved = await core.channel.media.saveMediaBuffer(imageBuffer, imageContentType, "inbound", maxBytes, originalFilename);
316
658
  mediaList.push({ path: saved.path, contentType: saved.contentType });
317
- runtime.log?.(`[WeCom] Image saved to ${saved.path}, finalContentType=${saved.contentType}`);
659
+ runtime.log?.(`[wecom][plugin] Image saved: path=${saved.path}, contentType=${saved.contentType}`);
318
660
  }
319
661
  catch (err) {
320
- runtime.error?.(`[WeCom] Failed to download image: ${String(err)}`);
662
+ runtime.error?.(`[wecom] Failed to download image: ${String(err)}`);
321
663
  }
322
664
  }
323
665
  return mediaList;
@@ -331,7 +673,7 @@ async function downloadAndSaveFiles(params) {
331
673
  const mediaList = [];
332
674
  for (const fileUrl of fileUrls) {
333
675
  try {
334
- runtime.log?.(`[WeCom] Downloading file from: ${fileUrl}`);
676
+ runtime.log?.(`[wecom] Downloading file: url=${fileUrl}`);
335
677
  const mediaMaxMb = config.agents?.defaults?.mediaMaxMb ?? DEFAULT_MEDIA_MAX_MB;
336
678
  const maxBytes = mediaMaxMb * 1024 * 1024;
337
679
  let fileBuffer;
@@ -346,27 +688,301 @@ async function downloadAndSaveFiles(params) {
346
688
  // 检测文件类型
347
689
  const type = await fileType.fileTypeFromBuffer(fileBuffer);
348
690
  fileContentType = type?.mime ?? "application/octet-stream";
349
- runtime.log?.(`[WeCom] File downloaded via SDK: size=${fileBuffer.length}, contentType=${fileContentType}${originalFilename ? `, filename=${originalFilename}` : ""}`);
691
+ runtime.log?.(`[wecom] File downloaded: size=${fileBuffer.length}, contentType=${fileContentType}, filename=${originalFilename ?? '(none)'}`);
350
692
  }
351
693
  catch (sdkError) {
352
694
  // 如果 SDK 方法失败,回退到 fetchRemoteMedia(带超时保护)
353
- runtime.log?.(`[WeCom] SDK file download failed, falling back to manual download: ${String(sdkError)}`);
695
+ runtime.log?.(`[wecom] SDK file download failed, fallback: ${String(sdkError)}`);
354
696
  const fetched = await withTimeout(core.channel.media.fetchRemoteMedia({ url: fileUrl }), FILE_DOWNLOAD_TIMEOUT_MS, `Manual file download timed out: ${fileUrl}`);
355
- runtime.log?.(`[WeCom] File fetched: contentType=${fetched.contentType}, size=${fetched.buffer.length}`);
697
+ runtime.log?.(`[wecom] File fetched: contentType=${fetched.contentType}, size=${fetched.buffer.length}`);
356
698
  fileBuffer = fetched.buffer;
357
699
  fileContentType = fetched.contentType ?? "application/octet-stream";
358
700
  }
359
701
  const saved = await core.channel.media.saveMediaBuffer(fileBuffer, fileContentType, "inbound", maxBytes, originalFilename);
360
702
  mediaList.push({ path: saved.path, contentType: saved.contentType });
361
- runtime.log?.(`[WeCom] File saved to ${saved.path}, finalContentType=${saved.contentType}`);
703
+ runtime.log?.(`[wecom][plugin] File saved: path=${saved.path}, contentType=${saved.contentType}`);
362
704
  }
363
705
  catch (err) {
364
- runtime.error?.(`[WeCom] Failed to download file: ${String(err)}`);
706
+ runtime.error?.(`[wecom] Failed to download file: ${String(err)}`);
365
707
  }
366
708
  }
367
709
  return mediaList;
368
710
  }
369
711
 
712
+ /**
713
+ * 企业微信出站媒体上传工具模块
714
+ *
715
+ * 负责:
716
+ * - 从 mediaUrl 加载文件 buffer(远程 URL 或本地路径均支持)
717
+ * - 检测 MIME 类型并映射为企微媒体类型
718
+ * - 文件大小检查与降级策略
719
+ */
720
+ // ============================================================================
721
+ // MIME → 企微媒体类型映射
722
+ // ============================================================================
723
+ /**
724
+ * 根据 MIME 类型检测企微媒体类型
725
+ *
726
+ * @param mimeType - MIME 类型字符串
727
+ * @returns 企微媒体类型
728
+ */
729
+ function detectWeComMediaType(mimeType) {
730
+ const mime = mimeType.toLowerCase();
731
+ // 图片类型
732
+ if (mime.startsWith("image/")) {
733
+ return "image";
734
+ }
735
+ // 视频类型
736
+ if (mime.startsWith("video/")) {
737
+ return "video";
738
+ }
739
+ // 语音类型
740
+ if (mime.startsWith("audio/") ||
741
+ mime === "application/ogg" // OGG 音频容器
742
+ ) {
743
+ return "voice";
744
+ }
745
+ // 其他类型默认为文件
746
+ return "file";
747
+ }
748
+ // ============================================================================
749
+ // 媒体文件加载
750
+ // ============================================================================
751
+ /**
752
+ * 从 mediaUrl 加载媒体文件
753
+ *
754
+ * 支持远程 URL(http/https)和本地路径(file:// 或绝对路径),
755
+ * 利用 openclaw plugin-sdk 的 loadOutboundMediaFromUrl 统一处理。
756
+ *
757
+ * @param mediaUrl - 媒体文件的 URL 或本地路径
758
+ * @param mediaLocalRoots - 允许读取本地文件的安全白名单目录
759
+ * @returns 解析后的媒体文件信息
760
+ */
761
+ async function resolveMediaFile(mediaUrl, mediaLocalRoots) {
762
+ // 使用兼容层加载媒体文件(优先 SDK,不可用时 fallback)
763
+ // 传入足够大的 maxBytes,由我们自己在后续步骤做大小检查
764
+ const result = await loadOutboundMediaFromUrl(mediaUrl, {
765
+ maxBytes: ABSOLUTE_MAX_BYTES,
766
+ mediaLocalRoots,
767
+ });
768
+ if (!result.buffer || result.buffer.length === 0) {
769
+ throw new Error(`Failed to load media from ${mediaUrl}: empty buffer`);
770
+ }
771
+ // 检测真实 MIME 类型
772
+ let contentType = result.contentType || "application/octet-stream";
773
+ // 如果没有返回准确的 contentType,尝试通过 buffer 魔术字节检测
774
+ if (contentType === "application/octet-stream" ||
775
+ contentType === "text/plain") {
776
+ const detected = await detectMime(result.buffer);
777
+ if (detected) {
778
+ contentType = detected;
779
+ }
780
+ }
781
+ // 提取文件名
782
+ const fileName = extractFileName(mediaUrl, result.fileName, contentType);
783
+ return {
784
+ buffer: result.buffer,
785
+ contentType,
786
+ fileName,
787
+ };
788
+ }
789
+ // ============================================================================
790
+ // 文件大小检查与降级
791
+ // ============================================================================
792
+ /** 企微语音消息仅支持 AMR 格式 */
793
+ const VOICE_SUPPORTED_MIMES = new Set(["audio/amr"]);
794
+ /**
795
+ * 检查文件大小并执行降级策略
796
+ *
797
+ * 降级规则:
798
+ * - voice 非 AMR 格式 → 降级为 file(企微后台仅支持 AMR)
799
+ * - image 超过 10MB → 降级为 file
800
+ * - video 超过 10MB → 降级为 file
801
+ * - voice 超过 2MB → 降级为 file
802
+ * - file 超过 20MB → 拒绝发送
803
+ *
804
+ * @param fileSize - 文件大小(字节)
805
+ * @param detectedType - 检测到的企微媒体类型
806
+ * @param contentType - 文件的 MIME 类型(用于语音格式校验)
807
+ * @returns 大小检查结果
808
+ */
809
+ function applyFileSizeLimits(fileSize, detectedType, contentType) {
810
+ const fileSizeMB = (fileSize / (1024 * 1024)).toFixed(2);
811
+ // 先检查绝对上限(20MB)
812
+ if (fileSize > ABSOLUTE_MAX_BYTES) {
813
+ return {
814
+ finalType: detectedType,
815
+ shouldReject: true,
816
+ rejectReason: `文件大小 ${fileSizeMB}MB 超过了企业微信允许的最大限制 20MB,无法发送。请尝试压缩文件或减小文件大小。`,
817
+ downgraded: false,
818
+ };
819
+ }
820
+ // 按类型检查大小限制
821
+ switch (detectedType) {
822
+ case "image":
823
+ if (fileSize > IMAGE_MAX_BYTES) {
824
+ return {
825
+ finalType: "file",
826
+ shouldReject: false,
827
+ downgraded: true,
828
+ downgradeNote: `图片大小 ${fileSizeMB}MB 超过 10MB 限制,已转为文件格式发送`,
829
+ };
830
+ }
831
+ break;
832
+ case "video":
833
+ if (fileSize > VIDEO_MAX_BYTES) {
834
+ return {
835
+ finalType: "file",
836
+ shouldReject: false,
837
+ downgraded: true,
838
+ downgradeNote: `视频大小 ${fileSizeMB}MB 超过 10MB 限制,已转为文件格式发送`,
839
+ };
840
+ }
841
+ break;
842
+ case "voice":
843
+ // 企微语音消息仅支持 AMR 格式,非 AMR 一律降级为文件
844
+ if (contentType && !VOICE_SUPPORTED_MIMES.has(contentType.toLowerCase())) {
845
+ return {
846
+ finalType: "file",
847
+ shouldReject: false,
848
+ downgraded: true,
849
+ downgradeNote: `语音格式 ${contentType} 不支持,企微仅支持 AMR 格式,已转为文件格式发送`,
850
+ };
851
+ }
852
+ if (fileSize > VOICE_MAX_BYTES) {
853
+ return {
854
+ finalType: "file",
855
+ shouldReject: false,
856
+ downgraded: true,
857
+ downgradeNote: `语音大小 ${fileSizeMB}MB 超过 2MB 限制,已转为文件格式发送`,
858
+ };
859
+ }
860
+ break;
861
+ }
862
+ // 无需降级
863
+ return {
864
+ finalType: detectedType,
865
+ shouldReject: false,
866
+ downgraded: false,
867
+ };
868
+ }
869
+ // ============================================================================
870
+ // 辅助函数
871
+ // ============================================================================
872
+ /**
873
+ * 从 URL/路径中提取文件名
874
+ */
875
+ function extractFileName(mediaUrl, providedFileName, contentType) {
876
+ // 优先使用提供的文件名
877
+ if (providedFileName) {
878
+ return providedFileName;
879
+ }
880
+ // 尝试从 URL 中提取
881
+ try {
882
+ const urlObj = new URL(mediaUrl, "file://");
883
+ const pathParts = urlObj.pathname.split("/");
884
+ const lastPart = pathParts[pathParts.length - 1];
885
+ if (lastPart && lastPart.includes(".")) {
886
+ return decodeURIComponent(lastPart);
887
+ }
888
+ }
889
+ catch {
890
+ // 尝试作为普通路径处理
891
+ const parts = mediaUrl.split("/");
892
+ const lastPart = parts[parts.length - 1];
893
+ if (lastPart && lastPart.includes(".")) {
894
+ return lastPart;
895
+ }
896
+ }
897
+ // 使用 MIME 类型生成默认文件名
898
+ const ext = mimeToExtension(contentType || "application/octet-stream");
899
+ return `media_${Date.now()}${ext}`;
900
+ }
901
+ /**
902
+ * MIME 类型转文件扩展名
903
+ */
904
+ function mimeToExtension(mime) {
905
+ const map = {
906
+ "image/jpeg": ".jpg",
907
+ "image/png": ".png",
908
+ "image/gif": ".gif",
909
+ "image/webp": ".webp",
910
+ "image/bmp": ".bmp",
911
+ "image/svg+xml": ".svg",
912
+ "video/mp4": ".mp4",
913
+ "video/quicktime": ".mov",
914
+ "video/x-msvideo": ".avi",
915
+ "video/webm": ".webm",
916
+ "audio/mpeg": ".mp3",
917
+ "audio/ogg": ".ogg",
918
+ "audio/wav": ".wav",
919
+ "audio/amr": ".amr",
920
+ "audio/aac": ".aac",
921
+ "application/pdf": ".pdf",
922
+ "application/zip": ".zip",
923
+ "application/msword": ".doc",
924
+ "application/vnd.openxmlformats-officedocument.wordprocessingml.document": ".docx",
925
+ "application/vnd.ms-excel": ".xls",
926
+ "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet": ".xlsx",
927
+ "text/plain": ".txt",
928
+ };
929
+ return map[mime] || ".bin";
930
+ }
931
+ /**
932
+ * 公共媒体上传+发送流程
933
+ *
934
+ * 统一处理:resolveMediaFile → detectType → sizeCheck → uploadMedia → sendMediaMessage
935
+ * 媒体消息统一走 aibot_send_msg 主动发送,避免多文件场景下 reqId 只能用一次的问题。
936
+ * channel.ts 的 sendMedia 和 monitor.ts 的 deliver 回调都使用此函数。
937
+ */
938
+ async function uploadAndSendMedia(options) {
939
+ const { wsClient, mediaUrl, chatId, mediaLocalRoots, log, errorLog } = options;
940
+ try {
941
+ // 1. 加载媒体文件
942
+ log?.(`[wecom] Uploading media: url=${mediaUrl}`);
943
+ const media = await resolveMediaFile(mediaUrl, mediaLocalRoots);
944
+ // 2. 检测企微媒体类型
945
+ const detectedType = detectWeComMediaType(media.contentType);
946
+ // 3. 文件大小检查与降级策略
947
+ const sizeCheck = applyFileSizeLimits(media.buffer.length, detectedType, media.contentType);
948
+ if (sizeCheck.shouldReject) {
949
+ errorLog?.(`[wecom] Media rejected: ${sizeCheck.rejectReason}`);
950
+ return {
951
+ ok: false,
952
+ rejected: true,
953
+ rejectReason: sizeCheck.rejectReason,
954
+ finalType: sizeCheck.finalType,
955
+ };
956
+ }
957
+ const finalType = sizeCheck.finalType;
958
+ // 4. 分片上传获取 media_id
959
+ const uploadResult = await wsClient.uploadMedia(media.buffer, {
960
+ type: finalType,
961
+ filename: media.fileName,
962
+ });
963
+ log?.(`[wecom] Media uploaded: media_id=${uploadResult.media_id}, type=${finalType}`);
964
+ // 5. 统一通过 aibot_send_msg 主动发送媒体消息
965
+ const result = await wsClient.sendMediaMessage(chatId, finalType, uploadResult.media_id);
966
+ const messageId = result?.headers?.req_id ?? `wecom-media-${Date.now()}`;
967
+ log?.(`[wecom] Media sent via sendMediaMessage: chatId=${chatId}, type=${finalType}`);
968
+ return {
969
+ ok: true,
970
+ messageId,
971
+ finalType,
972
+ downgraded: sizeCheck.downgraded,
973
+ downgradeNote: sizeCheck.downgradeNote,
974
+ };
975
+ }
976
+ catch (err) {
977
+ const errMsg = String(err);
978
+ errorLog?.(`[wecom] Failed to upload/send media: url=${mediaUrl}, error=${errMsg}`);
979
+ return {
980
+ ok: false,
981
+ error: errMsg,
982
+ };
983
+ }
984
+ }
985
+
370
986
  /**
371
987
  * 企业微信群组访问控制模块
372
988
  *
@@ -599,13 +1215,13 @@ function resolveStateDirFromEnv(env = process.env) {
599
1215
  return stateOverride;
600
1216
  }
601
1217
  if (env.VITEST || env.NODE_ENV === "test") {
602
- return path$1.join(os.tmpdir(), ["openclaw-vitest", String(process.pid)].join("-"));
1218
+ return path.join(os.tmpdir(), ["openclaw-vitest", String(process.pid)].join("-"));
603
1219
  }
604
- return path$1.join(os.homedir(), ".openclaw");
1220
+ return path.join(os.homedir(), ".openclaw");
605
1221
  }
606
1222
  function resolveReqIdFilePath(accountId) {
607
1223
  const safe = accountId.replace(/[^a-zA-Z0-9_-]/g, "_");
608
- return path$1.join(resolveStateDirFromEnv(), "wecom", `reqid-map-${safe}.json`);
1224
+ return path.join(resolveStateDirFromEnv(), "wecom", `reqid-map-${safe}.json`);
609
1225
  }
610
1226
  // ============================================================================
611
1227
  // 核心实现
@@ -1015,8 +1631,8 @@ async function saveMcpConfigToPluginJson(config, runtime) {
1015
1631
  // const pluginJsonPath = PLUGIN_JSON_PATH;
1016
1632
  // 获取绝对路径并写入到配置文件
1017
1633
  // const absolutePath = path.resolve(pluginJsonPath);
1018
- const wecomConfigDir = path.join(os$1.homedir(), ".openclaw", "wecomConfig");
1019
- const wecomConfigPath = path.join(wecomConfigDir, "config.json");
1634
+ const wecomConfigDir = path$1.join(os$1.homedir(), ".openclaw", "wecomConfig");
1635
+ const wecomConfigPath = path$1.join(wecomConfigDir, "config.json");
1020
1636
  // // 写入 wecom 配置文件
1021
1637
  // try {
1022
1638
  // const lockOptions = {
@@ -1110,6 +1726,79 @@ async function fetchAndSaveMcpConfig(wsClient, accountId, runtime) {
1110
1726
  * - state-manager.ts : 全局状态管理(带 TTL 清理)
1111
1727
  * - timeout.ts : 超时工具
1112
1728
  */
1729
+ /**
1730
+ * 去除文本中的 `<think>...</think>` 标签(支持跨行),返回剩余可见文本。
1731
+ * 用于判断大模型回复中是否包含实际用户可见内容(而非仅有 thinking 推理过程)。
1732
+ */
1733
+ function stripThinkTags(text) {
1734
+ return text;
1735
+ // return text.replace(/<think>[\s\S]*?<\/think>/g, "").trim();
1736
+ }
1737
+ // ============================================================================
1738
+ // 媒体本地路径白名单扩展
1739
+ // ============================================================================
1740
+ /**
1741
+ * 解析 openclaw 状态目录(与 plugin-sdk 内部逻辑保持一致)
1742
+ */
1743
+ function resolveStateDir() {
1744
+ const stateOverride = process.env.OPENCLAW_STATE_DIR?.trim() || process.env.CLAWDBOT_STATE_DIR?.trim();
1745
+ if (stateOverride)
1746
+ return stateOverride;
1747
+ return path__namespace$1.join(os__namespace$1.homedir(), ".openclaw");
1748
+ }
1749
+ /**
1750
+ * 在 getDefaultMediaLocalRoots() 基础上,将 stateDir 本身也加入白名单,
1751
+ * 并合并用户在 WeComConfig 中配置的自定义 mediaLocalRoots。
1752
+ *
1753
+ * getDefaultMediaLocalRoots() 仅包含 stateDir 下的子目录(media/agents/workspace/sandboxes),
1754
+ * 但 agent 生成的文件可能直接放在 stateDir 根目录下(如 ~/.openclaw-dev/1.png),
1755
+ * 因此需要将 stateDir 本身也加入白名单以避免 LocalMediaAccessError。
1756
+ *
1757
+ * 用户可在 openclaw.json 中配置:
1758
+ * {
1759
+ * "channels": {
1760
+ * "wecom": {
1761
+ * "mediaLocalRoots": ["~/Downloads", "~/Documents"]
1762
+ * }
1763
+ * }
1764
+ * }
1765
+ */
1766
+ async function getExtendedMediaLocalRoots(config) {
1767
+ // 从兼容层获取默认白名单(内部已处理低版本 SDK 的 fallback)
1768
+ const defaults = await getDefaultMediaLocalRoots();
1769
+ const roots = [...defaults];
1770
+ const stateDir = path__namespace$1.resolve(resolveStateDir());
1771
+ if (!roots.includes(stateDir)) {
1772
+ roots.push(stateDir);
1773
+ }
1774
+ // 合并用户在 WeComConfig 中配置的自定义路径
1775
+ if (config?.mediaLocalRoots) {
1776
+ for (const r of config.mediaLocalRoots) {
1777
+ const resolved = path__namespace$1.resolve(r.replace(/^~(?=\/|$)/, os__namespace$1.homedir()));
1778
+ if (!roots.includes(resolved)) {
1779
+ roots.push(resolved);
1780
+ }
1781
+ }
1782
+ }
1783
+ return roots;
1784
+ }
1785
+ // ============================================================================
1786
+ // 媒体发送错误提示
1787
+ // ============================================================================
1788
+ /**
1789
+ * 根据媒体发送结果生成纯文本错误摘要(用于替换 thinking 流式消息展示给用户)。
1790
+ *
1791
+ * 使用纯文本而非 markdown 格式,因为 replyStream 只支持纯文本。
1792
+ */
1793
+ function buildMediaErrorSummary(mediaUrl, result) {
1794
+ if (result.error?.includes("LocalMediaAccessError")) {
1795
+ return `⚠️ 文件发送失败:没有权限访问路径 ${mediaUrl}\n请在 openclaw.json 的 mediaLocalRoots 中添加该路径的父目录后重启生效。`;
1796
+ }
1797
+ if (result.rejectReason) {
1798
+ return `⚠️ 文件发送失败:${result.rejectReason}`;
1799
+ }
1800
+ return `⚠️ 文件发送失败:无法处理文件 ${mediaUrl},请稍后再试。`;
1801
+ }
1113
1802
  // ============================================================================
1114
1803
  // 消息上下文构建
1115
1804
  // ============================================================================
@@ -1171,15 +1860,11 @@ function buildMessageContext(frame, account, config, text, mediaList, quoteConte
1171
1860
  ReplyToBody: quoteContent,
1172
1861
  });
1173
1862
  }
1174
- // ============================================================================
1175
- // 消息处理和回复
1176
- // ============================================================================
1177
1863
  /**
1178
1864
  * 发送"思考中"消息
1179
1865
  */
1180
1866
  async function sendThinkingReply(params) {
1181
1867
  const { wsClient, frame, streamId, runtime } = params;
1182
- runtime.log?.(`[WeCom] Sending thinking message`);
1183
1868
  try {
1184
1869
  await sendWeComReply({
1185
1870
  wsClient,
@@ -1191,15 +1876,105 @@ async function sendThinkingReply(params) {
1191
1876
  });
1192
1877
  }
1193
1878
  catch (err) {
1194
- runtime.error?.(`[WeCom] Failed to send thinking message: ${String(err)}`);
1879
+ runtime.error?.(`[wecom] Failed to send thinking message: ${String(err)}`);
1195
1880
  }
1196
1881
  }
1882
+ /**
1883
+ * 累积文本并判断是否有可见内容(去除 <think> 标签后)
1884
+ */
1885
+ function accumulateText(state, text) {
1886
+ state.accumulatedText += text;
1887
+ if (!state.hasText && stripThinkTags(state.accumulatedText)) {
1888
+ state.hasText = true;
1889
+ }
1890
+ }
1891
+ /**
1892
+ * 上传并发送一批媒体文件(统一走主动发送通道)
1893
+ *
1894
+ * replyMedia(被动回复)无法覆盖 replyStream 发出的 thinking 流式消息,
1895
+ * 因此所有媒体统一走 aibot_send_msg 主动发送。
1896
+ */
1897
+ async function sendMediaBatch(ctx, mediaUrls) {
1898
+ const { wsClient, frame, state, account, runtime } = ctx;
1899
+ const body = frame.body;
1900
+ const chatId = body.chatid || body.from.userid;
1901
+ const mediaLocalRoots = await getExtendedMediaLocalRoots(account.config);
1902
+ runtime.log?.(`[wecom][debug] mediaLocalRoots=${JSON.stringify(mediaLocalRoots)}, mediaUrls=${JSON.stringify(mediaUrls)}, hasText=${!!state.hasText}`);
1903
+ for (const mediaUrl of mediaUrls) {
1904
+ const result = await uploadAndSendMedia({
1905
+ wsClient,
1906
+ mediaUrl,
1907
+ chatId,
1908
+ mediaLocalRoots,
1909
+ log: (...args) => runtime.log?.(...args),
1910
+ errorLog: (...args) => runtime.error?.(...args),
1911
+ });
1912
+ if (result.ok) {
1913
+ state.hasMedia = true;
1914
+ }
1915
+ else {
1916
+ state.hasMediaFailed = true;
1917
+ runtime.error?.(`[wecom] Media send failed: url=${mediaUrl}, reason=${result.rejectReason || result.error}`);
1918
+ // 收集错误摘要,后续在 finishThinkingStream 中直接替换 thinking 流展示给用户
1919
+ const summary = buildMediaErrorSummary(mediaUrl, result);
1920
+ state.mediaErrorSummary = state.mediaErrorSummary
1921
+ ? `${state.mediaErrorSummary}\n\n${summary}`
1922
+ : summary;
1923
+ }
1924
+ }
1925
+ }
1926
+ /**
1927
+ * 关闭 thinking 流(发送 finish=true 的流式消息)
1928
+ *
1929
+ * thinking 是通过 replyStream 用 streamId 发的流式消息,
1930
+ * 只有同一 streamId 的 replyStream(finish=true) 才能关闭它。
1931
+ *
1932
+ * ⚠️ 注意:企微会忽略空格等不可见内容,必须用有可见字符的文案才能真正
1933
+ * 替换掉 thinking 动画,否则 thinking 会一直残留。
1934
+ *
1935
+ * 关闭策略(按优先级):
1936
+ * 1. 有可见文本 → 用完整文本关闭
1937
+ * 2. 有媒体成功发送(通过 deliver 回调) → 用友好提示"文件已发送"
1938
+ * 3. 媒体发送失败 → 直接用错误摘要替换 thinking
1939
+ * 4. 其他 → 用通用"处理完成"提示
1940
+ * (agent 可能已通过内置 message 工具直接发送了文件,
1941
+ * 该路径走 outbound.sendMedia 完全绕过 deliver 回调,
1942
+ * 所以 state 中无记录,但文件已实际送达)
1943
+ */
1944
+ async function finishThinkingStream(ctx) {
1945
+ const { wsClient, frame, state, runtime } = ctx;
1946
+ const visibleText = stripThinkTags(state.accumulatedText);
1947
+ let finishText;
1948
+ if (visibleText) {
1949
+ // 有可见文本:用完整文本关闭流(覆盖 thinking 为真实内容)
1950
+ finishText = state.accumulatedText;
1951
+ }
1952
+ else if (state.hasMedia) {
1953
+ // 媒体成功发送:用友好提示告知用户
1954
+ finishText = "📎 文件已发送,请查收。";
1955
+ }
1956
+ else if (state.hasMediaFailed && state.mediaErrorSummary) {
1957
+ // 媒体发送失败:直接用错误摘要替换 thinking 流(不再额外发 sendMessage)
1958
+ finishText = state.mediaErrorSummary;
1959
+ }
1960
+ else {
1961
+ // 核心无可见文本且 deliver 中未处理过媒体。
1962
+ //
1963
+ // 不使用错误提示,因为 agent 可能已通过内置 message 工具直接调用
1964
+ // outbound.sendMedia 成功发送了文件——该路径完全绕过 monitor 的 deliver
1965
+ // 回调,所以 state.deliverCalled / state.hasMedia 均为 false,但文件
1966
+ // 实际已送达用户。此时显示 "未生成回复" 会误导用户。
1967
+ finishText = "✅ 处理完成。";
1968
+ }
1969
+ await sendWeComReply({ wsClient, frame, text: finishText, runtime, finish: true, streamId: state.streamId });
1970
+ }
1197
1971
  /**
1198
1972
  * 路由消息到核心处理流程并处理回复
1199
1973
  */
1200
1974
  async function routeAndDispatchMessage(params) {
1201
- const { ctxPayload, config, wsClient, frame, state, runtime, onCleanup } = params;
1975
+ const { ctxPayload, config, account, wsClient, frame, state, runtime, onCleanup } = params;
1202
1976
  const core = getWeComRuntime();
1977
+ const ctx = { wsClient, frame, state, account, runtime };
1203
1978
  // 防止 onCleanup 被多次调用(onError 回调与 catch 块可能重复触发)
1204
1979
  let cleanedUp = false;
1205
1980
  const safeCleanup = () => {
@@ -1214,37 +1989,54 @@ async function routeAndDispatchMessage(params) {
1214
1989
  cfg: config,
1215
1990
  dispatcherOptions: {
1216
1991
  deliver: async (payload, info) => {
1217
- state.accumulatedText += payload.text;
1218
- if (info.kind !== "final") {
1219
- await sendWeComReply({
1220
- wsClient,
1221
- frame,
1222
- text: state.accumulatedText,
1223
- runtime,
1224
- finish: false,
1225
- streamId: state.streamId,
1226
- });
1992
+ state.deliverCalled = true;
1993
+ runtime.log?.(`[openclaw -> plugin] kind=${info.kind}, text=${payload.text ?? ''}, mediaUrl=${payload.mediaUrl ?? ''}, mediaUrls=${JSON.stringify(payload.mediaUrls ?? [])}`);
1994
+ // 累积文本
1995
+ if (payload.text) {
1996
+ accumulateText(state, payload.text);
1997
+ }
1998
+ // 发送媒体(统一走主动发送)
1999
+ const mediaUrls = payload.mediaUrls?.length ? payload.mediaUrls : payload.mediaUrl ? [payload.mediaUrl] : [];
2000
+ if (mediaUrls.length > 0) {
2001
+ try {
2002
+ await sendMediaBatch(ctx, mediaUrls);
2003
+ }
2004
+ catch (mediaErr) {
2005
+ // sendMediaBatch 内部异常(如 getDefaultMediaLocalRoots 不可用等)
2006
+ // 必须标记 state,否则 finishThinkingStream 会显示"处理完成"误导用户
2007
+ state.hasMediaFailed = true;
2008
+ const errMsg = String(mediaErr);
2009
+ const summary = `⚠️ 文件发送失败:内部处理异常,请升级 openclaw 到最新版本后重试。\n错误详情:${errMsg}`;
2010
+ state.mediaErrorSummary = state.mediaErrorSummary
2011
+ ? `${state.mediaErrorSummary}\n\n${summary}`
2012
+ : summary;
2013
+ runtime.error?.(`[wecom] sendMediaBatch threw: ${errMsg}`);
2014
+ }
2015
+ }
2016
+ // 中间帧:有可见文本时流式更新
2017
+ if (info.kind !== "final" && state.hasText && state.accumulatedText) {
2018
+ await sendWeComReply({ wsClient, frame, text: state.accumulatedText, runtime, finish: false, streamId: state.streamId });
1227
2019
  }
1228
2020
  },
1229
2021
  onError: (err, info) => {
1230
- runtime.error?.(`[WeCom] ${info.kind} reply failed: ${String(err)}`);
1231
- // 仅记录错误,不立即 cleanup,让外层 try/catch 统一处理最终回复和 cleanup
2022
+ runtime.error?.(`[wecom] ${info.kind} reply failed: ${String(err)}`);
1232
2023
  },
1233
2024
  },
1234
2025
  });
1235
- // 发送最终消息
1236
- await sendWeComReply({
1237
- wsClient,
1238
- frame,
1239
- text: state.accumulatedText,
1240
- runtime,
1241
- finish: true,
1242
- streamId: state.streamId,
1243
- });
2026
+ // 关闭 thinking 流
2027
+ await finishThinkingStream(ctx);
1244
2028
  safeCleanup();
1245
2029
  }
1246
2030
  catch (err) {
1247
- runtime.error?.(`[WeCom] Failed to process message: ${String(err)}`);
2031
+ runtime.error?.(`[wecom][plugin] Failed to process message: ${String(err)}`);
2032
+ // 即使 dispatch 抛异常,也需要关闭 thinking 流,
2033
+ // 避免 deliver 已成功发送媒体但后续出错时 thinking 消息残留或被错误文案覆盖
2034
+ try {
2035
+ await finishThinkingStream(ctx);
2036
+ }
2037
+ catch (finishErr) {
2038
+ runtime.error?.(`[wecom] Failed to finish thinking stream after dispatch error: ${String(finishErr)}`);
2039
+ }
1248
2040
  safeCleanup();
1249
2041
  }
1250
2042
  }
@@ -1279,14 +2071,13 @@ async function processWeComMessage(params) {
1279
2071
  // 如果文本为空但存在引用消息,使用引用消息内容
1280
2072
  if (!text && quoteContent) {
1281
2073
  text = quoteContent;
1282
- runtime.log?.("[WeCom] Using quote content as message body (user only mentioned bot)");
2074
+ runtime.log?.("[wecom][plugin] Using quote content as message body (user only mentioned bot)");
1283
2075
  }
1284
2076
  // 如果既没有文本也没有图片也没有文件也没有引用内容,则跳过
1285
2077
  if (!text && imageUrls.length === 0 && fileUrls.length === 0) {
1286
- runtime.log?.("[WeCom] Skipping empty message (no text, image, file or quote)");
2078
+ runtime.log?.("[wecom][plugin] Skipping empty message (no text, image, file or quote)");
1287
2079
  return;
1288
2080
  }
1289
- runtime.log?.(`[WeCom] Processing ${chatType} message from chat: ${chatId} user: ${body.from.userid} reqId: ${reqId}${imageUrls.length > 0 ? ` (with ${imageUrls.length} image(s))` : ""}${fileUrls.length > 0 ? ` (with ${fileUrls.length} file(s))` : ""}${quoteContent ? ` (with quote)` : ""}`);
1290
2081
  // Step 2: 群组策略检查(仅群聊)
1291
2082
  if (chatType === "group") {
1292
2083
  const groupPolicyResult = checkGroupPolicy({
@@ -1347,10 +2138,12 @@ async function processWeComMessage(params) {
1347
2138
  }
1348
2139
  // Step 7: 构建上下文并路由到核心处理流程(带整体超时保护)
1349
2140
  const ctxPayload = buildMessageContext(frame, account, config, text, mediaList, quoteContent);
2141
+ runtime.log?.(`[plugin -> openclaw] body=${text}, mediaPaths=${JSON.stringify(mediaList.map(m => m.path))}${quoteContent ? `, quote=${quoteContent}` : ''}`);
1350
2142
  try {
1351
2143
  await withTimeout(routeAndDispatchMessage({
1352
2144
  ctxPayload,
1353
2145
  config,
2146
+ account,
1354
2147
  wsClient,
1355
2148
  frame,
1356
2149
  state,
@@ -1359,7 +2152,23 @@ async function processWeComMessage(params) {
1359
2152
  }), MESSAGE_PROCESS_TIMEOUT_MS, `Message processing timed out (msgId=${messageId})`);
1360
2153
  }
1361
2154
  catch (err) {
1362
- runtime.error?.(`[WeCom] Message processing failed or timed out: ${String(err)}`);
2155
+ runtime.error?.(`[wecom][plugin] Message processing failed or timed out: ${String(err)}`);
2156
+ // 确保 thinking 流被关闭,防止异常/超时时 thinking 消息一直残留
2157
+ try {
2158
+ if (shouldSendThinking) {
2159
+ await sendWeComReply({
2160
+ wsClient,
2161
+ frame,
2162
+ text: "处理消息时出现异常,请稍后重试。",
2163
+ runtime,
2164
+ finish: true,
2165
+ streamId: state.streamId,
2166
+ });
2167
+ }
2168
+ }
2169
+ catch (finishErr) {
2170
+ runtime.error?.(`[wecom] Failed to finish thinking stream on error: ${String(finishErr)}`);
2171
+ }
1363
2172
  cleanupState();
1364
2173
  }
1365
2174
  }
@@ -1649,7 +2458,6 @@ async function sendWeComMessage({ to, content, accountId, }) {
1649
2458
  // 从 to 中提取 chatId(格式是 "${CHANNEL_ID}:chatId" 或直接是 chatId)
1650
2459
  const channelPrefix = new RegExp(`^${CHANNEL_ID}:`, "i");
1651
2460
  const chatId = to.replace(channelPrefix, "");
1652
- console.log(`[WeCom] sendWeComMessage: ${JSON.stringify({ to, content, accountId })}`);
1653
2461
  // 获取 WSClient 实例
1654
2462
  const wsClient = getWeComWebSocket(resolvedAccountId);
1655
2463
  if (!wsClient) {
@@ -1661,7 +2469,6 @@ async function sendWeComMessage({ to, content, accountId, }) {
1661
2469
  markdown: { content },
1662
2470
  });
1663
2471
  const messageId = result?.headers?.req_id ?? `wecom-${Date.now()}`;
1664
- console.log(`[WeCom] Sent message to ${chatId}, messageId=${messageId}`);
1665
2472
  return {
1666
2473
  channel: CHANNEL_ID,
1667
2474
  messageId,
@@ -1694,7 +2501,7 @@ const wecomPlugin = {
1694
2501
  // content: " pairing approved",
1695
2502
  // accountId: cfg.accountId,
1696
2503
  // });
1697
- console.log(`[WeCom] Pairing approved for user: ${id}`);
2504
+ // Pairing approved for user
1698
2505
  },
1699
2506
  },
1700
2507
  onboarding: wecomOnboardingAdapter,
@@ -1821,14 +2628,51 @@ const wecomPlugin = {
1821
2628
  deliveryMode: "gateway",
1822
2629
  chunker: (text, limit) => getWeComRuntime().channel.text.chunkMarkdownText(text, limit),
1823
2630
  textChunkLimit: TEXT_CHUNK_LIMIT,
1824
- sendText: async ({ to, text, accountId, ...rest }) => {
1825
- console.log(`[WeCom] sendText: ${JSON.stringify({ to, text, accountId, ...rest })}`);
2631
+ sendText: async ({ to, text, accountId }) => {
1826
2632
  return sendWeComMessage({ to, content: text, accountId: accountId ?? undefined });
1827
2633
  },
1828
- sendMedia: async ({ to, text, mediaUrl, accountId, ...rest }) => {
1829
- console.log(`[WeCom] sendMedia: ${JSON.stringify({ to, text, mediaUrl, accountId, ...rest })}`);
1830
- const content = `Sending attachments is not supported yet\n${text ? `${text}\n${mediaUrl}` : (mediaUrl ?? "")}`;
1831
- return sendWeComMessage({ to, content, accountId: accountId ?? undefined });
2634
+ sendMedia: async ({ to, text, mediaUrl, mediaLocalRoots, accountId }) => {
2635
+ const resolvedAccountId = accountId ?? pluginSdk.DEFAULT_ACCOUNT_ID;
2636
+ const channelPrefix = new RegExp(`^${CHANNEL_ID}:`, "i");
2637
+ const chatId = to.replace(channelPrefix, "");
2638
+ // 获取 WSClient 实例
2639
+ const wsClient = getWeComWebSocket(resolvedAccountId);
2640
+ if (!wsClient) {
2641
+ throw new Error(`WSClient not connected for account ${resolvedAccountId}`);
2642
+ }
2643
+ // 如果没有 mediaUrl,fallback 为纯文本
2644
+ if (!mediaUrl) {
2645
+ return sendWeComMessage({ to, content: text || "", accountId: resolvedAccountId });
2646
+ }
2647
+ const result = await uploadAndSendMedia({
2648
+ wsClient,
2649
+ mediaUrl,
2650
+ chatId,
2651
+ mediaLocalRoots,
2652
+ });
2653
+ if (result.rejected) {
2654
+ return sendWeComMessage({ to, content: `⚠️ ${result.rejectReason}`, accountId: resolvedAccountId });
2655
+ }
2656
+ if (!result.ok) {
2657
+ // 上传/发送失败,fallback 为文本 + URL
2658
+ const fallbackContent = text
2659
+ ? `${text}\n📎 ${mediaUrl}`
2660
+ : `📎 ${mediaUrl}`;
2661
+ return sendWeComMessage({ to, content: fallbackContent, accountId: resolvedAccountId });
2662
+ }
2663
+ // 如有伴随文本,额外发送一条 markdown
2664
+ if (text) {
2665
+ await sendWeComMessage({ to, content: text, accountId: resolvedAccountId });
2666
+ }
2667
+ // 如果有降级说明,额外发送提示
2668
+ if (result.downgradeNote) {
2669
+ await sendWeComMessage({ to, content: `ℹ️ ${result.downgradeNote}`, accountId: resolvedAccountId });
2670
+ }
2671
+ return {
2672
+ channel: CHANNEL_ID,
2673
+ messageId: result.messageId,
2674
+ chatId,
2675
+ };
1832
2676
  },
1833
2677
  },
1834
2678
  status: {
@@ -1937,6 +2781,33 @@ const plugin = {
1937
2781
  register(api) {
1938
2782
  setWeComRuntime(api.runtime);
1939
2783
  api.registerChannel({ plugin: wecomPlugin });
2784
+ // 注入媒体发送指令和文件大小限制提示词
2785
+ api.on("before_prompt_build", () => {
2786
+ return {
2787
+ appendSystemContext: [
2788
+ "【发送文件/图片/视频/语音】",
2789
+ "当你需要向用户发送文件、图片、视频或语音时,必须在回复中单独一行使用 MEDIA: 指令,后面跟文件的本地路径。",
2790
+ "格式:MEDIA: /文件的绝对路径",
2791
+ "文件优先存放到 ~/.openclaw 目录下,确保路径可访问。",
2792
+ "示例:",
2793
+ " MEDIA: ~/.openclaw/output.png",
2794
+ " MEDIA: ~/.openclaw/report.pdf",
2795
+ "系统会自动识别文件类型并发送给用户。",
2796
+ "",
2797
+ "注意事项:",
2798
+ "- MEDIA: 必须在行首,后面紧跟文件路径(不是 URL)",
2799
+ "- 如果路径中包含空格,可以用反引号包裹:MEDIA: `/path/to/my file.png`",
2800
+ "- 每个文件单独一行 MEDIA: 指令",
2801
+ "- 可以在 MEDIA: 指令前后附带文字说明",
2802
+ "",
2803
+ "【文件大小限制】",
2804
+ "- 图片不超过 10MB,视频不超过 10MB,语音不超过 2MB(仅支持 AMR 格式),文件不超过 20MB",
2805
+ "- 语音消息仅支持 AMR 格式(.amr),如需发送语音请确保文件为 AMR 格式",
2806
+ "- 超过大小限制的图片/视频/语音会被自动转为文件格式发送",
2807
+ "- 如果文件超过 20MB,将无法发送,请提前告知用户并尝试缩减文件大小",
2808
+ ].join("\n"),
2809
+ };
2810
+ });
1940
2811
  },
1941
2812
  };
1942
2813