@wecom/wecom-openclaw-plugin 2026.4.2201 → 2026.5.7
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/src/mcp/interceptors/index.js +2 -0
- package/dist/src/mcp/interceptors/smartsheet-upload.d.ts +22 -0
- package/dist/src/mcp/interceptors/smartsheet-upload.js +263 -0
- package/dist/src/mcp/transport.js +26 -2
- package/dist/src/media-handler.d.ts +18 -0
- package/dist/src/media-handler.js +52 -0
- package/dist/src/message-parser.d.ts +8 -0
- package/dist/src/message-parser.js +14 -0
- package/dist/src/monitor.js +117 -45
- package/dist/src/state-manager.js +59 -32
- package/package.json +10 -1
- package/skills/wecom-doc-manager/SKILL.md +1 -0
- package/skills/wecom-smartsheet-data/SKILL.md +21 -2
- package/skills/wecom-smartsheet-data/references/cell-value-formats.md +13 -2
- package/skills/wecom-smartsheet-data/references/webhook-examples.md +185 -0
- package/skills/wecom-smartsheet-data/references/webhook-fallback.md +184 -0
- package/skills/wecom-smartsheet-schema/references/field-types.md +1 -0
|
@@ -13,6 +13,7 @@ import { docAuthErrorInterceptor } from "./doc-auth-error.js";
|
|
|
13
13
|
import { mediaInterceptor } from "./msg-media.js";
|
|
14
14
|
import { smartpageCreateInterceptor } from "./smartpage-create.js";
|
|
15
15
|
import { smartpageExportInterceptor } from "./smartpage-export.js";
|
|
16
|
+
import { smartsheetUploadInterceptor } from "./smartsheet-upload.js";
|
|
16
17
|
// ============================================================================
|
|
17
18
|
// 拦截器注册表(按注册顺序执行)
|
|
18
19
|
// ============================================================================
|
|
@@ -22,6 +23,7 @@ const interceptors = [
|
|
|
22
23
|
mediaInterceptor, // get_msg_media base64 拦截
|
|
23
24
|
smartpageCreateInterceptor, // smartpage_create 本地文件读取
|
|
24
25
|
smartpageExportInterceptor, // smartpage_get_export_result content → 本地文件
|
|
26
|
+
smartsheetUploadInterceptor, // smartsheet_add_records / update_records 本地文件上传
|
|
25
27
|
];
|
|
26
28
|
/**
|
|
27
29
|
* 收集匹配的 beforeCall 配置,合并后返回
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* smartsheet_add_records / smartsheet_update_records 本地文件上传拦截器
|
|
3
|
+
*
|
|
4
|
+
* 核心逻辑:
|
|
5
|
+
* 大模型在调用 smartsheet_add_records / smartsheet_update_records 时,
|
|
6
|
+
* 可在 CellImageValue 中传入 image_path(本地图片路径)代替 image_url/title,
|
|
7
|
+
* 或在 CellAttachmentValue 中传入 file_path(本地文件路径)代替 file_id。
|
|
8
|
+
*
|
|
9
|
+
* 本拦截器在 beforeCall 阶段:
|
|
10
|
+
* 1. 深度扫描 records[].values 中的所有字段值,收集含 image_path / file_path 的单元格
|
|
11
|
+
* 2. 校验文件大小:单文件不超过 10MB,所有文件总计不超过 20MB
|
|
12
|
+
* 3. 并行读取本地文件 → base64 编码 → 调用 MCP 上传接口
|
|
13
|
+
* - image_path → upload_doc_image → 获取 image_url
|
|
14
|
+
* - file_path → upload_doc_file → 获取 file_id
|
|
15
|
+
* 4. 用上传结果替换原字段,移除 image_path / file_path
|
|
16
|
+
* 5. 返回修改后的完整 args
|
|
17
|
+
*
|
|
18
|
+
* 传给 MCP Server 的始终是标准协议格式(image_url / file_id),
|
|
19
|
+
* MCP Server 不需要感知 image_path / file_path 的存在。
|
|
20
|
+
*/
|
|
21
|
+
import type { CallInterceptor } from "./types.js";
|
|
22
|
+
export declare const smartsheetUploadInterceptor: CallInterceptor;
|
|
@@ -0,0 +1,263 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* smartsheet_add_records / smartsheet_update_records 本地文件上传拦截器
|
|
3
|
+
*
|
|
4
|
+
* 核心逻辑:
|
|
5
|
+
* 大模型在调用 smartsheet_add_records / smartsheet_update_records 时,
|
|
6
|
+
* 可在 CellImageValue 中传入 image_path(本地图片路径)代替 image_url/title,
|
|
7
|
+
* 或在 CellAttachmentValue 中传入 file_path(本地文件路径)代替 file_id。
|
|
8
|
+
*
|
|
9
|
+
* 本拦截器在 beforeCall 阶段:
|
|
10
|
+
* 1. 深度扫描 records[].values 中的所有字段值,收集含 image_path / file_path 的单元格
|
|
11
|
+
* 2. 校验文件大小:单文件不超过 10MB,所有文件总计不超过 20MB
|
|
12
|
+
* 3. 并行读取本地文件 → base64 编码 → 调用 MCP 上传接口
|
|
13
|
+
* - image_path → upload_doc_image → 获取 image_url
|
|
14
|
+
* - file_path → upload_doc_file → 获取 file_id
|
|
15
|
+
* 4. 用上传结果替换原字段,移除 image_path / file_path
|
|
16
|
+
* 5. 返回修改后的完整 args
|
|
17
|
+
*
|
|
18
|
+
* 传给 MCP Server 的始终是标准协议格式(image_url / file_id),
|
|
19
|
+
* MCP Server 不需要感知 image_path / file_path 的存在。
|
|
20
|
+
*/
|
|
21
|
+
import * as fs from "node:fs/promises";
|
|
22
|
+
import * as path from "node:path";
|
|
23
|
+
import { sendJsonRpc } from "../transport.js";
|
|
24
|
+
// ============================================================================
|
|
25
|
+
// 常量
|
|
26
|
+
// ============================================================================
|
|
27
|
+
/** 单个文件大小上限:10MB */
|
|
28
|
+
const MAX_SINGLE_FILE_SIZE = 10 * 1024 * 1024;
|
|
29
|
+
/** 所有文件总大小上限:20MB */
|
|
30
|
+
const MAX_TOTAL_FILE_SIZE = 20 * 1024 * 1024;
|
|
31
|
+
/** 上传请求超时时间(毫秒):单次上传最大 10MB base64,给足时间 */
|
|
32
|
+
const UPLOAD_TIMEOUT_MS = 60_000;
|
|
33
|
+
/** beforeCall 整体超时延长到 120s(可能有多个文件并行上传) */
|
|
34
|
+
const INTERCEPTOR_TIMEOUT_MS = 120_000;
|
|
35
|
+
/** 日志前缀 */
|
|
36
|
+
const LOG_TAG = "[mcp] smartsheet-upload:";
|
|
37
|
+
// ============================================================================
|
|
38
|
+
// 内部辅助函数
|
|
39
|
+
// ============================================================================
|
|
40
|
+
/**
|
|
41
|
+
* 深度扫描 records 中所有含 image_path / file_path 的单元格值对象,
|
|
42
|
+
* 返回待上传任务列表。
|
|
43
|
+
*
|
|
44
|
+
* records 结构:
|
|
45
|
+
* [{ values: { "字段A": [{ image_path: "..." }], "字段B": [{ file_path: "..." }] } }]
|
|
46
|
+
*
|
|
47
|
+
* 字段值可能是数组(图片/附件/文本等)或标量(数字/布尔等),
|
|
48
|
+
* 仅对数组中的对象元素进行扫描。
|
|
49
|
+
*/
|
|
50
|
+
function collectUploadTasks(records) {
|
|
51
|
+
const tasks = [];
|
|
52
|
+
for (const record of records) {
|
|
53
|
+
const values = record.values;
|
|
54
|
+
if (!values || typeof values !== "object")
|
|
55
|
+
continue;
|
|
56
|
+
for (const fieldKey of Object.keys(values)) {
|
|
57
|
+
const fieldValue = values[fieldKey];
|
|
58
|
+
if (!Array.isArray(fieldValue))
|
|
59
|
+
continue;
|
|
60
|
+
for (const cellValue of fieldValue) {
|
|
61
|
+
if (!cellValue || typeof cellValue !== "object")
|
|
62
|
+
continue;
|
|
63
|
+
const cell = cellValue;
|
|
64
|
+
if (typeof cell.image_path === "string" && cell.image_path) {
|
|
65
|
+
tasks.push({
|
|
66
|
+
kind: "image",
|
|
67
|
+
filePath: cell.image_path,
|
|
68
|
+
title: typeof cell.title === "string" ? cell.title : undefined,
|
|
69
|
+
cellValue: cell,
|
|
70
|
+
});
|
|
71
|
+
}
|
|
72
|
+
else if (typeof cell.file_path === "string" && cell.file_path) {
|
|
73
|
+
tasks.push({
|
|
74
|
+
kind: "file",
|
|
75
|
+
filePath: cell.file_path,
|
|
76
|
+
cellValue: cell,
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
return tasks;
|
|
83
|
+
}
|
|
84
|
+
/**
|
|
85
|
+
* 校验所有待上传文件的大小
|
|
86
|
+
*
|
|
87
|
+
* - 单文件 > 10MB → 报错
|
|
88
|
+
* - 所有文件累计 > 20MB → 报错
|
|
89
|
+
*/
|
|
90
|
+
async function validateFileSizes(tasks) {
|
|
91
|
+
let totalSize = 0;
|
|
92
|
+
for (let i = 0; i < tasks.length; i++) {
|
|
93
|
+
const task = tasks[i];
|
|
94
|
+
let stat;
|
|
95
|
+
try {
|
|
96
|
+
stat = await fs.stat(task.filePath);
|
|
97
|
+
}
|
|
98
|
+
catch (err) {
|
|
99
|
+
throw new Error(`${LOG_TAG} 无法访问文件 "${task.filePath}": ${err instanceof Error ? err.message : String(err)}`);
|
|
100
|
+
}
|
|
101
|
+
if (!stat.isFile()) {
|
|
102
|
+
throw new Error(`${LOG_TAG} "${task.filePath}" 不是一个文件`);
|
|
103
|
+
}
|
|
104
|
+
if (stat.size > MAX_SINGLE_FILE_SIZE) {
|
|
105
|
+
throw new Error(`${LOG_TAG} 文件 "${task.filePath}" 大小 ${(stat.size / 1024 / 1024).toFixed(1)}MB 超过单文件上限 10MB`);
|
|
106
|
+
}
|
|
107
|
+
totalSize += stat.size;
|
|
108
|
+
if (totalSize > MAX_TOTAL_FILE_SIZE) {
|
|
109
|
+
throw new Error(`${LOG_TAG} 累计文件大小 ${(totalSize / 1024 / 1024).toFixed(1)}MB 超过总上限 20MB(在文件 "${task.filePath}" 处超出)`);
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
if (totalSize > 0) {
|
|
113
|
+
console.log(`${LOG_TAG} 文件大小校验通过,共 ${tasks.length} 个文件,总计 ${(totalSize / 1024 / 1024).toFixed(2)}MB`);
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
/**
|
|
117
|
+
* 执行单个图片上传任务
|
|
118
|
+
*
|
|
119
|
+
* 读取本地文件 → base64 → 调用 upload_doc_image → 替换 cellValue
|
|
120
|
+
*/
|
|
121
|
+
async function executeImageUpload(task, docLocator) {
|
|
122
|
+
const buffer = await fs.readFile(task.filePath);
|
|
123
|
+
const base64Content = buffer.toString("base64");
|
|
124
|
+
const fileName = path.basename(task.filePath);
|
|
125
|
+
console.log(`${LOG_TAG} 上传图片: "${task.filePath}" (${(buffer.length / 1024).toFixed(0)}KB)`);
|
|
126
|
+
const result = await sendJsonRpc("doc", "tools/call", {
|
|
127
|
+
name: "upload_doc_image",
|
|
128
|
+
arguments: {
|
|
129
|
+
...docLocator,
|
|
130
|
+
base64_content: base64Content,
|
|
131
|
+
},
|
|
132
|
+
}, { timeoutMs: UPLOAD_TIMEOUT_MS });
|
|
133
|
+
// 从 MCP result 中提取业务响应
|
|
134
|
+
const bizData = extractBizData(result, "upload_doc_image");
|
|
135
|
+
const imageUrl = bizData.url;
|
|
136
|
+
if (!imageUrl) {
|
|
137
|
+
throw new Error(`${LOG_TAG} upload_doc_image 未返回 url,文件: "${task.filePath}"`);
|
|
138
|
+
}
|
|
139
|
+
// 替换 cellValue:设置 image_url + title,移除 image_path
|
|
140
|
+
task.cellValue.image_url = imageUrl;
|
|
141
|
+
task.cellValue.title = task.title || fileName;
|
|
142
|
+
delete task.cellValue.image_path;
|
|
143
|
+
console.log(`${LOG_TAG} 图片上传成功: "${task.filePath}" → image_url="${imageUrl}"`);
|
|
144
|
+
}
|
|
145
|
+
/**
|
|
146
|
+
* 执行单个文件上传任务
|
|
147
|
+
*
|
|
148
|
+
* 读取本地文件 → base64 → 调用 upload_doc_file → 替换 cellValue
|
|
149
|
+
*/
|
|
150
|
+
async function executeFileUpload(task) {
|
|
151
|
+
const buffer = await fs.readFile(task.filePath);
|
|
152
|
+
const base64Content = buffer.toString("base64");
|
|
153
|
+
const fileName = path.basename(task.filePath);
|
|
154
|
+
console.log(`${LOG_TAG} 上传文件: "${task.filePath}" (${(buffer.length / 1024).toFixed(0)}KB)`);
|
|
155
|
+
const result = await sendJsonRpc("doc", "tools/call", {
|
|
156
|
+
name: "upload_doc_file",
|
|
157
|
+
arguments: {
|
|
158
|
+
file_name: fileName,
|
|
159
|
+
file_base64_content: base64Content,
|
|
160
|
+
},
|
|
161
|
+
}, { timeoutMs: UPLOAD_TIMEOUT_MS });
|
|
162
|
+
// 从 MCP result 中提取业务响应
|
|
163
|
+
const bizData = extractBizData(result, "upload_doc_file");
|
|
164
|
+
const fileId = bizData.fileid;
|
|
165
|
+
if (!fileId) {
|
|
166
|
+
throw new Error(`${LOG_TAG} upload_doc_file 未返回 fileid,文件: "${task.filePath}"`);
|
|
167
|
+
}
|
|
168
|
+
// 替换 cellValue:设置 file_id,移除 file_path
|
|
169
|
+
task.cellValue.file_id = fileId;
|
|
170
|
+
delete task.cellValue.file_path;
|
|
171
|
+
console.log(`${LOG_TAG} 文件上传成功: "${task.filePath}" → file_id="${fileId}"`);
|
|
172
|
+
}
|
|
173
|
+
/**
|
|
174
|
+
* 从 MCP tools/call 返回结构中提取业务 JSON 数据
|
|
175
|
+
*
|
|
176
|
+
* MCP result 格式:{ content: [{ type: "text", text: "{...json...}" }] }
|
|
177
|
+
*/
|
|
178
|
+
function extractBizData(result, interfaceName) {
|
|
179
|
+
const content = result?.content;
|
|
180
|
+
if (!Array.isArray(content)) {
|
|
181
|
+
throw new Error(`${LOG_TAG} ${interfaceName} 响应格式异常:缺少 content 数组`);
|
|
182
|
+
}
|
|
183
|
+
const textItem = content.find((c) => c.type === "text" && typeof c.text === "string");
|
|
184
|
+
if (!textItem) {
|
|
185
|
+
throw new Error(`${LOG_TAG} ${interfaceName} 响应格式异常:content 中无 text 类型条目`);
|
|
186
|
+
}
|
|
187
|
+
let bizData;
|
|
188
|
+
try {
|
|
189
|
+
bizData = JSON.parse(textItem.text);
|
|
190
|
+
}
|
|
191
|
+
catch {
|
|
192
|
+
throw new Error(`${LOG_TAG} ${interfaceName} 响应非 JSON: ${textItem.text.slice(0, 200)}`);
|
|
193
|
+
}
|
|
194
|
+
if (bizData.errcode !== 0) {
|
|
195
|
+
throw new Error(`${LOG_TAG} ${interfaceName} 业务错误: errcode=${bizData.errcode}, errmsg=${bizData.errmsg ?? "unknown"}`);
|
|
196
|
+
}
|
|
197
|
+
return bizData;
|
|
198
|
+
}
|
|
199
|
+
/**
|
|
200
|
+
* 从 args 中提取文档定位参数(docid 或 url),用于 upload_doc_image
|
|
201
|
+
*/
|
|
202
|
+
function extractDocLocator(args) {
|
|
203
|
+
if (typeof args.docid === "string" && args.docid) {
|
|
204
|
+
return { docid: args.docid };
|
|
205
|
+
}
|
|
206
|
+
if (typeof args.url === "string" && args.url) {
|
|
207
|
+
return { url: args.url };
|
|
208
|
+
}
|
|
209
|
+
throw new Error(`${LOG_TAG} args 中缺少 docid 或 url,无法调用 upload_doc_image`);
|
|
210
|
+
}
|
|
211
|
+
// ============================================================================
|
|
212
|
+
// 拦截器实现
|
|
213
|
+
// ============================================================================
|
|
214
|
+
export const smartsheetUploadInterceptor = {
|
|
215
|
+
name: "smartsheet-upload",
|
|
216
|
+
/** 对 doc 品类的 smartsheet_add_records / smartsheet_update_records 生效 */
|
|
217
|
+
match: (ctx) => ctx.category === "doc" &&
|
|
218
|
+
(ctx.method === "smartsheet_add_records" || ctx.method === "smartsheet_update_records"),
|
|
219
|
+
/** 扫描 records 中的 image_path / file_path,上传后替换为 image_url / file_id */
|
|
220
|
+
beforeCall(ctx) {
|
|
221
|
+
const records = ctx.args.records;
|
|
222
|
+
if (!Array.isArray(records) || records.length === 0) {
|
|
223
|
+
return undefined;
|
|
224
|
+
}
|
|
225
|
+
// 收集所有待上传任务
|
|
226
|
+
const tasks = collectUploadTasks(records);
|
|
227
|
+
if (tasks.length === 0) {
|
|
228
|
+
return undefined;
|
|
229
|
+
}
|
|
230
|
+
console.log(`${LOG_TAG} 检测到 ${tasks.length} 个本地文件待上传`);
|
|
231
|
+
// 异步执行上传流程
|
|
232
|
+
return resolveUploads(ctx, tasks);
|
|
233
|
+
},
|
|
234
|
+
};
|
|
235
|
+
/**
|
|
236
|
+
* 执行文件校验和并行上传,返回替换后的 args
|
|
237
|
+
*/
|
|
238
|
+
async function resolveUploads(ctx, tasks) {
|
|
239
|
+
// 阶段 1:文件大小校验
|
|
240
|
+
await validateFileSizes(tasks);
|
|
241
|
+
// 提取文档定位参数(仅图片上传需要)
|
|
242
|
+
const hasImageTasks = tasks.some((t) => t.kind === "image");
|
|
243
|
+
let docLocator = {};
|
|
244
|
+
if (hasImageTasks) {
|
|
245
|
+
docLocator = extractDocLocator(ctx.args);
|
|
246
|
+
}
|
|
247
|
+
// 阶段 2:并行执行所有上传任务
|
|
248
|
+
// 上传完毕后 task.cellValue 已被原地修改(image_path/file_path 已替换为 image_url/file_id)
|
|
249
|
+
const uploadStart = performance.now();
|
|
250
|
+
await Promise.all(tasks.map((task) => {
|
|
251
|
+
if (task.kind === "image") {
|
|
252
|
+
return executeImageUpload(task, docLocator);
|
|
253
|
+
}
|
|
254
|
+
return executeFileUpload(task);
|
|
255
|
+
}));
|
|
256
|
+
const uploadMs = (performance.now() - uploadStart).toFixed(1);
|
|
257
|
+
console.log(`${LOG_TAG} 全部上传完成,共 ${tasks.length} 个文件,耗时 ${uploadMs}ms`);
|
|
258
|
+
// args 中的 records 已被原地修改,返回完整 args + 延长超时
|
|
259
|
+
return {
|
|
260
|
+
args: { ...ctx.args },
|
|
261
|
+
timeoutMs: INTERCEPTOR_TIMEOUT_MS,
|
|
262
|
+
};
|
|
263
|
+
}
|
|
@@ -10,6 +10,7 @@
|
|
|
10
10
|
* - MCP 配置运行时缓存(通过 WSClient 拉取 URL 并缓存在内存中)
|
|
11
11
|
*/
|
|
12
12
|
import { generateReqId } from "@wecom/aibot-node-sdk";
|
|
13
|
+
import { fetch as undiciFetch } from "undici";
|
|
13
14
|
import { DEFAULT_ACCOUNT_ID } from "../openclaw-compat.js";
|
|
14
15
|
import { getWeComWebSocket } from "../state-manager.js";
|
|
15
16
|
import { MCP_GET_CONFIG_CMD, MCP_CONFIG_FETCH_TIMEOUT_MS } from "../const.js";
|
|
@@ -28,6 +29,26 @@ export const MEDIA_DOWNLOAD_TIMEOUT_MS = 120_000;
|
|
|
28
29
|
const LOG_TAG = "[mcp]";
|
|
29
30
|
/** 请求 MCP Server 时透传可信企业微信 userid 的 header 名 */
|
|
30
31
|
export const WECOM_USERID_HEADER = "x-openclaw-wecom-userid";
|
|
32
|
+
/**
|
|
33
|
+
* 构造 MCP 请求使用的 User-Agent
|
|
34
|
+
*
|
|
35
|
+
* 格式:`OpenClawPlugin/<version> <platform>/<arch>`
|
|
36
|
+
* 例如:`OpenClawPlugin/0.1.3 linux/x86_64`
|
|
37
|
+
*
|
|
38
|
+
* 说明:
|
|
39
|
+
* - version: 取自 package.json 的 PLUGIN_VERSION
|
|
40
|
+
* - platform: process.platform(linux / darwin / win32 ...)
|
|
41
|
+
* - arch: 将 Node 的 `x64` 归一化为传统 Unix 风格 `x86_64`,其他架构原样返回
|
|
42
|
+
*/
|
|
43
|
+
function buildUserAgent() {
|
|
44
|
+
const archMap = {
|
|
45
|
+
x64: "x86_64",
|
|
46
|
+
ia32: "i386",
|
|
47
|
+
};
|
|
48
|
+
const arch = archMap[process.arch] ?? process.arch;
|
|
49
|
+
return `OpenClawPlugin/${PLUGIN_VERSION} ${process.platform}/${arch}`;
|
|
50
|
+
}
|
|
51
|
+
const MCP_USER_AGENT = buildUserAgent();
|
|
31
52
|
/**
|
|
32
53
|
* MCP JSON-RPC 错误
|
|
33
54
|
*
|
|
@@ -209,6 +230,7 @@ async function sendRawJsonRpc(url, session, body, timeoutMs = HTTP_REQUEST_TIMEO
|
|
|
209
230
|
const headers = {
|
|
210
231
|
"Content-Type": "application/json",
|
|
211
232
|
Accept: "application/json, text/event-stream",
|
|
233
|
+
"User-Agent": MCP_USER_AGENT,
|
|
212
234
|
};
|
|
213
235
|
// Streamable HTTP:携带会话 ID
|
|
214
236
|
if (session.sessionId) {
|
|
@@ -221,12 +243,14 @@ async function sendRawJsonRpc(url, session, body, timeoutMs = HTTP_REQUEST_TIMEO
|
|
|
221
243
|
console.log(`${LOG_TAG} sendRawJsonRpc → ${body.method} | ${WECOM_USERID_HEADER}: ${headers[WECOM_USERID_HEADER] ?? "(not set)"}`);
|
|
222
244
|
let response;
|
|
223
245
|
try {
|
|
224
|
-
|
|
246
|
+
// 使用 undici 提供的 fetch,规避 Node 18.0–18.17 原生 fetch 无法自定义 User-Agent 的 bug;
|
|
247
|
+
// undici 是项目已有依赖,且在所有支持的 Node 版本上行为一致。
|
|
248
|
+
response = (await undiciFetch(url, {
|
|
225
249
|
method: "POST",
|
|
226
250
|
headers,
|
|
227
251
|
body: JSON.stringify(body),
|
|
228
252
|
signal: controller.signal,
|
|
229
|
-
});
|
|
253
|
+
}));
|
|
230
254
|
}
|
|
231
255
|
catch (err) {
|
|
232
256
|
if (err instanceof DOMException && err.name === "AbortError") {
|
|
@@ -7,6 +7,24 @@ import type { OpenClawConfig } from "openclaw/plugin-sdk/core";
|
|
|
7
7
|
import type { RuntimeEnv } from "openclaw/plugin-sdk/runtime-env";
|
|
8
8
|
import type { WSClient } from "@wecom/aibot-node-sdk";
|
|
9
9
|
import type { ResolvedWeComAccount } from "./utils.js";
|
|
10
|
+
/**
|
|
11
|
+
* 附件超过 OpenClaw 配置的 `agents.defaults.mediaMaxMb` 上限时抛出。
|
|
12
|
+
*
|
|
13
|
+
* 本错误由插件层主动判定并抛出,不依赖 OpenClaw 核心层错误消息的字符串匹配,
|
|
14
|
+
* 上层(monitor)可通过 `instanceof MediaOversizeError` 精确识别并向用户提示。
|
|
15
|
+
*/
|
|
16
|
+
export declare class MediaOversizeError extends Error {
|
|
17
|
+
readonly kind: "image" | "file";
|
|
18
|
+
readonly filename?: string;
|
|
19
|
+
readonly sizeBytes: number;
|
|
20
|
+
readonly maxBytes: number;
|
|
21
|
+
constructor(params: {
|
|
22
|
+
kind: "image" | "file";
|
|
23
|
+
filename?: string;
|
|
24
|
+
sizeBytes: number;
|
|
25
|
+
maxBytes: number;
|
|
26
|
+
});
|
|
27
|
+
}
|
|
10
28
|
/**
|
|
11
29
|
* 下载并保存所有图片到本地,每张图片的下载带超时保护
|
|
12
30
|
*/
|
|
@@ -8,6 +8,30 @@ import { getWeComRuntime } from "./runtime.js";
|
|
|
8
8
|
import { IMAGE_DOWNLOAD_TIMEOUT_MS, FILE_DOWNLOAD_TIMEOUT_MS, DEFAULT_MEDIA_MAX_MB } from "./const.js";
|
|
9
9
|
import { withTimeout } from "./timeout.js";
|
|
10
10
|
// ============================================================================
|
|
11
|
+
// 媒体超限错误
|
|
12
|
+
// ============================================================================
|
|
13
|
+
/**
|
|
14
|
+
* 附件超过 OpenClaw 配置的 `agents.defaults.mediaMaxMb` 上限时抛出。
|
|
15
|
+
*
|
|
16
|
+
* 本错误由插件层主动判定并抛出,不依赖 OpenClaw 核心层错误消息的字符串匹配,
|
|
17
|
+
* 上层(monitor)可通过 `instanceof MediaOversizeError` 精确识别并向用户提示。
|
|
18
|
+
*/
|
|
19
|
+
export class MediaOversizeError extends Error {
|
|
20
|
+
kind;
|
|
21
|
+
filename;
|
|
22
|
+
sizeBytes;
|
|
23
|
+
maxBytes;
|
|
24
|
+
constructor(params) {
|
|
25
|
+
super(`Media oversize: kind=${params.kind}, size=${params.sizeBytes}, max=${params.maxBytes}` +
|
|
26
|
+
(params.filename ? `, filename=${params.filename}` : ""));
|
|
27
|
+
this.name = "MediaOversizeError";
|
|
28
|
+
this.kind = params.kind;
|
|
29
|
+
this.filename = params.filename;
|
|
30
|
+
this.sizeBytes = params.sizeBytes;
|
|
31
|
+
this.maxBytes = params.maxBytes;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
// ============================================================================
|
|
11
35
|
// 图片格式检测辅助函数(基于 file-type 包)
|
|
12
36
|
// ============================================================================
|
|
13
37
|
/**
|
|
@@ -66,11 +90,25 @@ export async function downloadAndSaveImages(params) {
|
|
|
66
90
|
runtime.log?.(`[wecom] WARN: Downloaded data is not a valid image format`);
|
|
67
91
|
}
|
|
68
92
|
}
|
|
93
|
+
// 大小校验由插件层主动进行,超限抛出 MediaOversizeError,由 monitor 统一提示用户。
|
|
94
|
+
// 不再把 maxBytes 传给核心层 saveMediaBuffer,避免重复校验产生无结构化错误。
|
|
95
|
+
if (imageBuffer.length > maxBytes) {
|
|
96
|
+
throw new MediaOversizeError({
|
|
97
|
+
kind: "image",
|
|
98
|
+
filename: originalFilename,
|
|
99
|
+
sizeBytes: imageBuffer.length,
|
|
100
|
+
maxBytes,
|
|
101
|
+
});
|
|
102
|
+
}
|
|
69
103
|
const saved = await core.channel.media.saveMediaBuffer(imageBuffer, imageContentType, "inbound", maxBytes, originalFilename);
|
|
70
104
|
mediaList.push({ path: saved.path, contentType: saved.contentType });
|
|
71
105
|
runtime.log?.(`[wecom][plugin] Image saved: path=${saved.path}, contentType=${saved.contentType}`);
|
|
72
106
|
}
|
|
73
107
|
catch (err) {
|
|
108
|
+
// 媒体超限错误需要上抛给 monitor,用于向用户发送明确的提示文案。
|
|
109
|
+
if (err instanceof MediaOversizeError) {
|
|
110
|
+
throw err;
|
|
111
|
+
}
|
|
74
112
|
runtime.error?.(`[wecom] Failed to download image: ${String(err)}`);
|
|
75
113
|
}
|
|
76
114
|
}
|
|
@@ -110,11 +148,25 @@ export async function downloadAndSaveFiles(params) {
|
|
|
110
148
|
fileBuffer = fetched.buffer;
|
|
111
149
|
fileContentType = fetched.contentType ?? "application/octet-stream";
|
|
112
150
|
}
|
|
151
|
+
// 大小校验由插件层主动进行,超限抛出 MediaOversizeError,由 monitor 统一提示用户。
|
|
152
|
+
// 不再把 maxBytes 传给核心层 saveMediaBuffer,避免重复校验产生无结构化错误。
|
|
153
|
+
if (fileBuffer.length > maxBytes) {
|
|
154
|
+
throw new MediaOversizeError({
|
|
155
|
+
kind: "file",
|
|
156
|
+
filename: originalFilename,
|
|
157
|
+
sizeBytes: fileBuffer.length,
|
|
158
|
+
maxBytes,
|
|
159
|
+
});
|
|
160
|
+
}
|
|
113
161
|
const saved = await core.channel.media.saveMediaBuffer(fileBuffer, fileContentType, "inbound", maxBytes, originalFilename);
|
|
114
162
|
mediaList.push({ path: saved.path, contentType: saved.contentType });
|
|
115
163
|
runtime.log?.(`[wecom][plugin] File saved: path=${saved.path}, contentType=${saved.contentType}`);
|
|
116
164
|
}
|
|
117
165
|
catch (err) {
|
|
166
|
+
// 媒体超限错误需要上抛给 monitor,用于向用户发送明确的提示文案。
|
|
167
|
+
if (err instanceof MediaOversizeError) {
|
|
168
|
+
throw err;
|
|
169
|
+
}
|
|
118
170
|
runtime.error?.(`[wecom] Failed to download file: ${String(err)}`);
|
|
119
171
|
}
|
|
120
172
|
}
|
|
@@ -42,6 +42,10 @@ export interface MessageBody {
|
|
|
42
42
|
url?: string;
|
|
43
43
|
aeskey?: string;
|
|
44
44
|
};
|
|
45
|
+
video?: {
|
|
46
|
+
url?: string;
|
|
47
|
+
aeskey?: string;
|
|
48
|
+
};
|
|
45
49
|
quote?: {
|
|
46
50
|
msgtype: string;
|
|
47
51
|
text?: {
|
|
@@ -58,6 +62,10 @@ export interface MessageBody {
|
|
|
58
62
|
url?: string;
|
|
59
63
|
aeskey?: string;
|
|
60
64
|
};
|
|
65
|
+
video?: {
|
|
66
|
+
url?: string;
|
|
67
|
+
aeskey?: string;
|
|
68
|
+
};
|
|
61
69
|
};
|
|
62
70
|
event?: {
|
|
63
71
|
eventtype?: string;
|
|
@@ -175,6 +175,13 @@ export function parseMessageContent(body) {
|
|
|
175
175
|
fileAesKeys.set(body.file.url, body.file.aeskey);
|
|
176
176
|
}
|
|
177
177
|
}
|
|
178
|
+
// 处理视频消息(沿用 file 下载/解密通路,作为文件附件透传)
|
|
179
|
+
if (body.msgtype === "video" && body.video?.url) {
|
|
180
|
+
fileUrls.push(body.video.url);
|
|
181
|
+
if (body.video.aeskey) {
|
|
182
|
+
fileAesKeys.set(body.video.url, body.video.aeskey);
|
|
183
|
+
}
|
|
184
|
+
}
|
|
178
185
|
}
|
|
179
186
|
// 处理引用消息
|
|
180
187
|
if (body.quote) {
|
|
@@ -198,6 +205,13 @@ export function parseMessageContent(body) {
|
|
|
198
205
|
fileAesKeys.set(body.quote.file.url, body.quote.file.aeskey);
|
|
199
206
|
}
|
|
200
207
|
}
|
|
208
|
+
else if (body.quote.msgtype === "video" && body.quote.video?.url) {
|
|
209
|
+
// 引用的视频消息:沿用文件下载通路
|
|
210
|
+
fileUrls.push(body.quote.video.url);
|
|
211
|
+
if (body.quote.video.aeskey) {
|
|
212
|
+
fileAesKeys.set(body.quote.video.url, body.quote.video.aeskey);
|
|
213
|
+
}
|
|
214
|
+
}
|
|
201
215
|
}
|
|
202
216
|
return { textParts, imageUrls, imageAesKeys, fileUrls, fileAesKeys, quoteContent };
|
|
203
217
|
}
|