@wecom/wecom-openclaw-plugin 2026.3.20-beta.1 → 2026.3.20-beta.4

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.esm.js CHANGED
@@ -2666,6 +2666,8 @@ const wecomPlugin = {
2666
2666
  // ============================================================================
2667
2667
  /** HTTP 请求超时时间(毫秒) */
2668
2668
  const HTTP_REQUEST_TIMEOUT_MS = 30000;
2669
+ /** 媒体下载请求超时时间(毫秒),base64 编码的媒体文件最大可达 ~27MB */
2670
+ const MEDIA_DOWNLOAD_TIMEOUT_MS = 120000;
2669
2671
  /** 日志前缀 */
2670
2672
  const LOG_TAG = "[mcp]";
2671
2673
  /**
@@ -2772,9 +2774,9 @@ async function getMcpUrl(category) {
2772
2774
  * 自动携带 Mcp-Session-Id 请求头(如果有),
2773
2775
  * 并从响应头中更新 sessionId。
2774
2776
  */
2775
- async function sendRawJsonRpc(url, session, body) {
2777
+ async function sendRawJsonRpc(url, session, body, timeoutMs = HTTP_REQUEST_TIMEOUT_MS) {
2776
2778
  const controller = new AbortController();
2777
- const timeoutId = setTimeout(() => controller.abort(), HTTP_REQUEST_TIMEOUT_MS);
2779
+ const timeoutId = setTimeout(() => controller.abort(), timeoutMs);
2778
2780
  const headers = {
2779
2781
  "Content-Type": "application/json",
2780
2782
  Accept: "application/json, text/event-stream",
@@ -2794,7 +2796,7 @@ async function sendRawJsonRpc(url, session, body) {
2794
2796
  }
2795
2797
  catch (err) {
2796
2798
  if (err instanceof DOMException && err.name === "AbortError") {
2797
- throw new Error(`MCP 请求超时 (${HTTP_REQUEST_TIMEOUT_MS}ms)`);
2799
+ throw new Error(`MCP 请求超时 (${timeoutMs}ms)`);
2798
2800
  }
2799
2801
  throw new Error(`MCP 网络请求失败: ${err instanceof Error ? err.message : String(err)}`);
2800
2802
  }
@@ -2986,10 +2988,12 @@ function clearCategoryCache(category) {
2986
2988
  * @param category - MCP 品类名称
2987
2989
  * @param method - JSON-RPC 方法名
2988
2990
  * @param params - JSON-RPC 参数
2991
+ * @param options - 可选配置(如自定义超时)
2989
2992
  * @returns JSON-RPC result
2990
2993
  */
2991
- async function sendJsonRpc(category, method, params) {
2994
+ async function sendJsonRpc(category, method, params, options) {
2992
2995
  const url = await getMcpUrl(category);
2996
+ const timeoutMs = options?.timeoutMs;
2993
2997
  const body = {
2994
2998
  jsonrpc: "2.0",
2995
2999
  id: generateReqId("mcp_rpc"),
@@ -2998,7 +3002,7 @@ async function sendJsonRpc(category, method, params) {
2998
3002
  };
2999
3003
  let session = await getOrCreateSession(url, category);
3000
3004
  try {
3001
- const { rpcResult, newSessionId } = await sendRawJsonRpc(url, session, body);
3005
+ const { rpcResult, newSessionId } = await sendRawJsonRpc(url, session, body, timeoutMs);
3002
3006
  // 用最新的 sessionId 更新 session
3003
3007
  if (newSessionId) {
3004
3008
  session.sessionId = newSessionId;
@@ -3020,7 +3024,7 @@ async function sendJsonRpc(category, method, params) {
3020
3024
  mcpSessionCache.delete(category);
3021
3025
  // 使用 rebuildSession 合并并发的 session 重建请求,避免竞态条件
3022
3026
  session = await rebuildSession(url, category);
3023
- const { rpcResult, newSessionId } = await sendRawJsonRpc(url, session, body);
3027
+ const { rpcResult, newSessionId } = await sendRawJsonRpc(url, session, body, timeoutMs);
3024
3028
  if (newSessionId) {
3025
3029
  session.sessionId = newSessionId;
3026
3030
  }
@@ -3240,13 +3244,130 @@ const checkBizErrorAndClearCache = (result, category) => {
3240
3244
  }
3241
3245
  }
3242
3246
  };
3247
+ // ============================================================================
3248
+ // get_msg_media 响应拦截:base64 → 本地文件
3249
+ // ============================================================================
3250
+ /**
3251
+ * 拦截 get_msg_media 的 MCP 响应
3252
+ *
3253
+ * 核心逻辑:
3254
+ * 1. 从 MCP result 的 content[].text 中提取业务 JSON
3255
+ * 2. 提取 media_item.base64_data,解码为 Buffer
3256
+ * 3. 通过 openclaw SDK 的 saveMediaBuffer 保存到本地媒体目录
3257
+ * 4. 替换响应:移除 base64_data,加入 local_path
3258
+ *
3259
+ * 这样大模型只看到轻量的文件路径信息,不会被 base64 数据消耗 token。
3260
+ */
3261
+ async function interceptMediaResponse(result) {
3262
+ const t0 = performance.now();
3263
+ // 1. 提取 MCP result 中的 content 数组
3264
+ const content = result?.content;
3265
+ if (!Array.isArray(content))
3266
+ return result;
3267
+ const textItem = content.find((c) => c.type === "text" && typeof c.text === "string");
3268
+ if (!textItem)
3269
+ return result;
3270
+ // 2. 解析业务 JSON
3271
+ let bizData;
3272
+ try {
3273
+ bizData = JSON.parse(textItem.text);
3274
+ }
3275
+ catch {
3276
+ // 非 JSON 格式,原样返回
3277
+ return result;
3278
+ }
3279
+ // 3. 校验业务响应:errcode !== 0 或无 media_item 时原样返回
3280
+ if (bizData.errcode !== 0)
3281
+ return result;
3282
+ const mediaItem = bizData.media_item;
3283
+ if (!mediaItem || typeof mediaItem.base64_data !== "string")
3284
+ return result;
3285
+ const base64Data = mediaItem.base64_data;
3286
+ const mediaName = mediaItem.name;
3287
+ const mediaType = mediaItem.type;
3288
+ const mediaId = mediaItem.media_id;
3289
+ const tParsed = performance.now();
3290
+ // 4. 解码 base64 → Buffer
3291
+ const buffer = Buffer.from(base64Data, "base64");
3292
+ const tDecoded = performance.now();
3293
+ // 5. 检测 contentType,并通过 saveMediaBuffer 保存到本地媒体目录
3294
+ const contentType = await detectMime({ buffer, filePath: mediaName }) ?? "application/octet-stream";
3295
+ const tMimeDetected = performance.now();
3296
+ const core = getWeComRuntime();
3297
+ const saved = await core.channel.media.saveMediaBuffer(buffer, contentType, "inbound", undefined, // maxBytes: 使用 saveMediaBuffer 内部默认值
3298
+ mediaName);
3299
+ // 5.1 补偿:核心库 EXT_BY_MIME 可能缺少某些格式映射(如 audio/amr),
3300
+ // 导致保存的文件没有后缀。此处检测并修复。
3301
+ const MIME_EXT_PATCH = {
3302
+ "audio/amr": ".amr",
3303
+ };
3304
+ const patchExt = MIME_EXT_PATCH[contentType];
3305
+ if (patchExt && !path.extname(saved.path)) {
3306
+ const newPath = saved.path + patchExt;
3307
+ try {
3308
+ await fs.rename(saved.path, newPath);
3309
+ saved.path = newPath;
3310
+ }
3311
+ catch {
3312
+ // rename 失败不影响主流程,文件仍可用
3313
+ }
3314
+ }
3315
+ const tSaved = performance.now();
3316
+ // 6. 构造精简响应,移除 base64_data,加入本地路径
3317
+ const newBizData = {
3318
+ errcode: 0,
3319
+ errmsg: "ok",
3320
+ media_item: {
3321
+ media_id: mediaId,
3322
+ name: mediaName ?? saved.path.split("/").pop(),
3323
+ type: mediaType,
3324
+ local_path: saved.path,
3325
+ size: buffer.length,
3326
+ content_type: saved.contentType,
3327
+ },
3328
+ };
3329
+ const tEnd = performance.now();
3330
+ // 耗时日志:各环节耗时(ms)
3331
+ console.log(`[mcp] get_msg_media 拦截成功: media_id=${mediaId ?? "unknown"}, ` +
3332
+ `type=${mediaType ?? "unknown"}, size=${buffer.length}, saved=${saved.path}\n` +
3333
+ ` ⏱ 耗时明细 (总 ${(tEnd - t0).toFixed(1)}ms):\n` +
3334
+ ` 解析响应 JSON: ${(tParsed - t0).toFixed(1)}ms\n` +
3335
+ ` base64 解码: ${(tDecoded - tParsed).toFixed(1)}ms (${(base64Data.length / 1024).toFixed(0)}KB base64 → ${(buffer.length / 1024).toFixed(0)}KB buffer)\n` +
3336
+ ` MIME 检测: ${(tMimeDetected - tDecoded).toFixed(1)}ms (${contentType})\n` +
3337
+ ` saveMediaBuffer: ${(tSaved - tMimeDetected).toFixed(1)}ms\n` +
3338
+ ` 构造响应: ${(tEnd - tSaved).toFixed(1)}ms`);
3339
+ // 7. 返回修改后的 MCP result 结构
3340
+ return {
3341
+ content: [{
3342
+ type: "text",
3343
+ text: JSON.stringify(newBizData),
3344
+ }],
3345
+ };
3346
+ }
3347
+ // ============================================================================
3348
+ // call 操作:调用某品类的某个 MCP 工具
3349
+ // ============================================================================
3243
3350
  const handleCall = async (category, method, args) => {
3351
+ const callStart = performance.now();
3352
+ // get_msg_media 使用延长的超时时间(120s),因为返回的 base64 数据可达 ~27MB
3353
+ const options = method === "get_msg_media" ? { timeoutMs: MEDIA_DOWNLOAD_TIMEOUT_MS } : undefined;
3244
3354
  const result = await sendJsonRpc(category, "tools/call", {
3245
3355
  name: method,
3246
3356
  arguments: args,
3247
- });
3357
+ }, options);
3358
+ const rpcDone = performance.now();
3359
+ const rpcMs = (rpcDone - callStart).toFixed(1);
3248
3360
  // 检查业务层错误码,必要时清理缓存
3249
3361
  checkBizErrorAndClearCache(result, category);
3362
+ // 对 get_msg_media 响应进行 base64 拦截:解码存本地,只返回路径给大模型
3363
+ if (method === "get_msg_media") {
3364
+ const intercepted = await interceptMediaResponse(result);
3365
+ const totalMs = (performance.now() - callStart).toFixed(1);
3366
+ console.log(`[mcp] handleCall ${category}/${method} 总耗时: ${totalMs}ms` +
3367
+ ` (MCP请求: ${rpcMs}ms, 拦截处理: ${(performance.now() - rpcDone).toFixed(1)}ms)`);
3368
+ return intercepted;
3369
+ }
3370
+ console.log(`[mcp] handleCall ${category}/${method} 耗时: ${rpcMs}ms`);
3250
3371
  return result;
3251
3372
  };
3252
3373
  // ============================================================================
@@ -3363,51 +3484,7 @@ const plugin = {
3363
3484
  }
3364
3485
  return {
3365
3486
  appendSystemContext: [
3366
- "## MEDIA 指令:向用户发送本地文件",
3367
- "",
3368
- "当需要向用户发送图片、视频、语音或文件时,在回复中使用 `MEDIA:` 指令。系统会自动识别文件类型,以对应的消息格式(图片/视频/语音/文件消息)发送。",
3369
- "",
3370
- "### 语法",
3371
- "",
3372
- "```",
3373
- "MEDIA: <本地文件绝对路径>",
3374
- "```",
3375
- "",
3376
- "### 规则",
3377
- "",
3378
- "1. 如果文件路径前已经有 `MEDIA:` 指令,无需再重复添加,保持原样即可",
3379
- "2. `MEDIA:` 必须置于行首,与路径之间用一个空格分隔",
3380
- "3. 路径必须为本地文件的绝对路径(支持 `~` 展开),不支持 URL",
3381
- "4. 路径含空格时用反引号包裹:MEDIA: `/path/to/my file.png`",
3382
- "5. 每个文件独占一行,不可在同一行发送多个文件",
3383
- "6. 可在 `MEDIA:` 指令前后附加文字说明,文字与指令各占独立行",
3384
- "",
3385
- "### 文件存放",
3386
- "",
3387
- "生成的文件优先存放至 `~/.openclaw/workspace/` 目录。",
3388
- "",
3389
- "### 大小与格式限制",
3390
- "",
3391
- "| 类型 | 上限 | 格式要求 |",
3392
- "|------|------|----------|",
3393
- "| 图片 | 10MB | 常见图片格式 |",
3394
- "| 视频 | 10MB | 常见视频格式 |",
3395
- "| 语音 | 2MB | 仅 AMR(.amr) |",
3396
- "| 文件 | 20MB | 不限 |",
3397
- "",
3398
- "- 图片/视频/语音超出各自上限时,系统自动降级为文件消息发送,请主动告知用户。",
3399
- "- 文件超过 20MB 无法发送,请告知用户并尝试压缩或拆分。",
3400
- "- 非 AMR 格式的音频会以文件消息发送。",
3401
- "",
3402
- "### 示例",
3403
- "",
3404
- "```",
3405
- "以下是生成的图表:",
3406
- "MEDIA: ~/.openclaw/workspace/output.png",
3407
- "",
3408
- "报告已生成,请查收:",
3409
- "MEDIA: ~/.openclaw/workspace/report.pdf",
3410
- "```",
3487
+ "重要:涉及发送图片/视频/语音/文件给用户时,请务必使用 `MEDIA:` 指令。详见 wecom-send-media 这个 skill(技能)。"
3411
3488
  ].join("\n"),
3412
3489
  };
3413
3490
  });