@yanhaidao/wecom 2.3.180 → 2.3.190

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.
@@ -0,0 +1,73 @@
1
+ # OpenClaw WeCom 插件 v2.3.19 变更简报
2
+
3
+ > [!TIP]
4
+ > `v2.3.19` 主要修了四件和真实使用强相关的问题:第一,本机图片/文件终于能更自然地发出去,不再默认只认 OpenClaw 自己目录里的文件;第二,走 Bot WebSocket 时,`dynamicAgents` 也会真正生效,不会再出现“同样开了动态路由,但 WS 会话还是串到主 Agent”这种割裂行为;第三,媒体大小上限开始统一跟随 OpenClaw 标准配置 `mediaMaxMb`,不再是 WeCom 插件自己一套、OpenClaw 主配置又是一套;第四,Bot WS 对话里的图片/文件现在会尽量留在当前 Bot WS 对话里发送,不再静默切到 Agent 私聊链路。
5
+
6
+ ## 2026-03-19(v2.3.19)
7
+ - 【修复 WS 不走 dynamicAgents】**[重要修复]** 之前 `dynamicAgents` 只在旧消息链路上生效,新的 Bot WebSocket 统一运行时没有把这层路由覆盖带进去。结果就是,同样一份配置,在 Webhook 或 Agent 路径上能按“每个用户 / 每个群”隔离会话,但走 WS 时却可能重新落回主 Agent。现在 WS 运行时也会执行同样的动态路由逻辑,单聊、群聊的隔离行为终于统一了。
8
+ - 【修正 Bot WS 对话里的媒体回落策略】**[重要修复]** 如果当前消息本来就在 Bot WS 对话流里,图片/文件发送现在会优先并固定留在同一个 Bot WS 会话内处理。一旦已经尝试过 WS 发送,就不再静默回落到 Agent 私聊链路,避免用户明明在一个 Bot 对话里操作,结果媒体却从另一个 Agent 会话发出来。这个调整只针对 Bot WS,对 Bot Webhook 模式没有影响。
9
+ - 【统一媒体大小配置到 OpenClaw 标准】之前 WeCom 插件内部主要认自己的 `channels.wecom.media.maxBytes`,而不是 OpenClaw 通用的 `mediaMaxMb`。这会带来一个很直接的问题:你明明已经在 OpenClaw 里把媒体大小放宽了,WeCom 这边却还像没看见一样。现在插件会优先读取 `channels.wecom.mediaMaxMb`,并支持 `channels.wecom.accounts.<accountId>.mediaMaxMb` 做账号级覆盖;旧的 `channels.wecom.media.maxBytes` 仍然保留兼容,但只作为历史配置兜底。
10
+ - 【补齐 Bot WS 媒体发送链路的大小透传】Bot WS 在读取本地文件、下载远程媒体并上传到企业微信前,现在会真正带上解析后的 `mediaMaxMb` 上限,而不是继续走内部固定值。换句话说,媒体大小限制终于从“写在配置里”和“运行时真的执行”两边对齐了。
11
+ - 【放宽本地文件默认可发送目录】之前本地媒体白名单更偏向 OpenClaw 自己的状态目录,所以像 `~/Downloads/01.png` 这种很自然的用户文件路径,虽然文件确实存在,也会被当成“不允许发送”。现在插件默认额外放行常见用户目录:`~/Desktop`、`~/Documents`、`~/Downloads`、`~/Movies`、`~/Pictures`。对普通用户来说,这意味着“桌面、下载、图片目录里的文件”现在默认就更符合直觉地可发送了。
12
+ - 【支持继续追加自定义本地媒体目录】除了默认目录外,现在仍然可以通过 `channels.wecom.media.localRoots` 继续追加共享盘、挂载盘、业务导出目录等自定义路径。这里的设计原则很简单:默认给出大多数人会用到的目录,但不假设所有企业的数据都在同一个地方。
13
+ - 【文案与排障提示同步更新】当媒体太大或解密失败时,提示语现在会直接指向 `channels.wecom.mediaMaxMb` 这套推荐配置,避免用户继续被旧的 `media.maxBytes` 误导。
14
+
15
+ ## 这次改动背后的最小理解模型
16
+
17
+ 把这次版本理解成四句话就够了:
18
+
19
+ 1. **会话该隔离的,要真的隔离。**
20
+ 你开了 `dynamicAgents`,系统就应该不管走 Webhook、Agent Callback 还是 Bot WS,都稳定地把“不同用户 / 不同群”落到不同会话上,而不是不同链路表现不一致。
21
+
22
+ 2. **从哪个对话开始,就该尽量在哪个对话里发回去。**
23
+ 如果用户是在 Bot WS 对话里让系统发图,那么结果也应该优先回到这个 Bot WS 对话,而不是悄悄切到 Agent 私聊。否则用户看到的“会话边界”和系统内部实际走的链路会分裂。
24
+
25
+ 3. **配置写在哪里,运行时就该认哪里。**
26
+ OpenClaw 既然已经把媒体大小的标准字段定成 `mediaMaxMb`,WeCom 插件就不该再偷偷主要走自己那套 `media.maxBytes`。统一之后,用户只需要记住一套规则。
27
+
28
+ 4. **用户的文件,默认应该在用户常用目录里。**
29
+ 大多数人发文件,不会先把图拷进 `.openclaw/media`,而是直接从下载目录、桌面、图片目录发。默认路径如果违背这个常识,系统虽然“安全”,但体验是不成立的。
30
+
31
+ ## 推荐配置
32
+
33
+ ```json
34
+ {
35
+ "channels": {
36
+ "wecom": {
37
+ "mediaMaxMb": 50,
38
+ "media": {
39
+ "tempDir": "/tmp/openclaw-wecom-media",
40
+ "localRoots": [
41
+ "/srv/company-share",
42
+ "/data/reports"
43
+ ]
44
+ },
45
+ "dynamicAgents": {
46
+ "enabled": true,
47
+ "dmCreateAgent": true,
48
+ "groupEnabled": true,
49
+ "adminUsers": ["your-admin-userid"]
50
+ }
51
+ }
52
+ }
53
+ }
54
+ ```
55
+
56
+ ## 升级后你应该感受到的变化
57
+
58
+ - 从 `Downloads`、`Desktop` 这类常见目录直接发图、发文件,更容易一次成功。
59
+ - 同样开启 `dynamicAgents` 的前提下,Bot WS 会话也会像其他链路一样稳定隔离,不再更容易串上下文。
60
+ - 如果当前就是 Bot WS 对话流,发图、发文件会优先留在这个对话里处理,不会再更容易“跳”到 Agent 私聊。
61
+ - 调整 `mediaMaxMb` 后,WeCom 插件的上传 / 解密 / 读取链路会真正跟着变化,而不是“配置改了,行为没改”。
62
+
63
+ ## 仍然需要知道的边界
64
+
65
+ - `mediaMaxMb` 控制的是 OpenClaw 这边愿意读取和处理多大的媒体,不代表企业微信协议本身没有限制。
66
+ - 企业微信自身的媒体限制仍然存在,例如图片 / 视频超过 10MB、语音超过 2MB、文件超过 20MB 时,最终行为仍受企业微信接口约束。
67
+ - 旧配置 `channels.wecom.media.maxBytes` 目前还兼容,但新配置建议统一迁移到 `channels.wecom.mediaMaxMb`。
68
+
69
+ ## 升级提示
70
+
71
+ - 执行 `openclaw plugins update wecom` 即可升级到 `v2.3.19`。
72
+ - 如果你之前遇到“WS 开了 dynamicAgents 但还是串到主 Agent”“明明在 Bot WS 对话里发图,结果却从 Agent 私聊发出来”,或“明明改了媒体大小配置却没有生效”,这一版就是针对这些问题的直接修复。
73
+ - 如果你的文件仍然不在默认目录内,例如 NAS、共享盘或业务导出盘,请继续在 `channels.wecom.media.localRoots` 中追加对应路径。
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yanhaidao/wecom",
3
- "version": "2.3.180",
3
+ "version": "2.3.190",
4
4
  "type": "module",
5
5
  "description": "OpenClaw 企业微信(WeCom)插件,默认 Bot WebSocket,支持加密媒体解密、Agent 主动发消息与多账号接入",
6
6
  "repository": {
@@ -481,7 +481,7 @@ async function processAgentMessage(params: {
481
481
  const resolvedContent = resolveEventText();
482
482
  let finalContent = resolvedContent;
483
483
 
484
- const mediaMaxBytes = resolveWecomMediaMaxBytes(config);
484
+ const mediaMaxBytes = resolveWecomMediaMaxBytes(config, agent.accountId);
485
485
 
486
486
  // 处理媒体文件
487
487
  const attachments: NonNullable<UnifiedInboundEvent["attachments"]> = [];
@@ -598,8 +598,8 @@ async function processAgentMessage(params: {
598
598
  content,
599
599
  "",
600
600
  `媒体处理失败:${String(err)}`,
601
- `提示:可在 OpenClaw 配置中提高 channels.wecom.media.maxBytes(当前=${mediaMaxBytes})`,
602
- `例如:openclaw config set channels.wecom.media.maxBytes ${50 * 1024 * 1024}`,
601
+ `提示:可在 OpenClaw 配置中提高 channels.wecom.mediaMaxMb(当前=${Math.round(mediaMaxBytes / (1024 * 1024))}MB)`,
602
+ "例如:openclaw config set channels.wecom.mediaMaxMb 50",
603
603
  ].join("\n");
604
604
  }
605
605
  } else {
package/src/app/index.ts CHANGED
@@ -19,6 +19,7 @@ export type BotWsPushHandle = {
19
19
  mediaUrl: string;
20
20
  text?: string;
21
21
  mediaLocalRoots?: readonly string[];
22
+ maxBytes?: number;
22
23
  }) => Promise<{
23
24
  ok: boolean;
24
25
  messageId?: string;
@@ -154,7 +154,7 @@ export function createBotStreamOrchestrator(params: {
154
154
  let mediaType: string | undefined;
155
155
  if (media) {
156
156
  try {
157
- const maxBytes = resolveWecomMediaMaxBytes(target.config);
157
+ const maxBytes = resolveWecomMediaMaxBytes(target.config, target.account.accountId);
158
158
  const saved = await core.channel.media.saveMediaBuffer(media.buffer, media.contentType, "inbound", maxBytes, media.filename);
159
159
  mediaPath = saved.path;
160
160
  mediaType = saved.contentType;
@@ -196,25 +196,112 @@ export class WecomDocClient {
196
196
  const normalizedFatherId = readString(fatherId);
197
197
  if (normalizedSpaceId) payload.spaceid = normalizedSpaceId;
198
198
  if (normalizedFatherId) payload.fatherid = normalizedFatherId;
199
+
200
+ // admin_users is required for smart_table to ensure proper permissions
199
201
  const normalizedAdminUsers = Array.isArray(adminUsers)
200
202
  ? adminUsers.map((item) => readString(item)).filter(Boolean)
201
203
  : [];
202
204
  if (normalizedAdminUsers.length > 0) {
203
205
  payload.admin_users = normalizedAdminUsers;
204
206
  }
207
+
205
208
  const json = await this.postWecomDocApi({
206
209
  path: "/cgi-bin/wedoc/create_doc",
207
210
  actionLabel: "create_doc",
208
211
  agent,
209
212
  body: payload,
210
213
  });
211
- return {
214
+
215
+ const result = {
212
216
  raw: json,
213
217
  docId: readString(json.docid),
214
218
  url: readString(json.url),
215
219
  docType: normalizedDocType,
216
220
  docTypeLabel: mapDocTypeLabel(normalizedDocType),
217
221
  };
222
+
223
+ // Auto-initialize smart_table: clean up default fields and records
224
+ if (normalizedDocType === 10) {
225
+ await this.initializeSmartTable({ agent, docId: result.docId });
226
+ }
227
+
228
+ return result;
229
+ }
230
+
231
+ /**
232
+ * Initialize smart_table after creation:
233
+ * 1. Get default sheet (smartsheet type)
234
+ * 2. Get default fields (usually 5 fields)
235
+ * 3. Delete 4 default fields, keep 1 as primary key
236
+ * 4. Get default records (usually 5 empty records)
237
+ * 5. Delete all default records
238
+ */
239
+ private async initializeSmartTable(params: { agent: ResolvedAgentAccount; docId: string }) {
240
+ const { agent, docId } = params;
241
+
242
+ try {
243
+ // Step 1: Get sheet list to find the default smartsheet
244
+ const sheetsResult = await this.smartTableGetSheets({ agent, docId });
245
+ const defaultSheet = sheetsResult.sheets.find((s: any) => s.type === "smartsheet");
246
+ if (!defaultSheet) return; // No smartsheet found, skip initialization
247
+
248
+ const sheetId = (defaultSheet as any).sheet_id;
249
+
250
+ // Step 2: Get default fields
251
+ const fieldsResult = await this.smartTableGetFields({ agent, docId, sheetId });
252
+ const fields = fieldsResult.fields || [];
253
+
254
+ if (fields.length > 1) {
255
+ // Keep the last field as primary key, delete the rest
256
+ // Primary key capable types: TEXT, NUMBER, DATE_TIME, URL, PROGRESS, EMAIL, PHONE_NUMBER, FORMULA, LOCATION, CURRENCY, AUTONUMBER, TITLE, WWGROUP
257
+ const primaryKeyCapableTypes = [
258
+ 'FIELD_TYPE_TEXT', 'FIELD_TYPE_NUMBER', 'FIELD_TYPE_DATE_TIME',
259
+ 'FIELD_TYPE_URL', 'FIELD_TYPE_PROGRESS', 'FIELD_TYPE_EMAIL',
260
+ 'FIELD_TYPE_PHONE_NUMBER', 'FIELD_TYPE_LOCATION', 'FIELD_TYPE_CURRENCY',
261
+ 'FIELD_TYPE_AUTONUMBER', 'FIELD_TYPE_WWGROUP'
262
+ ];
263
+
264
+ // Find a field that can be primary key (prefer the last one)
265
+ let fieldToDelete: string[] = [];
266
+ let fieldToKeep: string | null = null;
267
+
268
+ for (let i = fields.length - 1; i >= 0; i--) {
269
+ const field = fields[i] as any;
270
+ if (!fieldToKeep && primaryKeyCapableTypes.includes(field.field_type)) {
271
+ fieldToKeep = field.field_id;
272
+ } else if (field.field_id) {
273
+ fieldToDelete.push(field.field_id);
274
+ }
275
+ }
276
+
277
+ // If no primary key capable field found, keep the last one anyway
278
+ if (!fieldToKeep && fields.length > 0) {
279
+ const lastField = fields[fields.length - 1] as any;
280
+ fieldToKeep = lastField.field_id;
281
+ fieldToDelete = fields.slice(0, -1).map((f: any) => f.field_id);
282
+ }
283
+
284
+ // Delete the fields
285
+ if (fieldToDelete.length > 0) {
286
+ await this.smartTableDelFields({ agent, docId, sheetId, field_ids: fieldToDelete });
287
+ }
288
+ }
289
+
290
+ // Step 3: Get default records
291
+ const recordsResult = await this.smartTableGetRecords({ agent, docId, sheetId, limit: 100 });
292
+ const records = recordsResult.records || [];
293
+
294
+ if (records.length > 0) {
295
+ // Delete all default empty records
296
+ const recordIds = records.map((r: any) => r.record_id).filter(Boolean);
297
+ if (recordIds.length > 0) {
298
+ await this.smartTableDelRecords({ agent, docId, sheetId, record_ids: recordIds });
299
+ }
300
+ }
301
+ } catch (err) {
302
+ // Non-fatal: smart_table created, just default cleanup failed
303
+ console.error(`[WecomDocClient] initializeSmartTable failed:`, err);
304
+ }
218
305
  }
219
306
 
220
307
  async renameDoc(params: { agent: ResolvedAgentAccount; docId: string; newName: string }) {
@@ -1325,8 +1412,9 @@ export class WecomDocClient {
1325
1412
  docId: string;
1326
1413
  sheetId: string;
1327
1414
  fields: any[];
1415
+ autoCleanupDefaultField?: boolean; // Auto-delete leftover default field after adding new fields
1328
1416
  }) {
1329
- const { agent, docId, sheetId, fields } = params;
1417
+ const { agent, docId, sheetId, fields, autoCleanupDefaultField = true } = params;
1330
1418
 
1331
1419
  // Validate fields per official API spec
1332
1420
  if (!Array.isArray(fields) || fields.length === 0) {
@@ -1367,10 +1455,60 @@ export class WecomDocClient {
1367
1455
  fields: fields,
1368
1456
  },
1369
1457
  });
1370
- return {
1458
+
1459
+ const result = {
1371
1460
  raw: json,
1372
1461
  fields: readArray(json.fields),
1373
1462
  };
1463
+
1464
+ // Auto-cleanup: delete leftover default field after successfully adding new fields
1465
+ // This handles the case where initializeSmartTable kept 1 default field
1466
+ if (autoCleanupDefaultField) {
1467
+ await this.cleanupLeftoverDefaultField({ agent, docId, sheetId, newlyAddedFieldCount: result.fields.length });
1468
+ }
1469
+
1470
+ return result;
1471
+ }
1472
+
1473
+ /**
1474
+ * Cleanup leftover default field after adding new fields
1475
+ * When user adds new fields, we can safely delete the leftover default field from initialization
1476
+ */
1477
+ private async cleanupLeftoverDefaultField(params: {
1478
+ agent: ResolvedAgentAccount;
1479
+ docId: string;
1480
+ sheetId: string;
1481
+ newlyAddedFieldCount: number;
1482
+ }) {
1483
+ const { agent, docId, sheetId, newlyAddedFieldCount } = params;
1484
+
1485
+ try {
1486
+ // Get all fields to find the leftover default field
1487
+ const fieldsResult = await this.smartTableGetFields({ agent, docId, sheetId, limit: 100 });
1488
+ const allFields = fieldsResult.fields || [];
1489
+
1490
+ // After adding N new fields to a table with 1 default field, we should have N+1 fields
1491
+ // If total = newlyAdded + 1, then there's 1 leftover default field to delete
1492
+ if (allFields.length === newlyAddedFieldCount + 1 && newlyAddedFieldCount > 0) {
1493
+ // Find the field that looks like a leftover default
1494
+ // Default fields typically have generic titles like "文本", "数字", "日期", "单选", "人员"
1495
+ const defaultFieldTitles = ['文本', '数字', '日期', '单选', '人员', '文本 1', '数字 1', '日期 1', '单选 1', '人员 1'];
1496
+ const defaultFieldTypes = ['FIELD_TYPE_TEXT', 'FIELD_TYPE_NUMBER', 'FIELD_TYPE_DATE_TIME', 'FIELD_TYPE_SINGLE_SELECT', 'FIELD_TYPE_USER'];
1497
+
1498
+ const leftoverField = allFields.find((field: any) => {
1499
+ const isDefaultTitle = defaultFieldTitles.includes(field.field_title);
1500
+ const isDefaultType = defaultFieldTypes.includes(field.field_type);
1501
+ return isDefaultTitle && isDefaultType;
1502
+ }) as any;
1503
+
1504
+ if (leftoverField && leftoverField.field_id) {
1505
+ await this.smartTableDelFields({ agent, docId, sheetId, field_ids: [leftoverField.field_id] });
1506
+ }
1507
+ }
1508
+ } catch (err) {
1509
+ // Non-fatal: new fields added, just cleanup failed
1510
+ console.error(`[WecomDocClient] cleanupLeftoverDefaultField failed:`, err);
1511
+ }
1374
1512
  }
1375
1513
 
1376
1514
  async smartTableUpdateFields(params: {
@@ -1515,16 +1653,55 @@ export class WecomDocClient {
1515
1653
  throw new Error("records 必须是非空数组");
1516
1654
  }
1517
1655
 
1518
- // Validate each record has values object
1519
- records.forEach((record: any, index: number) => {
1520
- if (!record.values || typeof record.values !== 'object') {
1521
- throw new Error(`第${index + 1}条记录缺少 values 对象`);
1656
+ // Strict validation: require correct format based on field type
1657
+ // Do NOT auto-convert ambiguous values to avoid corrupting user intent
1658
+ const validatedRecords = records.map((record: any, recordIndex: number) => {
1659
+ if (!record.values || typeof record.values !== 'object' || Array.isArray(record.values)) {
1660
+ throw new Error(`第${recordIndex + 1}条记录:values 必须是非空对象`);
1661
+ }
1662
+
1663
+ const validatedValues: Record<string, any> = {};
1664
+ for (const [key, value] of Object.entries(record.values)) {
1665
+ // Accept both array and non-array formats based on field type
1666
+ // Array types: TEXT, USER, SELECT, SINGLE_SELECT, CHECKBOX, PHONE_NUMBER, EMAIL, URL, LOCATION, BARCODE, ATTACHMENT, IMAGE
1667
+ // Non-array types: NUMBER, DATE_TIME, PROGRESS, CURRENCY, PERCENTAGE
1668
+
1669
+ if (Array.isArray(value)) {
1670
+ // Array format - validate structure
1671
+ if (value.length === 0) {
1672
+ throw new Error(`第${recordIndex + 1}条记录字段 "${key}": 数组不能为空`);
1673
+ }
1674
+ validatedValues[key] = value;
1675
+ } else if (typeof value === 'number') {
1676
+ // Non-array number - valid for NUMBER, PROGRESS, CURRENCY, PERCENTAGE
1677
+ validatedValues[key] = value;
1678
+ } else if (typeof value === 'string' && /^\d{13}$/.test(value)) {
1679
+ // Non-array 13-digit string - valid for DATE_TIME (millisecond timestamp)
1680
+ validatedValues[key] = value;
1681
+ } else if (typeof value === 'object' && value !== null && !Array.isArray(value)) {
1682
+ // Object format - wrap in array for types like USER, TEXT object
1683
+ // This allows {user_id: "..."} to become [{user_id: "..."}]
1684
+ validatedValues[key] = [value];
1685
+ } else {
1686
+ // Reject ambiguous primitives (plain strings, booleans)
1687
+ // Users should explicitly use array format: [{type: "text", text: "..."}]
1688
+ throw new Error(
1689
+ `第${recordIndex + 1}条记录字段 "${key}": 值格式不明确。` +
1690
+ `数字/日期类型直接写值 (25, "1704067200000"),` +
1691
+ `文本/成员/选项类型用数组 ([{"type": "text", "text": "..."}], [{"user_id": "..."}])`
1692
+ );
1693
+ }
1522
1694
  }
1695
+
1696
+ return {
1697
+ ...record,
1698
+ values: validatedValues,
1699
+ };
1523
1700
  });
1524
1701
 
1525
1702
  const bodyData: Record<string, unknown> = {
1526
1703
  sheet_id: readString(sheetId),
1527
- records: records,
1704
+ records: validatedRecords,
1528
1705
  };
1529
1706
 
1530
1707
  if (key_type) {
@@ -1536,7 +1713,49 @@ export class WecomDocClient {
1536
1713
 
1537
1714
  async smartTableUpdateRecords(params: { agent: ResolvedAgentAccount; docId: string; sheetId: string; records: any[] }) {
1538
1715
  const { agent, docId, sheetId, records } = params;
1539
- return this.smartTableOperate({ agent, docId, operation: "update_records", bodyData: { sheet_id: sheetId, records } });
1716
+
1717
+ // Strict validation: same as addRecords
1718
+ if (!Array.isArray(records) || records.length === 0) {
1719
+ throw new Error("records 必须是非空数组");
1720
+ }
1721
+
1722
+ const validatedRecords = records.map((record: any, recordIndex: number) => {
1723
+ if (!record.record_id) {
1724
+ throw new Error(`第${recordIndex + 1}条记录缺少 record_id`);
1725
+ }
1726
+ if (!record.values || typeof record.values !== 'object' || Array.isArray(record.values)) {
1727
+ throw new Error(`第${recordIndex + 1}条记录:values 必须是非空对象`);
1728
+ }
1729
+
1730
+ const validatedValues: Record<string, any> = {};
1731
+ for (const [key, value] of Object.entries(record.values)) {
1732
+ if (Array.isArray(value)) {
1733
+ if (value.length === 0) {
1734
+ throw new Error(`第${recordIndex + 1}条记录字段 "${key}": 数组不能为空`);
1735
+ }
1736
+ validatedValues[key] = value;
1737
+ } else if (typeof value === 'number') {
1738
+ validatedValues[key] = value;
1739
+ } else if (typeof value === 'string' && /^\d{13}$/.test(value)) {
1740
+ validatedValues[key] = value;
1741
+ } else if (typeof value === 'object' && value !== null && !Array.isArray(value)) {
1742
+ validatedValues[key] = [value];
1743
+ } else {
1744
+ throw new Error(
1745
+ `第${recordIndex + 1}条记录字段 "${key}": 值格式不明确。` +
1746
+ `数字/日期类型直接写值 (25, "1704067200000"),` +
1747
+ `文本/成员/选项类型用数组 ([{"type": "text", "text": "..."}], [{"user_id": "..."}])`
1748
+ );
1749
+ }
1750
+ }
1751
+
1752
+ return {
1753
+ ...record,
1754
+ values: validatedValues,
1755
+ };
1756
+ });
1757
+
1758
+ return this.smartTableOperate({ agent, docId, operation: "update_records", bodyData: { sheet_id: sheetId, records: validatedRecords } });
1540
1759
  }
1541
1760
 
1542
1761
  async smartTableDelRecords(params: { agent: ResolvedAgentAccount; docId: string; sheetId: string; record_ids: string[] }) {
@@ -1189,11 +1189,12 @@ export function registerWecomDocTools(api: OpenClawPluginApi) {
1189
1189
  });
1190
1190
  }
1191
1191
  case "smartsheet_add_records": {
1192
- const result = await docClient.smartTableOperate({
1192
+ const result = await docClient.smartTableAddRecords({
1193
1193
  agent: account,
1194
1194
  docId: params.docId,
1195
- operation: "add_records",
1196
- bodyData: params,
1195
+ sheetId: params.sheetId,
1196
+ records: params.records,
1197
+ key_type: params.key_type,
1197
1198
  });
1198
1199
  return buildToolResult({
1199
1200
  ok: true,
@@ -1205,11 +1206,11 @@ export function registerWecomDocTools(api: OpenClawPluginApi) {
1205
1206
  });
1206
1207
  }
1207
1208
  case "smartsheet_update_records": {
1208
- const result = await docClient.smartTableOperate({
1209
+ const result = await docClient.smartTableUpdateRecords({
1209
1210
  agent: account,
1210
1211
  docId: params.docId,
1211
- operation: "update_records",
1212
- bodyData: params,
1212
+ sheetId: params.sheetId,
1213
+ records: params.records,
1213
1214
  });
1214
1215
  return buildToolResult({
1215
1216
  ok: true,
@@ -1350,7 +1351,13 @@ export function registerWecomDocTools(api: OpenClawPluginApi) {
1350
1351
  });
1351
1352
  }
1352
1353
  case "smartsheet_add_fields": {
1353
- const result = await docClient.smartTableAddFields({ agent: account, ...params });
1354
+ const result = await docClient.smartTableAddFields({
1355
+ agent: account,
1356
+ docId: params.docId,
1357
+ sheetId: params.sheetId,
1358
+ fields: params.fields,
1359
+ autoCleanupDefaultField: params.autoCleanupDefaultField !== false, // Default true
1360
+ });
1354
1361
  return buildToolResult({
1355
1362
  ok: true,
1356
1363
  action,
@@ -12,5 +12,11 @@ export {
12
12
  export { resolveWecomRuntimeAccount, resolveWecomRuntimeConfig, type ResolvedRuntimeAccount, type ResolvedRuntimeConfig } from "./runtime-config.js";
13
13
  export { resolveDerivedPath, resolveDerivedPathSummary } from "./derived-paths.js";
14
14
  export { resolveWecomEgressProxyUrl, resolveWecomEgressProxyUrlFromNetwork } from "./network.js";
15
- export { DEFAULT_WECOM_MEDIA_MAX_BYTES, resolveWecomMediaMaxBytes } from "./media.js";
15
+ export {
16
+ DEFAULT_WECOM_MEDIA_MAX_BYTES,
17
+ getWecomDefaultMediaLocalRoots,
18
+ resolveWecomConfiguredMediaLocalRoots,
19
+ resolveWecomMediaMaxBytes,
20
+ resolveWecomMergedMediaLocalRoots,
21
+ } from "./media.js";
16
22
  export { resolveWecomFailClosedOnDefaultRoute, shouldRejectWecomDefaultRoute } from "./routing.js";
@@ -0,0 +1,113 @@
1
+ import os from "node:os";
2
+ import path from "node:path";
3
+ import { afterEach, describe, expect, it, vi } from "vitest";
4
+ import { resolvePreferredOpenClawTmpDir } from "openclaw/plugin-sdk";
5
+ import { resolveWecomMediaMaxBytes, resolveWecomMergedMediaLocalRoots } from "./media.js";
6
+
7
+ describe("resolveWecomMergedMediaLocalRoots", () => {
8
+ afterEach(() => {
9
+ vi.unstubAllEnvs();
10
+ });
11
+
12
+ it("merges defaults with configured local roots", () => {
13
+ vi.stubEnv("OPENCLAW_STATE_DIR", "/tmp/wecom-state");
14
+
15
+ const roots = resolveWecomMergedMediaLocalRoots({
16
+ cfg: {
17
+ channels: {
18
+ wecom: {
19
+ media: {
20
+ localRoots: ["~/Downloads", "/tmp/custom-root"],
21
+ },
22
+ },
23
+ },
24
+ } as never,
25
+ });
26
+
27
+ expect(roots).toEqual(
28
+ expect.arrayContaining([
29
+ path.resolve(resolvePreferredOpenClawTmpDir()),
30
+ "/tmp/wecom-state",
31
+ "/tmp/wecom-state/media",
32
+ "/tmp/wecom-state/agents",
33
+ "/tmp/wecom-state/workspace",
34
+ "/tmp/wecom-state/sandboxes",
35
+ path.resolve(os.homedir(), "Desktop"),
36
+ path.resolve(os.homedir(), "Documents"),
37
+ path.resolve(os.homedir(), "Downloads"),
38
+ path.resolve(os.homedir(), "Movies"),
39
+ path.resolve(os.homedir(), "Pictures"),
40
+ "/tmp/custom-root",
41
+ ]),
42
+ );
43
+ });
44
+
45
+ it("keeps defaults, base roots, and configured roots without duplicates", () => {
46
+ vi.stubEnv("OPENCLAW_STATE_DIR", "/tmp/wecom-state");
47
+
48
+ const roots = resolveWecomMergedMediaLocalRoots({
49
+ cfg: {
50
+ channels: {
51
+ wecom: {
52
+ media: {
53
+ localRoots: ["/tmp/agent-root", "/tmp/downloads"],
54
+ },
55
+ },
56
+ },
57
+ } as never,
58
+ baseRoots: ["/tmp/agent-root", "/tmp/workspace-agent"],
59
+ });
60
+
61
+ expect(roots).toEqual(
62
+ expect.arrayContaining([
63
+ path.resolve(resolvePreferredOpenClawTmpDir()),
64
+ "/tmp/wecom-state",
65
+ "/tmp/workspace-agent",
66
+ "/tmp/agent-root",
67
+ "/tmp/downloads",
68
+ ]),
69
+ );
70
+ expect(roots.filter((root) => root === "/tmp/agent-root")).toHaveLength(1);
71
+ });
72
+ });
73
+
74
+ describe("resolveWecomMediaMaxBytes", () => {
75
+ it("prefers account mediaMaxMb over channel and agent defaults", () => {
76
+ expect(
77
+ resolveWecomMediaMaxBytes(
78
+ {
79
+ agents: {
80
+ defaults: {
81
+ mediaMaxMb: 12,
82
+ },
83
+ },
84
+ channels: {
85
+ wecom: {
86
+ mediaMaxMb: 24,
87
+ accounts: {
88
+ ops: {
89
+ mediaMaxMb: 32,
90
+ },
91
+ },
92
+ },
93
+ },
94
+ } as never,
95
+ "ops",
96
+ ),
97
+ ).toBe(32 * 1024 * 1024);
98
+ });
99
+
100
+ it("falls back to legacy channels.wecom.media.maxBytes when mediaMaxMb is unset", () => {
101
+ expect(
102
+ resolveWecomMediaMaxBytes({
103
+ channels: {
104
+ wecom: {
105
+ media: {
106
+ maxBytes: 15 * 1024 * 1024,
107
+ },
108
+ },
109
+ },
110
+ } as never),
111
+ ).toBe(15 * 1024 * 1024);
112
+ });
113
+ });