@wecom/wecom-openclaw-plugin 2026.4.27 → 2026.4.28-beta.0

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.
@@ -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
+ }
@@ -28,6 +28,26 @@ export const MEDIA_DOWNLOAD_TIMEOUT_MS = 120_000;
28
28
  const LOG_TAG = "[mcp]";
29
29
  /** 请求 MCP Server 时透传可信企业微信 userid 的 header 名 */
30
30
  export const WECOM_USERID_HEADER = "x-openclaw-wecom-userid";
31
+ /**
32
+ * 构造 MCP 请求使用的 User-Agent
33
+ *
34
+ * 格式:`OpenClawPlugin/<version> <platform>/<arch>`
35
+ * 例如:`OpenClawPlugin/0.1.3 linux/x86_64`
36
+ *
37
+ * 说明:
38
+ * - version: 取自 package.json 的 PLUGIN_VERSION
39
+ * - platform: process.platform(linux / darwin / win32 ...)
40
+ * - arch: 将 Node 的 `x64` 归一化为传统 Unix 风格 `x86_64`,其他架构原样返回
41
+ */
42
+ function buildUserAgent() {
43
+ const archMap = {
44
+ x64: "x86_64",
45
+ ia32: "i386",
46
+ };
47
+ const arch = archMap[process.arch] ?? process.arch;
48
+ return `OpenClawPlugin/${PLUGIN_VERSION} ${process.platform}/${arch}`;
49
+ }
50
+ const MCP_USER_AGENT = buildUserAgent();
31
51
  /**
32
52
  * MCP JSON-RPC 错误
33
53
  *
@@ -209,6 +229,7 @@ async function sendRawJsonRpc(url, session, body, timeoutMs = HTTP_REQUEST_TIMEO
209
229
  const headers = {
210
230
  "Content-Type": "application/json",
211
231
  Accept: "application/json, text/event-stream",
232
+ "User-Agent": MCP_USER_AGENT,
212
233
  };
213
234
  // Streamable HTTP:携带会话 ID
214
235
  if (session.sessionId) {
@@ -7,6 +7,34 @@
7
7
  import { createPersistentReqIdStore } from "./reqid-store.js";
8
8
  import { MESSAGE_STATE_TTL_MS, MESSAGE_STATE_CLEANUP_INTERVAL_MS, MESSAGE_STATE_MAX_SIZE, GLOBAL_WS_CLIENT_KEY, } from "./const.js";
9
9
  // ============================================================================
10
+ // 全局单例状态(通过 globalThis + Symbol.for 确保跨 jiti loader 实例共享)
11
+ //
12
+ // 问题背景:openclaw 框架在不同阶段(主加载、configSchema 加载等)会创建
13
+ // 多个独立的 jiti loader 实例,每个实例有独立的模块缓存(parentCache),
14
+ // 导致本模块被多次实例化,模块级变量不共享。
15
+ // 使用 globalThis + Symbol.for 可以确保同一进程内只有一份状态。
16
+ // ============================================================================
17
+ /** 全局共享状态的唯一键 */
18
+ const SHARED_STATE_KEY = Symbol.for("wecom-openclaw-plugin:shared-state:v1");
19
+ /**
20
+ * 获取或创建全局共享状态(进程内单例)
21
+ */
22
+ function getSharedState() {
23
+ const existing = globalThis[SHARED_STATE_KEY];
24
+ if (existing)
25
+ return existing;
26
+ const state = {
27
+ wsClientInstances: new Map(),
28
+ messageStates: new Map(),
29
+ cleanupTimer: null,
30
+ reqIdStores: new Map(),
31
+ };
32
+ globalThis[SHARED_STATE_KEY] = state;
33
+ return state;
34
+ }
35
+ /** 进程内唯一的共享状态 */
36
+ const shared = getSharedState();
37
+ // ============================================================================
10
38
  // WSClient 实例管理
11
39
  // ============================================================================
12
40
  /** WSClient 实例管理 */
@@ -16,45 +44,45 @@ const wsClientInstances = (globalThis[GLOBAL_WS_CLIENT_KEY]
16
44
  * 获取指定账户的 WSClient 实例
17
45
  */
18
46
  export function getWeComWebSocket(accountId) {
19
- return wsClientInstances.get(accountId) ?? null;
47
+ return shared.wsClientInstances.get(accountId) ?? null;
20
48
  }
21
49
  /**
22
50
  * 设置指定账户的 WSClient 实例
23
51
  */
24
52
  export function setWeComWebSocket(accountId, client) {
25
- wsClientInstances.set(accountId, client);
53
+ shared.wsClientInstances.set(accountId, client);
26
54
  }
27
55
  /**
28
56
  * 删除指定账户的 WSClient 实例
29
57
  */
30
58
  export function deleteWeComWebSocket(accountId) {
31
- wsClientInstances.delete(accountId);
59
+ shared.wsClientInstances.delete(accountId);
32
60
  }
33
- /** 消息状态管理 */
34
- const messageStates = new Map();
35
- /** 定期清理定时器 */
36
- let cleanupTimer = null;
61
+ // ============================================================================
62
+ // 消息状态管理(带 TTL 清理,防止内存泄漏)
63
+ // ============================================================================
64
+ // MessageStateEntry 接口已在上方 SharedState 定义处声明
37
65
  /**
38
66
  * 启动消息状态定期清理(自动 TTL 清理 + 容量限制)
39
67
  */
40
68
  export function startMessageStateCleanup() {
41
- if (cleanupTimer)
69
+ if (shared.cleanupTimer)
42
70
  return;
43
- cleanupTimer = setInterval(() => {
71
+ shared.cleanupTimer = setInterval(() => {
44
72
  pruneMessageStates();
45
73
  }, MESSAGE_STATE_CLEANUP_INTERVAL_MS);
46
74
  // 允许进程退出时不阻塞
47
- if (cleanupTimer && typeof cleanupTimer === "object" && "unref" in cleanupTimer) {
48
- cleanupTimer.unref();
75
+ if (shared.cleanupTimer && typeof shared.cleanupTimer === "object" && "unref" in shared.cleanupTimer) {
76
+ shared.cleanupTimer.unref();
49
77
  }
50
78
  }
51
79
  /**
52
80
  * 停止消息状态定期清理
53
81
  */
54
82
  export function stopMessageStateCleanup() {
55
- if (cleanupTimer) {
56
- clearInterval(cleanupTimer);
57
- cleanupTimer = null;
83
+ if (shared.cleanupTimer) {
84
+ clearInterval(shared.cleanupTimer);
85
+ shared.cleanupTimer = null;
58
86
  }
59
87
  }
60
88
  /**
@@ -63,17 +91,17 @@ export function stopMessageStateCleanup() {
63
91
  function pruneMessageStates() {
64
92
  const now = Date.now();
65
93
  // 1. 清理过期条目
66
- for (const [key, entry] of messageStates) {
94
+ for (const [key, entry] of shared.messageStates) {
67
95
  if (now - entry.createdAt >= MESSAGE_STATE_TTL_MS) {
68
- messageStates.delete(key);
96
+ shared.messageStates.delete(key);
69
97
  }
70
98
  }
71
99
  // 2. 容量限制:如果仍超过最大条目数,按时间淘汰最旧的
72
- if (messageStates.size > MESSAGE_STATE_MAX_SIZE) {
73
- const sorted = [...messageStates.entries()].sort((a, b) => a[1].createdAt - b[1].createdAt);
74
- const toRemove = sorted.slice(0, messageStates.size - MESSAGE_STATE_MAX_SIZE);
100
+ if (shared.messageStates.size > MESSAGE_STATE_MAX_SIZE) {
101
+ const sorted = [...shared.messageStates.entries()].sort((a, b) => a[1].createdAt - b[1].createdAt);
102
+ const toRemove = sorted.slice(0, shared.messageStates.size - MESSAGE_STATE_MAX_SIZE);
75
103
  for (const [key] of toRemove) {
76
- messageStates.delete(key);
104
+ shared.messageStates.delete(key);
77
105
  }
78
106
  }
79
107
  }
@@ -81,7 +109,7 @@ function pruneMessageStates() {
81
109
  * 设置消息状态
82
110
  */
83
111
  export function setMessageState(messageId, state) {
84
- messageStates.set(messageId, {
112
+ shared.messageStates.set(messageId, {
85
113
  state,
86
114
  createdAt: Date.now(),
87
115
  });
@@ -90,12 +118,12 @@ export function setMessageState(messageId, state) {
90
118
  * 获取消息状态
91
119
  */
92
120
  export function getMessageState(messageId) {
93
- const entry = messageStates.get(messageId);
121
+ const entry = shared.messageStates.get(messageId);
94
122
  if (!entry)
95
123
  return undefined;
96
124
  // 检查 TTL
97
125
  if (Date.now() - entry.createdAt >= MESSAGE_STATE_TTL_MS) {
98
- messageStates.delete(messageId);
126
+ shared.messageStates.delete(messageId);
99
127
  return undefined;
100
128
  }
101
129
  return entry.state;
@@ -104,13 +132,13 @@ export function getMessageState(messageId) {
104
132
  * 删除消息状态
105
133
  */
106
134
  export function deleteMessageState(messageId) {
107
- messageStates.delete(messageId);
135
+ shared.messageStates.delete(messageId);
108
136
  }
109
137
  /**
110
138
  * 清空所有消息状态
111
139
  */
112
140
  export function clearAllMessageStates() {
113
- messageStates.clear();
141
+ shared.messageStates.clear();
114
142
  }
115
143
  // ============================================================================
116
144
  // ReqId 持久化存储管理(按 accountId 隔离)
@@ -120,12 +148,11 @@ export function clearAllMessageStates() {
120
148
  * 参考 createPersistentDedupe 模式:内存 + 磁盘双层、文件锁、原子写入、TTL 过期、防抖写入
121
149
  * 重启后可从磁盘恢复,确保主动推送消息时能获取到 reqId
122
150
  */
123
- const reqIdStores = new Map();
124
151
  function getOrCreateReqIdStore(accountId) {
125
- let store = reqIdStores.get(accountId);
152
+ let store = shared.reqIdStores.get(accountId);
126
153
  if (!store) {
127
154
  store = createPersistentReqIdStore(accountId);
128
- reqIdStores.set(accountId, store);
155
+ shared.reqIdStores.set(accountId, store);
129
156
  }
130
157
  return store;
131
158
  }
@@ -215,7 +242,7 @@ export function deleteSessionChatInfo(sessionKey) {
215
242
  */
216
243
  export async function cleanupAccount(accountId) {
217
244
  // 1. 断开 WSClient
218
- const wsClient = wsClientInstances.get(accountId);
245
+ const wsClient = shared.wsClientInstances.get(accountId);
219
246
  if (wsClient) {
220
247
  try {
221
248
  wsClient.disconnect();
@@ -223,7 +250,7 @@ export async function cleanupAccount(accountId) {
223
250
  catch {
224
251
  // 忽略断开连接时的错误
225
252
  }
226
- wsClientInstances.delete(accountId);
253
+ shared.wsClientInstances.delete(accountId);
227
254
  }
228
255
  // 2. 由于移除了磁盘存储,不再需要 flush reqId 存储
229
256
  // 注意:不删除 store,因为重连后可能还需要
@@ -235,7 +262,7 @@ export async function cleanupAll() {
235
262
  // 停止定期清理
236
263
  stopMessageStateCleanup();
237
264
  // 清理所有 WSClient
238
- for (const [accountId, wsClient] of wsClientInstances) {
265
+ for (const [accountId, wsClient] of shared.wsClientInstances) {
239
266
  try {
240
267
  wsClient.disconnect();
241
268
  }
@@ -243,7 +270,7 @@ export async function cleanupAll() {
243
270
  // 忽略
244
271
  }
245
272
  }
246
- wsClientInstances.clear();
273
+ shared.wsClientInstances.clear();
247
274
  // 由于移除了磁盘存储,不再需要 flush 所有 reqId 存储
248
275
  // 清空消息状态
249
276
  clearAllMessageStates();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wecom/wecom-openclaw-plugin",
3
- "version": "2026.4.27",
3
+ "version": "2026.4.28-beta.0",
4
4
  "type": "module",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",
@@ -41,16 +41,23 @@ description: 企业微信智能表格数据(记录)管理技能。提供智
41
41
 
42
42
  添加一行或多行记录,单次建议 500 行内。
43
43
 
44
- **调用前**必须先了解目标表的字段类型(通过 `smartsheet_get_fields`)。
44
+ **调用前**必须先了解目标表的字段类型(通过 `smartsheet_get_fields`),并查看[单元格值格式参考](references/cell-value-formats.md),了解做法后再实施。
45
45
 
46
46
  使用 `wecom_mcp` tool 调用 `wecom_mcp call doc smartsheet_add_records '{"docid": "DOCID", "sheet_id": "SHEETID", "records": [{"values": {"任务名称": [{"type": "text", "text": "完成需求文档"}], "优先级": [{"text": "高"}]}}]}'`
47
47
 
48
48
  各字段类型的值格式参见 [单元格值格式参考](references/cell-value-formats.md)。
49
49
 
50
+ > ⚠️ **图片(IMAGE)/ 文件(ATTACHMENT)字段**:**必须且只能**直接在 cell value 中传入本地路径(`image_path` / `file_path`),格式见下方示例。**严禁**调用 `upload_doc_image`、`upload_doc_file` 或任何上传接口,**严禁**进行 base64 编码——无论文件大小、无论 API 返回何种错误,均不得绕过此规则。若 `image_path` / `file_path` 方式返回错误,应直接将错误码和错误信息告知用户,**不得**尝试其他上传方式。
51
+ >
52
+ > 图片字段示例:`"封面": [{"image_path": "/path/to/img.png", "title": "图片标题"}]`
53
+ > 文件字段示例:`"文件": [{"file_path": "/path/to/file.pdf"}]`
54
+
50
55
  > ⚠️ 若返回 `errcode: 851003` 或 `errmsg` 包含 `no authority`(通常是企业可见范围 > 10 人的规模限制),切换到 Webhook 兜底方案,详见 [webhook-fallback.md](references/webhook-fallback.md)。
51
56
 
52
57
  ### smartsheet_update_records
53
58
 
59
+ **调用前**必须先了解目标表的字段类型(通过 `smartsheet_get_fields`),并查看[单元格值格式参考](references/cell-value-formats.md),了解做法后再实施。
60
+
54
61
  更新一行或多行记录,单次建议在 500 行内。需提供 record_id(通过 `smartsheet_get_records` 获取)。支持通过 `key_type` 指定 values 的 key 使用字段标题或字段 ID:
55
62
 
56
63
  - `CELL_VALUE_KEY_TYPE_FIELD_TITLE`:key 为字段标题
@@ -60,6 +67,11 @@ description: 企业微信智能表格数据(记录)管理技能。提供智
60
67
 
61
68
  **注意**:创建时间、最后编辑时间、创建人、最后编辑人字段不可更新。
62
69
 
70
+ > ⚠️ **图片(IMAGE)/ 文件(ATTACHMENT)字段**:**必须且只能**直接在 cell value 中传入本地路径(`image_path` / `file_path`),格式见下方示例。**严禁**调用 `upload_doc_image`、`upload_doc_file` 或任何上传接口,**严禁**进行 base64 编码——无论文件大小、无论 API 返回何种错误,均不得绕过此规则。若 `image_path` / `file_path` 方式返回错误,应直接将错误码和错误信息告知用户,**不得**尝试其他上传方式。
71
+ >
72
+ > 图片字段示例:`"封面": [{"image_path": "/path/to/img.png", "title": "图片标题"}]`
73
+ > 文件字段示例:`"文件": [{"file_path": "/path/to/file.pdf"}]`
74
+
63
75
  > ⚠️ 若返回 `errcode: 851003` 或 `errmsg` 包含 `no authority`(通常是企业可见范围 > 10 人的规模限制),切换到 Webhook 兜底方案,详见 [webhook-fallback.md](references/webhook-fallback.md)。注意 Webhook 只能更新通过 Webhook 写入的记录,人工创建的记录无法更新。
64
76
 
65
77
  ### smartsheet_delete_records
@@ -73,7 +85,9 @@ description: 企业微信智能表格数据(记录)管理技能。提供智
73
85
  1. **读取数据** → 使用 `wecom_mcp` tool 调用 `wecom_mcp call doc smartsheet_get_records '{"docid":"DOCID","sheet_id":"SHEETID"}'`
74
86
  2. **写入数据** → 先 `smartsheet_get_fields` 了解列类型 → 若涉及成员(USER)字段,先通过 `wecom-contact-lookup` 的 `get_userlist` 查找人员 userid → `smartsheet_add_records` 写入
75
87
  3. **更新数据** → 先 `smartsheet_get_records` 获取 record_id → 若涉及成员(USER)字段,先通过 `wecom-contact-lookup` 的 `get_userlist` 查找人员 userid → `smartsheet_update_records` 更新
76
- 4. **删除数据** → 先 `smartsheet_get_records` 确认 record_id → `smartsheet_delete_records` 删除
77
- 5. **写入失败 fallback** → 第 2/3 步返回 `errcode: 851003` / `no authority`(通常是企业可见范围 > 10 人的规模限制)时 → 请用户临时提供目标表的 Webhook 地址 + schema 示例 JSON(不保存到本地)→ 按 [webhook-fallback.md](references/webhook-fallback.md) 构造请求体发送
88
+ 4. **写入失败 fallback** → 第 2/3 步返回 `errcode: 851003` / `no authority`(通常是企业可见范围 > 10 人的规模限制)时 → 请用户临时提供目标表的 Webhook 地址 + schema 示例 JSON(不保存到本地)→ 按 [webhook-fallback.md](references/webhook-fallback.md) 构造请求体发送
89
+ 5. 若涉及**图片(IMAGE)或文件(ATTACHMENT)**字段,**必须且只能**直接在 cell value 中传入本地路径(`image_path` / `file_path`)。**严禁**调用 `upload_doc_image`、`upload_doc_file` 或任何上传接口,**严禁**进行 base64 编码——无论文件大小、无论 API 返回何种错误,均不得绕过此规则。若返回错误,直接将错误码和错误信息告知用户,不得尝试其他上传方式。传入格式参考 [单元格值格式参考](references/cell-value-formats.md)。
90
+ 6. **删除数据** → 先 `smartsheet_get_records` 确认 record_id → `smartsheet_delete_records` 删除
78
91
 
79
92
  > **注意**:成员(USER)类型字段需要填写 `user_id`,不能直接使用姓名。必须先通过 `wecom-contact-lookup` 技能的 `get_userlist` 接口按姓名查找到对应的 `userid` 后再使用。
93
+ > **注意**:图片(IMAGE)或文件(ATTACHMENT)字段**必须且只能**直接在 cell value 中传入本地路径(`image_path` / `file_path`)。**严禁**调用 `upload_doc_image`、`upload_doc_file` 或任何上传接口,**严禁**进行 base64 编码——无论文件大小、无论 API 返回何种错误,均不得绕过此规则。若返回错误,直接将错误码和错误信息告知用户,不得尝试其他上传方式。格式参考 [单元格值格式参考](references/cell-value-formats.md)。
@@ -118,12 +118,15 @@
118
118
 
119
119
  ### 9. 图片 (IMAGE)
120
120
 
121
- 数组格式:
121
+ 数组格式,**必须且只能**直接在 cell value 中传入本地路径(`image_path`)。**严禁**调用 `upload_doc_image`、`upload_doc_file` 或任何上传接口,**严禁**进行 base64 编码——无论文件大小、无论 API 返回何种错误,均不得绕过此规则。若 `image_path` 方式返回错误,应直接将错误码和错误信息告知用户,**不得**尝试其他上传方式。
122
+
123
+ #### 9.1. 图片本地路径传入(唯一合法方式)
122
124
 
123
125
  ```json
124
- "封面": [{"image_url": "https://example.com/img.png"}]
126
+ "封面": [{"image_path": "/path/to/img.png", "title": "图片标题"}]
125
127
  ```
126
128
 
129
+
127
130
  ### 10. 地理位置 (LOCATION)
128
131
 
129
132
  数组格式:
@@ -132,6 +135,14 @@
132
135
  "地点": [{"source_type": 1, "id": "地点ID", "latitude": "39.9", "longitude": "116.3", "title": "北京"}]
133
136
  ```
134
137
 
138
+ ### 11. 文件(Attachment)
139
+
140
+ 数组格式,**必须且只能**直接在 cell value 中传入本地路径(`file_path`)。**严禁**调用 `upload_doc_image`、`upload_doc_file` 或任何上传接口,**严禁**进行 base64 编码——无论文件大小、无论 API 返回何种错误,均不得绕过此规则。若 `file_path` 方式返回错误,应直接将错误码和错误信息告知用户,**不得**尝试其他上传方式。
141
+
142
+ ```json
143
+ "文件": [{"file_path": "/path/to/file.pdf"}]
144
+ ```
145
+
135
146
  ## 完整添加记录示例
136
147
 
137
148
  ```json
@@ -20,6 +20,7 @@
20
20
  | `FIELD_TYPE_CURRENCY` | 货币 | 货币金额 |
21
21
  | `FIELD_TYPE_PERCENTAGE` | 百分比 | 比率类数值(完成率、转化率) |
22
22
  | `FIELD_TYPE_BARCODE` | 条码 | 条形码/二维码 |
23
+ | `FIELD_TYPE_ATTACHMENT` | 文件 | 文件/附件 |
23
24
 
24
25
  ## 添加字段示例
25
26