@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.cjs.js +129 -52
- package/dist/index.cjs.js.map +1 -1
- package/dist/index.esm.js +129 -52
- 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 +35 -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 +1 -3
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
|
// ============================================================================
|
|
@@ -3363,51 +3484,7 @@ const plugin = {
|
|
|
3363
3484
|
}
|
|
3364
3485
|
return {
|
|
3365
3486
|
appendSystemContext: [
|
|
3366
|
-
"
|
|
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
|
});
|