@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.
- package/README.md +81 -2
- package/SKILLS_DOC.md +272 -120
- package/changelog/v2.3.19.md +73 -0
- package/package.json +1 -1
- package/src/agent/handler.ts +3 -3
- package/src/app/index.ts +1 -0
- package/src/capability/bot/stream-orchestrator.ts +1 -1
- package/src/capability/doc/client.ts +228 -9
- package/src/capability/doc/tool.ts +14 -7
- package/src/config/index.ts +7 -1
- package/src/config/media.test.ts +113 -0
- package/src/config/media.ts +133 -6
- package/src/config/schema.ts +3 -0
- package/src/outbound.test.ts +162 -4
- package/src/outbound.ts +13 -1
- package/src/runtime/routing-bridge.test.ts +115 -0
- package/src/runtime/routing-bridge.ts +26 -1
- package/src/transport/bot-webhook/inbound-normalizer.ts +4 -4
- package/src/transport/bot-ws/media.test.ts +44 -0
- package/src/transport/bot-ws/media.ts +6 -3
- package/src/transport/bot-ws/reply.test.ts +131 -1
- package/src/transport/bot-ws/reply.ts +7 -0
- package/src/transport/bot-ws/sdk-adapter.ts +2 -1
- package/src/types/config.ts +3 -0
|
@@ -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
package/src/agent/handler.ts
CHANGED
|
@@ -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.
|
|
602
|
-
|
|
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
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
1519
|
-
|
|
1520
|
-
|
|
1521
|
-
|
|
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:
|
|
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
|
-
|
|
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.
|
|
1192
|
+
const result = await docClient.smartTableAddRecords({
|
|
1193
1193
|
agent: account,
|
|
1194
1194
|
docId: params.docId,
|
|
1195
|
-
|
|
1196
|
-
|
|
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.
|
|
1209
|
+
const result = await docClient.smartTableUpdateRecords({
|
|
1209
1210
|
agent: account,
|
|
1210
1211
|
docId: params.docId,
|
|
1211
|
-
|
|
1212
|
-
|
|
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({
|
|
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,
|
package/src/config/index.ts
CHANGED
|
@@ -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 {
|
|
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
|
+
});
|