@wecom/wecom-openclaw-plugin 2026.3.20-beta.2 → 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.cjs.js CHANGED
@@ -2692,6 +2692,8 @@ const wecomPlugin = {
2692
2692
  // ============================================================================
2693
2693
  /** HTTP 请求超时时间(毫秒) */
2694
2694
  const HTTP_REQUEST_TIMEOUT_MS = 30000;
2695
+ /** 媒体下载请求超时时间(毫秒),base64 编码的媒体文件最大可达 ~27MB */
2696
+ const MEDIA_DOWNLOAD_TIMEOUT_MS = 120000;
2695
2697
  /** 日志前缀 */
2696
2698
  const LOG_TAG = "[mcp]";
2697
2699
  /**
@@ -2798,9 +2800,9 @@ async function getMcpUrl(category) {
2798
2800
  * 自动携带 Mcp-Session-Id 请求头(如果有),
2799
2801
  * 并从响应头中更新 sessionId。
2800
2802
  */
2801
- async function sendRawJsonRpc(url, session, body) {
2803
+ async function sendRawJsonRpc(url, session, body, timeoutMs = HTTP_REQUEST_TIMEOUT_MS) {
2802
2804
  const controller = new AbortController();
2803
- const timeoutId = setTimeout(() => controller.abort(), HTTP_REQUEST_TIMEOUT_MS);
2805
+ const timeoutId = setTimeout(() => controller.abort(), timeoutMs);
2804
2806
  const headers = {
2805
2807
  "Content-Type": "application/json",
2806
2808
  Accept: "application/json, text/event-stream",
@@ -2820,7 +2822,7 @@ async function sendRawJsonRpc(url, session, body) {
2820
2822
  }
2821
2823
  catch (err) {
2822
2824
  if (err instanceof DOMException && err.name === "AbortError") {
2823
- throw new Error(`MCP 请求超时 (${HTTP_REQUEST_TIMEOUT_MS}ms)`);
2825
+ throw new Error(`MCP 请求超时 (${timeoutMs}ms)`);
2824
2826
  }
2825
2827
  throw new Error(`MCP 网络请求失败: ${err instanceof Error ? err.message : String(err)}`);
2826
2828
  }
@@ -3012,10 +3014,12 @@ function clearCategoryCache(category) {
3012
3014
  * @param category - MCP 品类名称
3013
3015
  * @param method - JSON-RPC 方法名
3014
3016
  * @param params - JSON-RPC 参数
3017
+ * @param options - 可选配置(如自定义超时)
3015
3018
  * @returns JSON-RPC result
3016
3019
  */
3017
- async function sendJsonRpc(category, method, params) {
3020
+ async function sendJsonRpc(category, method, params, options) {
3018
3021
  const url = await getMcpUrl(category);
3022
+ const timeoutMs = options?.timeoutMs;
3019
3023
  const body = {
3020
3024
  jsonrpc: "2.0",
3021
3025
  id: aibotNodeSdk.generateReqId("mcp_rpc"),
@@ -3024,7 +3028,7 @@ async function sendJsonRpc(category, method, params) {
3024
3028
  };
3025
3029
  let session = await getOrCreateSession(url, category);
3026
3030
  try {
3027
- const { rpcResult, newSessionId } = await sendRawJsonRpc(url, session, body);
3031
+ const { rpcResult, newSessionId } = await sendRawJsonRpc(url, session, body, timeoutMs);
3028
3032
  // 用最新的 sessionId 更新 session
3029
3033
  if (newSessionId) {
3030
3034
  session.sessionId = newSessionId;
@@ -3046,7 +3050,7 @@ async function sendJsonRpc(category, method, params) {
3046
3050
  mcpSessionCache.delete(category);
3047
3051
  // 使用 rebuildSession 合并并发的 session 重建请求,避免竞态条件
3048
3052
  session = await rebuildSession(url, category);
3049
- const { rpcResult, newSessionId } = await sendRawJsonRpc(url, session, body);
3053
+ const { rpcResult, newSessionId } = await sendRawJsonRpc(url, session, body, timeoutMs);
3050
3054
  if (newSessionId) {
3051
3055
  session.sessionId = newSessionId;
3052
3056
  }
@@ -3266,13 +3270,130 @@ const checkBizErrorAndClearCache = (result, category) => {
3266
3270
  }
3267
3271
  }
3268
3272
  };
3273
+ // ============================================================================
3274
+ // get_msg_media 响应拦截:base64 → 本地文件
3275
+ // ============================================================================
3276
+ /**
3277
+ * 拦截 get_msg_media 的 MCP 响应
3278
+ *
3279
+ * 核心逻辑:
3280
+ * 1. 从 MCP result 的 content[].text 中提取业务 JSON
3281
+ * 2. 提取 media_item.base64_data,解码为 Buffer
3282
+ * 3. 通过 openclaw SDK 的 saveMediaBuffer 保存到本地媒体目录
3283
+ * 4. 替换响应:移除 base64_data,加入 local_path
3284
+ *
3285
+ * 这样大模型只看到轻量的文件路径信息,不会被 base64 数据消耗 token。
3286
+ */
3287
+ async function interceptMediaResponse(result) {
3288
+ const t0 = performance.now();
3289
+ // 1. 提取 MCP result 中的 content 数组
3290
+ const content = result?.content;
3291
+ if (!Array.isArray(content))
3292
+ return result;
3293
+ const textItem = content.find((c) => c.type === "text" && typeof c.text === "string");
3294
+ if (!textItem)
3295
+ return result;
3296
+ // 2. 解析业务 JSON
3297
+ let bizData;
3298
+ try {
3299
+ bizData = JSON.parse(textItem.text);
3300
+ }
3301
+ catch {
3302
+ // 非 JSON 格式,原样返回
3303
+ return result;
3304
+ }
3305
+ // 3. 校验业务响应:errcode !== 0 或无 media_item 时原样返回
3306
+ if (bizData.errcode !== 0)
3307
+ return result;
3308
+ const mediaItem = bizData.media_item;
3309
+ if (!mediaItem || typeof mediaItem.base64_data !== "string")
3310
+ return result;
3311
+ const base64Data = mediaItem.base64_data;
3312
+ const mediaName = mediaItem.name;
3313
+ const mediaType = mediaItem.type;
3314
+ const mediaId = mediaItem.media_id;
3315
+ const tParsed = performance.now();
3316
+ // 4. 解码 base64 → Buffer
3317
+ const buffer = Buffer.from(base64Data, "base64");
3318
+ const tDecoded = performance.now();
3319
+ // 5. 检测 contentType,并通过 saveMediaBuffer 保存到本地媒体目录
3320
+ const contentType = await detectMime({ buffer, filePath: mediaName }) ?? "application/octet-stream";
3321
+ const tMimeDetected = performance.now();
3322
+ const core = getWeComRuntime();
3323
+ const saved = await core.channel.media.saveMediaBuffer(buffer, contentType, "inbound", undefined, // maxBytes: 使用 saveMediaBuffer 内部默认值
3324
+ mediaName);
3325
+ // 5.1 补偿:核心库 EXT_BY_MIME 可能缺少某些格式映射(如 audio/amr),
3326
+ // 导致保存的文件没有后缀。此处检测并修复。
3327
+ const MIME_EXT_PATCH = {
3328
+ "audio/amr": ".amr",
3329
+ };
3330
+ const patchExt = MIME_EXT_PATCH[contentType];
3331
+ if (patchExt && !path__namespace.extname(saved.path)) {
3332
+ const newPath = saved.path + patchExt;
3333
+ try {
3334
+ await fs__namespace.rename(saved.path, newPath);
3335
+ saved.path = newPath;
3336
+ }
3337
+ catch {
3338
+ // rename 失败不影响主流程,文件仍可用
3339
+ }
3340
+ }
3341
+ const tSaved = performance.now();
3342
+ // 6. 构造精简响应,移除 base64_data,加入本地路径
3343
+ const newBizData = {
3344
+ errcode: 0,
3345
+ errmsg: "ok",
3346
+ media_item: {
3347
+ media_id: mediaId,
3348
+ name: mediaName ?? saved.path.split("/").pop(),
3349
+ type: mediaType,
3350
+ local_path: saved.path,
3351
+ size: buffer.length,
3352
+ content_type: saved.contentType,
3353
+ },
3354
+ };
3355
+ const tEnd = performance.now();
3356
+ // 耗时日志:各环节耗时(ms)
3357
+ console.log(`[mcp] get_msg_media 拦截成功: media_id=${mediaId ?? "unknown"}, ` +
3358
+ `type=${mediaType ?? "unknown"}, size=${buffer.length}, saved=${saved.path}\n` +
3359
+ ` ⏱ 耗时明细 (总 ${(tEnd - t0).toFixed(1)}ms):\n` +
3360
+ ` 解析响应 JSON: ${(tParsed - t0).toFixed(1)}ms\n` +
3361
+ ` base64 解码: ${(tDecoded - tParsed).toFixed(1)}ms (${(base64Data.length / 1024).toFixed(0)}KB base64 → ${(buffer.length / 1024).toFixed(0)}KB buffer)\n` +
3362
+ ` MIME 检测: ${(tMimeDetected - tDecoded).toFixed(1)}ms (${contentType})\n` +
3363
+ ` saveMediaBuffer: ${(tSaved - tMimeDetected).toFixed(1)}ms\n` +
3364
+ ` 构造响应: ${(tEnd - tSaved).toFixed(1)}ms`);
3365
+ // 7. 返回修改后的 MCP result 结构
3366
+ return {
3367
+ content: [{
3368
+ type: "text",
3369
+ text: JSON.stringify(newBizData),
3370
+ }],
3371
+ };
3372
+ }
3373
+ // ============================================================================
3374
+ // call 操作:调用某品类的某个 MCP 工具
3375
+ // ============================================================================
3269
3376
  const handleCall = async (category, method, args) => {
3377
+ const callStart = performance.now();
3378
+ // get_msg_media 使用延长的超时时间(120s),因为返回的 base64 数据可达 ~27MB
3379
+ const options = method === "get_msg_media" ? { timeoutMs: MEDIA_DOWNLOAD_TIMEOUT_MS } : undefined;
3270
3380
  const result = await sendJsonRpc(category, "tools/call", {
3271
3381
  name: method,
3272
3382
  arguments: args,
3273
- });
3383
+ }, options);
3384
+ const rpcDone = performance.now();
3385
+ const rpcMs = (rpcDone - callStart).toFixed(1);
3274
3386
  // 检查业务层错误码,必要时清理缓存
3275
3387
  checkBizErrorAndClearCache(result, category);
3388
+ // 对 get_msg_media 响应进行 base64 拦截:解码存本地,只返回路径给大模型
3389
+ if (method === "get_msg_media") {
3390
+ const intercepted = await interceptMediaResponse(result);
3391
+ const totalMs = (performance.now() - callStart).toFixed(1);
3392
+ console.log(`[mcp] handleCall ${category}/${method} 总耗时: ${totalMs}ms` +
3393
+ ` (MCP请求: ${rpcMs}ms, 拦截处理: ${(performance.now() - rpcDone).toFixed(1)}ms)`);
3394
+ return intercepted;
3395
+ }
3396
+ console.log(`[mcp] handleCall ${category}/${method} 耗时: ${rpcMs}ms`);
3276
3397
  return result;
3277
3398
  };
3278
3399
  // ============================================================================