@wecom/wecom-openclaw-plugin 2026.3.20-beta.2 → 2026.3.20-beta.6
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 +128 -7
- package/dist/index.cjs.js.map +1 -1
- package/dist/index.esm.js +128 -7
- package/dist/index.esm.js.map +1 -1
- package/dist/src/mcp/transport.d.ts +9 -1
- package/package.json +1 -1
- package/skills/wecom-msg/SKILL.md +40 -4
- package/skills/wecom-msg/references/api-get-messages.md +37 -12
- package/skills/wecom-msg/references/api-get-msg-media.md +44 -0
- package/skills/wecom-send-media/SKILL.md +9 -10
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(),
|
|
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 请求超时 (${
|
|
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
|
// ============================================================================
|