@modesto_ma/dsh-channels 0.5.0-beta.1 → 0.5.0-beta.3
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 +15 -7
- package/docs/access-control-design.md +1 -0
- package/docs/attachment-pipeline-v2.md +2 -2
- package/docs/feishu-onboarding.md +6 -2
- package/lib/client.js +271 -1
- package/lib/client.js.map +1 -1
- package/lib/index.js +3374 -167
- package/lib/index.js.map +1 -1
- package/package.json +1 -1
- package/src/access/store.ts +31 -1
- package/src/cards/picker.ts +9 -16
- package/src/cards/wiki.ts +124 -0
- package/src/client/panel/shared.tsx +96 -1
- package/src/client/string-list.ts +139 -0
- package/src/client/styles.ts +3 -0
- package/src/commands/registry.ts +6 -3
- package/src/config.ts +50 -6
- package/src/domain.ts +10 -0
- package/src/feishu/delivery.ts +34 -1
- package/src/feishu/message-read.ts +78 -2
- package/src/feishu/scopes.ts +35 -0
- package/src/feishu/wiki/a1.ts +97 -0
- package/src/feishu/wiki/api.ts +361 -0
- package/src/feishu/wiki/base-api.ts +267 -0
- package/src/feishu/wiki/base-tools.ts +423 -0
- package/src/feishu/wiki/consent.ts +183 -0
- package/src/feishu/wiki/links.ts +122 -0
- package/src/feishu/wiki/scope.ts +66 -0
- package/src/feishu/wiki/shared.ts +284 -0
- package/src/feishu/wiki/sheet-api.ts +250 -0
- package/src/feishu/wiki/sheet-tools.ts +493 -0
- package/src/feishu/wiki/tools.ts +897 -0
- package/src/feishu-channel.ts +23 -0
- package/src/service.ts +87 -7
- package/src/settings/contract.ts +42 -4
- package/src/settings/surface.ts +111 -10
package/lib/index.js
CHANGED
|
@@ -295,7 +295,7 @@ function standaloneInfoCard(title, lines) {
|
|
|
295
295
|
* @module dsh-channels/access/cards
|
|
296
296
|
*/
|
|
297
297
|
/** Truncate one display string so the card stays far below the size budget. */
|
|
298
|
-
function clip$
|
|
298
|
+
function clip$2(value, max = 120) {
|
|
299
299
|
const trimmed = value.trim();
|
|
300
300
|
return trimmed.length <= max ? trimmed : `${trimmed.slice(0, max)}…`;
|
|
301
301
|
}
|
|
@@ -339,7 +339,7 @@ function field(label, value) {
|
|
|
339
339
|
* group). Unauthorized groups with a pending request carry「授权本群 / 忽略」.
|
|
340
340
|
*/
|
|
341
341
|
function buildGroupInfoCard(input) {
|
|
342
|
-
const name = input.name === void 0 ? void 0 : clip$
|
|
342
|
+
const name = input.name === void 0 ? void 0 : clip$2(input.name);
|
|
343
343
|
const elements = [];
|
|
344
344
|
elements.push(field("群名", name === void 0 || name === "" ? "(未知)" : name));
|
|
345
345
|
elements.push(field("群 ID", `\`${input.chatId}\``));
|
|
@@ -361,8 +361,8 @@ function buildGroupInfoCard(input) {
|
|
|
361
361
|
* from the DM card.
|
|
362
362
|
*/
|
|
363
363
|
function buildUserRequestCard(input) {
|
|
364
|
-
const display = clip$
|
|
365
|
-
const groupName = input.groupName === void 0 ? void 0 : clip$
|
|
364
|
+
const display = clip$2(input.name ?? input.requesterName ?? "");
|
|
365
|
+
const groupName = input.groupName === void 0 ? void 0 : clip$2(input.groupName);
|
|
366
366
|
const elements = [field("用户", display === "" ? "(未知昵称)" : display), field("user_id", `\`${input.userId}\``)];
|
|
367
367
|
if (groupName !== void 0 && groupName !== "") {
|
|
368
368
|
const group = input.chatId === void 0 ? groupName : `${groupName}(\`${input.chatId}\`)`;
|
|
@@ -396,7 +396,7 @@ const SETTLED_PRESENTATION = {
|
|
|
396
396
|
function buildRequestSettledCard(input) {
|
|
397
397
|
const presentation = SETTLED_PRESENTATION[input.status];
|
|
398
398
|
const kindLabel = input.kind === "group" ? "群" : "用户";
|
|
399
|
-
const name = input.targetName === void 0 ? void 0 : clip$
|
|
399
|
+
const name = input.targetName === void 0 ? void 0 : clip$2(input.targetName);
|
|
400
400
|
const elements = [
|
|
401
401
|
field(kindLabel, name === void 0 || name === "" ? `\`${input.targetId}\`` : `${name}(\`${input.targetId}\`)`),
|
|
402
402
|
field("处理时间", formatTime(input.now)),
|
|
@@ -857,6 +857,8 @@ var AccessStore = class {
|
|
|
857
857
|
groups = /* @__PURE__ */ new Map();
|
|
858
858
|
users = /* @__PURE__ */ new Map();
|
|
859
859
|
requests = /* @__PURE__ */ new Map();
|
|
860
|
+
/** Wiki whitelist (`/auth wiki add`), independent of the user whitelist. */
|
|
861
|
+
wikiUsers = /* @__PURE__ */ new Map();
|
|
860
862
|
/** Cached key sets so `snapshot()` can stay allocation-free per message. */
|
|
861
863
|
groupKeys = /* @__PURE__ */ new Set();
|
|
862
864
|
userKeys = /* @__PURE__ */ new Set();
|
|
@@ -880,12 +882,14 @@ var AccessStore = class {
|
|
|
880
882
|
this.userKeys.add(key);
|
|
881
883
|
}
|
|
882
884
|
for (const [key, record] of domain.accessRequests.entries()) this.requests.set(key, record);
|
|
885
|
+
for (const [key, record] of domain.accessWikiUsers.entries()) this.wikiUsers.set(key, record);
|
|
883
886
|
}
|
|
884
887
|
/** Drop every in-memory record (shutdown). The domain owns durability. */
|
|
885
888
|
clear() {
|
|
886
889
|
this.groups.clear();
|
|
887
890
|
this.users.clear();
|
|
888
891
|
this.requests.clear();
|
|
892
|
+
this.wikiUsers.clear();
|
|
889
893
|
this.groupKeys.clear();
|
|
890
894
|
this.userKeys.clear();
|
|
891
895
|
}
|
|
@@ -941,6 +945,25 @@ var AccessStore = class {
|
|
|
941
945
|
this.userKeys.delete(userId);
|
|
942
946
|
return true;
|
|
943
947
|
}
|
|
948
|
+
/** Whether `userId` is on the wiki whitelist (the administrator is not implied). */
|
|
949
|
+
hasWikiUser(userId) {
|
|
950
|
+
return this.wikiUsers.has(userId);
|
|
951
|
+
}
|
|
952
|
+
/** Every wiki-whitelisted user, oldest first. */
|
|
953
|
+
wikiUserRecords() {
|
|
954
|
+
return [...this.wikiUsers.values()].sort((left, right) => left.addedAt - right.addedAt);
|
|
955
|
+
}
|
|
956
|
+
async addWikiUser(record) {
|
|
957
|
+
await this.requireDomain("add wiki user").accessWikiUsers.put(record.userId, record);
|
|
958
|
+
this.wikiUsers.set(record.userId, record);
|
|
959
|
+
return record;
|
|
960
|
+
}
|
|
961
|
+
async removeWikiUser(userId) {
|
|
962
|
+
if (!this.wikiUsers.has(userId)) return false;
|
|
963
|
+
await this.requireDomain("remove wiki user").accessWikiUsers.delete(userId);
|
|
964
|
+
this.wikiUsers.delete(userId);
|
|
965
|
+
return true;
|
|
966
|
+
}
|
|
944
967
|
/** Every authorization request, including settled ones (audit). */
|
|
945
968
|
requestRecords() {
|
|
946
969
|
return [...this.requests.values()].sort((left, right) => left.createdAt - right.createdAt);
|
|
@@ -1262,7 +1285,8 @@ const feishuDomain = {
|
|
|
1262
1285
|
expired_routes: { valueSchema: expiredRouteRecordSchema },
|
|
1263
1286
|
access_groups: { valueSchema: accessGroupRecordSchema },
|
|
1264
1287
|
access_users: { valueSchema: accessUserRecordSchema },
|
|
1265
|
-
access_requests: { valueSchema: accessRequestRecordSchema }
|
|
1288
|
+
access_requests: { valueSchema: accessRequestRecordSchema },
|
|
1289
|
+
access_wiki_users: { valueSchema: accessUserRecordSchema }
|
|
1266
1290
|
}
|
|
1267
1291
|
};
|
|
1268
1292
|
/**
|
|
@@ -1348,7 +1372,7 @@ function approvalTimeoutLabel(timeoutMs) {
|
|
|
1348
1372
|
return `${Math.max(1, Math.round(timeoutMs / 6e4))} 分钟`;
|
|
1349
1373
|
}
|
|
1350
1374
|
/** Clip to `max` chars and report how many were dropped. */
|
|
1351
|
-
function clip(value, max) {
|
|
1375
|
+
function clip$1(value, max) {
|
|
1352
1376
|
return value.length <= max ? {
|
|
1353
1377
|
shown: value,
|
|
1354
1378
|
hidden: 0
|
|
@@ -1395,12 +1419,12 @@ function approvalActions(question) {
|
|
|
1395
1419
|
function approvalQuestionElements(question) {
|
|
1396
1420
|
const elements = [markdown(question.escalateTo === void 0 ? `工具 \`${question.toolName}\` 请求执行一个需要授权的操作。` : `工具 \`${question.toolName}\` 请求把本次调用提升到 \`${question.escalateTo}\` 权限。`)];
|
|
1397
1421
|
if (question.command !== void 0 && question.command !== "") {
|
|
1398
|
-
const command = clip(question.command, APPROVAL_COMMAND_MAX_CHARS);
|
|
1422
|
+
const command = clip$1(question.command, APPROVAL_COMMAND_MAX_CHARS);
|
|
1399
1423
|
elements.push(markdown(`**将执行**\n\`\`\`\n${command.shown.replace(/```/gu, "```")}\n\`\`\``));
|
|
1400
1424
|
if (command.hidden > 0) elements.push(note(`已截断 ${command.hidden} 个字符`));
|
|
1401
1425
|
}
|
|
1402
1426
|
if (question.reason !== void 0 && question.reason !== "") {
|
|
1403
|
-
const reason = clip(question.reason, APPROVAL_REASON_MAX_CHARS);
|
|
1427
|
+
const reason = clip$1(question.reason, APPROVAL_REASON_MAX_CHARS);
|
|
1404
1428
|
elements.push(markdown(`> ${reason.shown.replace(/\s+/gu, " ")}${reason.hidden > 0 ? "…" : ""}`));
|
|
1405
1429
|
}
|
|
1406
1430
|
return elements;
|
|
@@ -1849,7 +1873,7 @@ function questionOptionButton(input, question, option) {
|
|
|
1849
1873
|
}
|
|
1850
1874
|
});
|
|
1851
1875
|
}
|
|
1852
|
-
function questionElements(input) {
|
|
1876
|
+
function questionElements$1(input) {
|
|
1853
1877
|
const elements = [markdown("**❓ 需要你的回答**")];
|
|
1854
1878
|
input.questions.forEach((question, index) => {
|
|
1855
1879
|
const heading = question.header === void 0 || question.header === "" ? `**问题 ${index + 1}**` : `**问题 ${index + 1} · ${clipQuestionText(question.header, 80)}**`;
|
|
@@ -1881,7 +1905,7 @@ function questionElements(input) {
|
|
|
1881
1905
|
}
|
|
1882
1906
|
/** The pending structured-question card (one card carries every question). */
|
|
1883
1907
|
function buildQuestionCard(input) {
|
|
1884
|
-
return card(questionElements(input));
|
|
1908
|
+
return card(questionElements$1(input));
|
|
1885
1909
|
}
|
|
1886
1910
|
const QUESTION_SETTLED_LINES = {
|
|
1887
1911
|
answered: "✅ 已回答",
|
|
@@ -2578,7 +2602,8 @@ function buildOnboardingCard() {
|
|
|
2578
2602
|
*
|
|
2579
2603
|
* A click carries the command and its argument back to the service, which
|
|
2580
2604
|
* runs it exactly like the typed `/<command> <arg>` line, so admission,
|
|
2581
|
-
* ownership and validation stay in one place
|
|
2605
|
+
* ownership and validation stay in one place; the result replaces the picker
|
|
2606
|
+
* card in place.
|
|
2582
2607
|
*
|
|
2583
2608
|
* @module dsh-channels/cards/picker
|
|
2584
2609
|
*/
|
|
@@ -2601,33 +2626,23 @@ function pickButton(input, label, arg) {
|
|
|
2601
2626
|
return textButton({
|
|
2602
2627
|
label: active ? `✅ ${label}` : label,
|
|
2603
2628
|
type: active ? "primary" : "default",
|
|
2604
|
-
width: "fill",
|
|
2605
|
-
margin: "0px",
|
|
2606
2629
|
value
|
|
2607
2630
|
});
|
|
2608
2631
|
}
|
|
2609
|
-
/** Choices in two-column rows. */
|
|
2610
|
-
function optionRows(input, options) {
|
|
2611
|
-
const rows = [];
|
|
2612
|
-
for (let index = 0; index < options.length; index += 2) {
|
|
2613
|
-
const pair = options.slice(index, index + 2).map((option) => pickButton(input, option.label, option.arg));
|
|
2614
|
-
rows.push(columnSet(pair));
|
|
2615
|
-
}
|
|
2616
|
-
return rows;
|
|
2617
|
-
}
|
|
2618
2632
|
/** Whether a value can be appended to a command line as one argument. */
|
|
2619
2633
|
function isPickerArg(value) {
|
|
2620
2634
|
return value !== "" && !/\s/u.test(value);
|
|
2621
2635
|
}
|
|
2622
2636
|
/**
|
|
2623
|
-
* The picker card
|
|
2637
|
+
* The picker card: one full-width button per choice, in a single column.
|
|
2638
|
+
* Options whose argument cannot form one command-line token
|
|
2624
2639
|
* are skipped; when more than `MAX_PICKER_OPTIONS` remain the rest are
|
|
2625
2640
|
* reported as typed-only.
|
|
2626
2641
|
*/
|
|
2627
2642
|
function buildPickerCard(input) {
|
|
2628
2643
|
const usable = input.options.filter((option) => isPickerArg(option.arg));
|
|
2629
2644
|
const shown = usable.slice(0, 12);
|
|
2630
|
-
const elements = [markdown(input.text), ...
|
|
2645
|
+
const elements = [markdown(input.text), ...shown.map((option) => pickButton(input, option.label, option.arg))];
|
|
2631
2646
|
if (usable.length > shown.length) elements.push(note(`另有 ${usable.length - shown.length} 项未展示,可直接输入 /${input.command} <id>。`));
|
|
2632
2647
|
if (input.reset === true) elements.push(textButton({
|
|
2633
2648
|
label: "恢复默认",
|
|
@@ -2684,7 +2699,7 @@ const CHANNEL_COMMANDS = [
|
|
|
2684
2699
|
group: "基础",
|
|
2685
2700
|
description: "显示当前支持的斜杠命令",
|
|
2686
2701
|
routeFree: true,
|
|
2687
|
-
directReply:
|
|
2702
|
+
directReply: true
|
|
2688
2703
|
},
|
|
2689
2704
|
{
|
|
2690
2705
|
name: "userid",
|
|
@@ -2710,7 +2725,8 @@ const CHANNEL_COMMANDS = [
|
|
|
2710
2725
|
name: "health",
|
|
2711
2726
|
group: "基础",
|
|
2712
2727
|
description: "显示 Harness 与飞书通道健康状态",
|
|
2713
|
-
sessionOptional: true
|
|
2728
|
+
sessionOptional: true,
|
|
2729
|
+
directReply: true
|
|
2714
2730
|
},
|
|
2715
2731
|
{
|
|
2716
2732
|
name: "status",
|
|
@@ -3384,6 +3400,72 @@ const SEND_FILE_REQUIREMENT = {
|
|
|
3384
3400
|
level: "optional",
|
|
3385
3401
|
purpose: "上传图片/文件,让模型用 feishu_send_file 把生成的文件发给用户"
|
|
3386
3402
|
};
|
|
3403
|
+
/** Spreadsheet and Base read needs, shared by the wiki read and write sets. */
|
|
3404
|
+
const SHEET_BASE_READ_REQUIREMENTS = [{
|
|
3405
|
+
anyOf: [
|
|
3406
|
+
"sheets:spreadsheet",
|
|
3407
|
+
"sheets:spreadsheet:readonly",
|
|
3408
|
+
"sheets:spreadsheet:read"
|
|
3409
|
+
],
|
|
3410
|
+
level: "optional",
|
|
3411
|
+
purpose: "读取知识库电子表格(feishu_wiki_sheet_read)"
|
|
3412
|
+
}, {
|
|
3413
|
+
anyOf: [
|
|
3414
|
+
"bitable:app",
|
|
3415
|
+
"bitable:app:readonly",
|
|
3416
|
+
"base:record:read"
|
|
3417
|
+
],
|
|
3418
|
+
level: "optional",
|
|
3419
|
+
purpose: "读取知识库多维表格(feishu_wiki_base_read)"
|
|
3420
|
+
}];
|
|
3421
|
+
/** Wiki read needs (`feishu_wiki_list` / `feishu_wiki_read` and the sheet/base read tools). */
|
|
3422
|
+
const WIKI_READ_REQUIREMENTS = [
|
|
3423
|
+
{
|
|
3424
|
+
anyOf: ["wiki:wiki", "wiki:wiki:readonly"],
|
|
3425
|
+
level: "optional",
|
|
3426
|
+
purpose: "浏览知识库节点(feishu_wiki_list / feishu_wiki_read)"
|
|
3427
|
+
},
|
|
3428
|
+
{
|
|
3429
|
+
anyOf: ["docx:document", "docx:document:readonly"],
|
|
3430
|
+
level: "optional",
|
|
3431
|
+
purpose: "读取知识库中的文档内容(feishu_wiki_read)"
|
|
3432
|
+
},
|
|
3433
|
+
...SHEET_BASE_READ_REQUIREMENTS
|
|
3434
|
+
];
|
|
3435
|
+
/** Wiki write needs (`feishu_wiki_create` / `feishu_wiki_edit`). */
|
|
3436
|
+
const WIKI_WRITE_REQUIREMENTS = [
|
|
3437
|
+
{
|
|
3438
|
+
anyOf: ["wiki:wiki"],
|
|
3439
|
+
level: "optional",
|
|
3440
|
+
purpose: "在知识库中新建节点(feishu_wiki_create)"
|
|
3441
|
+
},
|
|
3442
|
+
{
|
|
3443
|
+
anyOf: ["docx:document"],
|
|
3444
|
+
level: "optional",
|
|
3445
|
+
purpose: "写入与修改知识库文档内容(feishu_wiki_create / feishu_wiki_edit)"
|
|
3446
|
+
},
|
|
3447
|
+
{
|
|
3448
|
+
anyOf: ["sheets:spreadsheet", "sheets:spreadsheet:write_only"],
|
|
3449
|
+
level: "optional",
|
|
3450
|
+
purpose: "写入知识库电子表格(feishu_wiki_sheet_edit)"
|
|
3451
|
+
},
|
|
3452
|
+
{
|
|
3453
|
+
anyOf: ["bitable:app", "base:record:create"],
|
|
3454
|
+
level: "optional",
|
|
3455
|
+
purpose: "新增知识库多维表格记录(feishu_wiki_base_edit)"
|
|
3456
|
+
},
|
|
3457
|
+
{
|
|
3458
|
+
anyOf: ["bitable:app", "base:record:update"],
|
|
3459
|
+
level: "optional",
|
|
3460
|
+
purpose: "修改本应用创建的多维表格中的记录(feishu_wiki_base_edit)"
|
|
3461
|
+
},
|
|
3462
|
+
{
|
|
3463
|
+
anyOf: ["bitable:app", "base:record:delete"],
|
|
3464
|
+
level: "optional",
|
|
3465
|
+
purpose: "删除本应用创建的多维表格中的记录(feishu_wiki_base_edit)"
|
|
3466
|
+
},
|
|
3467
|
+
...SHEET_BASE_READ_REQUIREMENTS
|
|
3468
|
+
];
|
|
3387
3469
|
/** Group receive needs while ordinary group messages require an @ mention. */
|
|
3388
3470
|
const MENTION_GROUP_REQUIREMENTS = [{
|
|
3389
3471
|
anyOf: ["im:message.group_at_msg:readonly", "im:message.group_msg"],
|
|
@@ -3444,9 +3526,15 @@ function scopeRequirements(context) {
|
|
|
3444
3526
|
...context.requireMention ? MENTION_GROUP_REQUIREMENTS : [ALL_GROUP_REQUIREMENT],
|
|
3445
3527
|
...BASE_REQUIREMENTS,
|
|
3446
3528
|
...context.sendFile ? [SEND_FILE_REQUIREMENT] : [],
|
|
3529
|
+
...wikiRequirements(context),
|
|
3447
3530
|
...context.slashCommandPanel ? SLASH_COMMAND_REQUIREMENTS : []
|
|
3448
3531
|
];
|
|
3449
3532
|
}
|
|
3533
|
+
/** Wiki needs; the write set subsumes the read set's preferred scopes. */
|
|
3534
|
+
function wikiRequirements(context) {
|
|
3535
|
+
if (context.wikiWrite) return WIKI_WRITE_REQUIREMENTS;
|
|
3536
|
+
return context.wikiRead ? WIKI_READ_REQUIREMENTS : [];
|
|
3537
|
+
}
|
|
3450
3538
|
/**
|
|
3451
3539
|
* Compare `grants` with the applicable requirements. Only tenant (or untyped)
|
|
3452
3540
|
* grants count: the plugin always calls with the app identity.
|
|
@@ -3473,6 +3561,486 @@ function checkScopes(grants, appId, context) {
|
|
|
3473
3561
|
};
|
|
3474
3562
|
}
|
|
3475
3563
|
//#endregion
|
|
3564
|
+
//#region src/feishu/wiki/api.ts
|
|
3565
|
+
/**
|
|
3566
|
+
* Feishu wiki (知识库) and docx OpenAPI calls used by the wiki tools, made
|
|
3567
|
+
* with the app identity (tenant access token).
|
|
3568
|
+
*
|
|
3569
|
+
* Structure calls use `wiki/v2` (`spaces/get_node`, `spaces/:id`,
|
|
3570
|
+
* `spaces/:id/nodes`); document content uses the `docs_ai/v1` document
|
|
3571
|
+
* endpoints, which read and write Markdown or Feishu document XML directly
|
|
3572
|
+
* (block ids are exported only in XML); the revision check uses
|
|
3573
|
+
* `docx/v1/documents/:id`. Every call maps a thrown transport error or a
|
|
3574
|
+
* non-zero Feishu `code` to {@link FeishuApiError}.
|
|
3575
|
+
*
|
|
3576
|
+
* @module dsh-channels/feishu/wiki/api
|
|
3577
|
+
*/
|
|
3578
|
+
const OBJ_TYPES = /* @__PURE__ */ new Set([
|
|
3579
|
+
"doc",
|
|
3580
|
+
"docx",
|
|
3581
|
+
"sheet",
|
|
3582
|
+
"mindnote",
|
|
3583
|
+
"bitable",
|
|
3584
|
+
"file",
|
|
3585
|
+
"slides"
|
|
3586
|
+
]);
|
|
3587
|
+
/** Feishu's per-page maximum for `spaces/:id/nodes`. */
|
|
3588
|
+
const LIST_PAGE_SIZE = 50;
|
|
3589
|
+
function thrownError(error) {
|
|
3590
|
+
if (error instanceof FeishuApiError) return error;
|
|
3591
|
+
const data = error?.response?.data;
|
|
3592
|
+
const code = typeof data?.code === "number" ? data.code : void 0;
|
|
3593
|
+
const msg = typeof data?.msg === "string" ? data.msg : void 0;
|
|
3594
|
+
if (code === void 0 && msg === void 0) return new FeishuApiError(error instanceof Error ? error.message : String(error));
|
|
3595
|
+
return new FeishuApiError([code === void 0 ? void 0 : `code=${code}`, msg].filter(Boolean).join(" "), code);
|
|
3596
|
+
}
|
|
3597
|
+
/**
|
|
3598
|
+
* One Feishu OpenAPI call: unwraps `data` and maps a transport error or a
|
|
3599
|
+
* non-zero `code` to {@link FeishuApiError}.
|
|
3600
|
+
*
|
|
3601
|
+
* @param request The SDK's generic request.
|
|
3602
|
+
* @param input Method, URL, query, and body.
|
|
3603
|
+
* @returns The response `data`.
|
|
3604
|
+
*/
|
|
3605
|
+
async function call(request, input) {
|
|
3606
|
+
let response;
|
|
3607
|
+
try {
|
|
3608
|
+
response = await request(input);
|
|
3609
|
+
} catch (error) {
|
|
3610
|
+
throw thrownError(error);
|
|
3611
|
+
}
|
|
3612
|
+
if (response.code !== void 0 && response.code !== 0) throw new FeishuApiError(`code=${response.code}${response.msg === void 0 ? "" : ` ${response.msg}`}`, response.code);
|
|
3613
|
+
if (response.data === void 0) throw new FeishuApiError(`${input.url}: response has no data`);
|
|
3614
|
+
return response.data;
|
|
3615
|
+
}
|
|
3616
|
+
function wikiNode(raw, where) {
|
|
3617
|
+
if (raw?.space_id === void 0 || raw.node_token === void 0 || raw.obj_token === void 0 || raw.obj_type === void 0 || !OBJ_TYPES.has(raw.obj_type)) throw new FeishuApiError(`${where}: response node is incomplete`);
|
|
3618
|
+
return {
|
|
3619
|
+
spaceId: raw.space_id,
|
|
3620
|
+
nodeToken: raw.node_token,
|
|
3621
|
+
objToken: raw.obj_token,
|
|
3622
|
+
objType: raw.obj_type,
|
|
3623
|
+
...raw.parent_node_token === void 0 || raw.parent_node_token === "" ? {} : { parentNodeToken: raw.parent_node_token },
|
|
3624
|
+
nodeType: raw.node_type === "shortcut" ? "shortcut" : "origin",
|
|
3625
|
+
title: raw.title ?? "",
|
|
3626
|
+
hasChild: raw.has_child === true,
|
|
3627
|
+
...raw.owner === void 0 || raw.owner === "" ? {} : { owner: raw.owner },
|
|
3628
|
+
...raw.url === void 0 || raw.url === "" ? {} : { url: raw.url }
|
|
3629
|
+
};
|
|
3630
|
+
}
|
|
3631
|
+
/**
|
|
3632
|
+
* Encode one URL path segment.
|
|
3633
|
+
*
|
|
3634
|
+
* @param value Raw segment.
|
|
3635
|
+
* @returns The encoded segment.
|
|
3636
|
+
*/
|
|
3637
|
+
function segment(value) {
|
|
3638
|
+
return encodeURIComponent(value);
|
|
3639
|
+
}
|
|
3640
|
+
/**
|
|
3641
|
+
* {@link WikiApi} over the SDK's generic authenticated request.
|
|
3642
|
+
*
|
|
3643
|
+
* @param request `Client.request` of the connected SDK client.
|
|
3644
|
+
* @returns The API.
|
|
3645
|
+
*/
|
|
3646
|
+
function createWikiApi(request) {
|
|
3647
|
+
return {
|
|
3648
|
+
async getNode(token, objType) {
|
|
3649
|
+
const url = "/open-apis/wiki/v2/spaces/get_node";
|
|
3650
|
+
return wikiNode((await call(request, {
|
|
3651
|
+
method: "GET",
|
|
3652
|
+
url,
|
|
3653
|
+
params: {
|
|
3654
|
+
token,
|
|
3655
|
+
obj_type: objType
|
|
3656
|
+
}
|
|
3657
|
+
})).node, url);
|
|
3658
|
+
},
|
|
3659
|
+
async spaceName(spaceId) {
|
|
3660
|
+
return (await call(request, {
|
|
3661
|
+
method: "GET",
|
|
3662
|
+
url: `/open-apis/wiki/v2/spaces/${segment(spaceId)}`
|
|
3663
|
+
})).space?.name ?? "";
|
|
3664
|
+
},
|
|
3665
|
+
async listChildren(spaceId, parentNodeToken, pageToken) {
|
|
3666
|
+
const url = `/open-apis/wiki/v2/spaces/${segment(spaceId)}/nodes`;
|
|
3667
|
+
const data = await call(request, {
|
|
3668
|
+
method: "GET",
|
|
3669
|
+
url,
|
|
3670
|
+
params: {
|
|
3671
|
+
page_size: LIST_PAGE_SIZE,
|
|
3672
|
+
...parentNodeToken === void 0 ? {} : { parent_node_token: parentNodeToken },
|
|
3673
|
+
...pageToken === void 0 ? {} : { page_token: pageToken }
|
|
3674
|
+
}
|
|
3675
|
+
});
|
|
3676
|
+
return {
|
|
3677
|
+
items: (data.items ?? []).map((item) => wikiNode(item, url)),
|
|
3678
|
+
...data.has_more === true && data.page_token !== void 0 && data.page_token !== "" ? { pageToken: data.page_token } : {}
|
|
3679
|
+
};
|
|
3680
|
+
},
|
|
3681
|
+
async createNode(spaceId, parentNodeToken, title, objType) {
|
|
3682
|
+
const url = `/open-apis/wiki/v2/spaces/${segment(spaceId)}/nodes`;
|
|
3683
|
+
return wikiNode((await call(request, {
|
|
3684
|
+
method: "POST",
|
|
3685
|
+
url,
|
|
3686
|
+
data: {
|
|
3687
|
+
obj_type: objType,
|
|
3688
|
+
node_type: "origin",
|
|
3689
|
+
title,
|
|
3690
|
+
...parentNodeToken === void 0 ? {} : { parent_node_token: parentNodeToken }
|
|
3691
|
+
}
|
|
3692
|
+
})).node, url);
|
|
3693
|
+
},
|
|
3694
|
+
async documentRevision(documentId) {
|
|
3695
|
+
const url = `/open-apis/docx/v1/documents/${segment(documentId)}`;
|
|
3696
|
+
const revision = (await call(request, {
|
|
3697
|
+
method: "GET",
|
|
3698
|
+
url
|
|
3699
|
+
})).document?.revision_id;
|
|
3700
|
+
if (typeof revision !== "number") throw new FeishuApiError(`${url}: response has no revision_id`);
|
|
3701
|
+
return revision;
|
|
3702
|
+
},
|
|
3703
|
+
async fetchDocument(documentId, fetch) {
|
|
3704
|
+
const url = `/open-apis/docs_ai/v1/documents/${segment(documentId)}/fetch`;
|
|
3705
|
+
const ids = fetch.format === "xml" && fetch.withBlockIds;
|
|
3706
|
+
const readOption = fetch.scope === "full" ? void 0 : {
|
|
3707
|
+
read_mode: fetch.scope,
|
|
3708
|
+
...fetch.startBlockId === void 0 ? {} : { start_block_id: fetch.startBlockId },
|
|
3709
|
+
...fetch.endBlockId === void 0 ? {} : { end_block_id: fetch.endBlockId },
|
|
3710
|
+
...fetch.keyword === void 0 ? {} : { keyword: fetch.keyword },
|
|
3711
|
+
...fetch.maxDepth === void 0 ? {} : { max_depth: String(fetch.maxDepth) }
|
|
3712
|
+
};
|
|
3713
|
+
const document = (await call(request, {
|
|
3714
|
+
method: "POST",
|
|
3715
|
+
url,
|
|
3716
|
+
data: {
|
|
3717
|
+
format: fetch.format,
|
|
3718
|
+
export_option: {
|
|
3719
|
+
export_block_id: ids,
|
|
3720
|
+
export_style_attrs: false,
|
|
3721
|
+
export_cite_extra_data: false
|
|
3722
|
+
},
|
|
3723
|
+
...readOption === void 0 ? {} : { read_option: readOption }
|
|
3724
|
+
}
|
|
3725
|
+
})).document;
|
|
3726
|
+
if (document === void 0 || typeof document.content !== "string") throw new FeishuApiError(`${url}: response has no document content`);
|
|
3727
|
+
return {
|
|
3728
|
+
revisionId: document.revision_id ?? 0,
|
|
3729
|
+
content: document.content
|
|
3730
|
+
};
|
|
3731
|
+
},
|
|
3732
|
+
async updateDocument(documentId, update) {
|
|
3733
|
+
const data = await call(request, {
|
|
3734
|
+
method: "PUT",
|
|
3735
|
+
url: `/open-apis/docs_ai/v1/documents/${segment(documentId)}`,
|
|
3736
|
+
data: {
|
|
3737
|
+
format: update.format,
|
|
3738
|
+
command: update.command,
|
|
3739
|
+
...update.content === void 0 ? {} : { content: update.content },
|
|
3740
|
+
...update.pattern === void 0 ? {} : { pattern: update.pattern },
|
|
3741
|
+
...update.blockId === void 0 ? {} : { block_id: update.blockId },
|
|
3742
|
+
...update.startBlockId === void 0 ? {} : { start_block_id: update.startBlockId },
|
|
3743
|
+
...update.endBlockId === void 0 ? {} : { end_block_id: update.endBlockId },
|
|
3744
|
+
...update.revisionId === void 0 ? {} : { revision_id: update.revisionId }
|
|
3745
|
+
}
|
|
3746
|
+
});
|
|
3747
|
+
const warnings = Array.isArray(data.warnings) ? data.warnings.filter((item) => typeof item === "string" && item !== "") : [];
|
|
3748
|
+
return {
|
|
3749
|
+
result: data.result ?? "success",
|
|
3750
|
+
...data.document?.revision_id === void 0 ? {} : { revisionId: data.document.revision_id },
|
|
3751
|
+
...data.updated_blocks_count === void 0 ? {} : { updatedBlocks: data.updated_blocks_count },
|
|
3752
|
+
warnings
|
|
3753
|
+
};
|
|
3754
|
+
}
|
|
3755
|
+
};
|
|
3756
|
+
}
|
|
3757
|
+
//#endregion
|
|
3758
|
+
//#region src/feishu/wiki/base-api.ts
|
|
3759
|
+
/**
|
|
3760
|
+
* Feishu Base (多维表格) calls used by the wiki base tools, made with the app
|
|
3761
|
+
* identity (tenant access token).
|
|
3762
|
+
*
|
|
3763
|
+
* Everything goes through `base/v3/bases/:base_token/...` (the legacy
|
|
3764
|
+
* `bitable/v1` API is not used). Record reads return a column matrix that
|
|
3765
|
+
* this module turns into one field map per record; record writes take flat
|
|
3766
|
+
* maps keyed by field name or field id. Writes carry no precondition, are
|
|
3767
|
+
* applied asynchronously (a read straight after a write may not show it), and
|
|
3768
|
+
* must not run in parallel on one table.
|
|
3769
|
+
*
|
|
3770
|
+
* @module dsh-channels/feishu/wiki/base-api
|
|
3771
|
+
*/
|
|
3772
|
+
function stringArray(value) {
|
|
3773
|
+
return Array.isArray(value) && value.every((item) => typeof item === "string") ? value : void 0;
|
|
3774
|
+
}
|
|
3775
|
+
function ignoredFieldsOf(value) {
|
|
3776
|
+
if (!Array.isArray(value)) return [];
|
|
3777
|
+
return value.flatMap((item) => {
|
|
3778
|
+
if (typeof item !== "object" || item === null) return [];
|
|
3779
|
+
const { name, id, reason } = item;
|
|
3780
|
+
const label = typeof name === "string" && name !== "" ? name : typeof id === "string" ? id : "?";
|
|
3781
|
+
return [typeof reason === "string" && reason !== "" ? `${label}: ${reason}` : label];
|
|
3782
|
+
});
|
|
3783
|
+
}
|
|
3784
|
+
function recordPage(raw, where) {
|
|
3785
|
+
const names = stringArray(raw.fields) ?? [];
|
|
3786
|
+
const ids = stringArray(raw.record_id_list) ?? [];
|
|
3787
|
+
const rows = Array.isArray(raw.data) ? raw.data : [];
|
|
3788
|
+
if (rows.length !== ids.length) throw new FeishuApiError(`${where}: record ids and rows differ in length`);
|
|
3789
|
+
return {
|
|
3790
|
+
records: ids.map((recordId, index) => {
|
|
3791
|
+
const row = rows[index];
|
|
3792
|
+
if (!Array.isArray(row) || row.length !== names.length) throw new FeishuApiError(`${where}: row ${index} does not match the field list`);
|
|
3793
|
+
return {
|
|
3794
|
+
recordId,
|
|
3795
|
+
fields: Object.fromEntries(names.map((name, column) => [name, row[column] ?? null]))
|
|
3796
|
+
};
|
|
3797
|
+
}),
|
|
3798
|
+
hasMore: raw.has_more === true,
|
|
3799
|
+
...typeof raw.rev === "number" ? { rev: raw.rev } : {},
|
|
3800
|
+
...typeof raw.timezone === "string" && raw.timezone !== "" ? { timezone: raw.timezone } : {},
|
|
3801
|
+
ignoredFields: ignoredFieldsOf(raw.ignored_fields)
|
|
3802
|
+
};
|
|
3803
|
+
}
|
|
3804
|
+
/**
|
|
3805
|
+
* {@link BaseApi} over the SDK's generic authenticated request.
|
|
3806
|
+
*
|
|
3807
|
+
* @param request `Client.request` of the connected SDK client.
|
|
3808
|
+
* @returns The API.
|
|
3809
|
+
*/
|
|
3810
|
+
function createBaseApi(request) {
|
|
3811
|
+
const tablePath = (baseToken, tableId) => `/open-apis/base/v3/bases/${segment(baseToken)}/tables/${segment(tableId)}`;
|
|
3812
|
+
return {
|
|
3813
|
+
async listTables(baseToken) {
|
|
3814
|
+
const data = await call(request, {
|
|
3815
|
+
method: "GET",
|
|
3816
|
+
url: `/open-apis/base/v3/bases/${segment(baseToken)}/tables`,
|
|
3817
|
+
params: {
|
|
3818
|
+
offset: 0,
|
|
3819
|
+
limit: 300
|
|
3820
|
+
}
|
|
3821
|
+
});
|
|
3822
|
+
return (Array.isArray(data.tables) ? data.tables : Array.isArray(data.items) ? data.items : []).flatMap((item) => {
|
|
3823
|
+
if (typeof item !== "object" || item === null) return [];
|
|
3824
|
+
const { id, table_id, name, table_name } = item;
|
|
3825
|
+
const tableId = typeof id === "string" ? id : typeof table_id === "string" ? table_id : void 0;
|
|
3826
|
+
if (tableId === void 0) return [];
|
|
3827
|
+
return [{
|
|
3828
|
+
tableId,
|
|
3829
|
+
name: typeof name === "string" ? name : typeof table_name === "string" ? table_name : ""
|
|
3830
|
+
}];
|
|
3831
|
+
});
|
|
3832
|
+
},
|
|
3833
|
+
async listFields(baseToken, tableId) {
|
|
3834
|
+
const data = await call(request, {
|
|
3835
|
+
method: "GET",
|
|
3836
|
+
url: `${tablePath(baseToken, tableId)}/fields`,
|
|
3837
|
+
params: {
|
|
3838
|
+
offset: 0,
|
|
3839
|
+
limit: 300
|
|
3840
|
+
}
|
|
3841
|
+
});
|
|
3842
|
+
return (Array.isArray(data.fields) ? data.fields : Array.isArray(data.items) ? data.items : []).flatMap((item) => {
|
|
3843
|
+
if (typeof item !== "object" || item === null) return [];
|
|
3844
|
+
const { id, field_id, name, field_name, type, ...properties } = item;
|
|
3845
|
+
const fieldId = typeof id === "string" ? id : typeof field_id === "string" ? field_id : void 0;
|
|
3846
|
+
if (fieldId === void 0) return [];
|
|
3847
|
+
return [{
|
|
3848
|
+
fieldId,
|
|
3849
|
+
name: typeof name === "string" ? name : typeof field_name === "string" ? field_name : "",
|
|
3850
|
+
type: typeof type === "string" ? type : "not_support",
|
|
3851
|
+
properties
|
|
3852
|
+
}];
|
|
3853
|
+
});
|
|
3854
|
+
},
|
|
3855
|
+
async listRecords(baseToken, tableId, query) {
|
|
3856
|
+
const url = `${tablePath(baseToken, tableId)}/records`;
|
|
3857
|
+
const params = new URLSearchParams({
|
|
3858
|
+
offset: String(query.offset),
|
|
3859
|
+
limit: String(query.limit)
|
|
3860
|
+
});
|
|
3861
|
+
for (const field of query.fields ?? []) params.append("field_id", field);
|
|
3862
|
+
if (query.viewId !== void 0) params.set("view_id", query.viewId);
|
|
3863
|
+
if (query.filter !== void 0) params.set("filter", JSON.stringify(query.filter));
|
|
3864
|
+
if (query.sort !== void 0) params.set("sort", JSON.stringify(query.sort));
|
|
3865
|
+
return recordPage(await call(request, {
|
|
3866
|
+
method: "GET",
|
|
3867
|
+
url: `${url}?${params.toString()}`
|
|
3868
|
+
}), url);
|
|
3869
|
+
},
|
|
3870
|
+
async searchRecords(baseToken, tableId, search) {
|
|
3871
|
+
const url = `${tablePath(baseToken, tableId)}/records/search`;
|
|
3872
|
+
return recordPage(await call(request, {
|
|
3873
|
+
method: "POST",
|
|
3874
|
+
url,
|
|
3875
|
+
data: {
|
|
3876
|
+
keyword: search.keyword,
|
|
3877
|
+
search_fields: search.searchFields,
|
|
3878
|
+
...search.fields === void 0 ? {} : { select_fields: search.fields },
|
|
3879
|
+
...search.viewId === void 0 ? {} : { view_id: search.viewId },
|
|
3880
|
+
...search.filter === void 0 ? {} : { filter: search.filter },
|
|
3881
|
+
...search.sort === void 0 ? {} : { sort: search.sort },
|
|
3882
|
+
offset: search.offset,
|
|
3883
|
+
limit: search.limit
|
|
3884
|
+
}
|
|
3885
|
+
}), url);
|
|
3886
|
+
},
|
|
3887
|
+
async createRecords(baseToken, tableId, records) {
|
|
3888
|
+
return stringArray((await call(request, {
|
|
3889
|
+
method: "POST",
|
|
3890
|
+
url: `${tablePath(baseToken, tableId)}/records/batch_create`,
|
|
3891
|
+
data: { create_records: records }
|
|
3892
|
+
})).record_id_list) ?? [];
|
|
3893
|
+
},
|
|
3894
|
+
async updateRecords(baseToken, tableId, updates) {
|
|
3895
|
+
return ignoredFieldsOf((await call(request, {
|
|
3896
|
+
method: "POST",
|
|
3897
|
+
url: `${tablePath(baseToken, tableId)}/records/batch_update`,
|
|
3898
|
+
data: { update_records: updates }
|
|
3899
|
+
})).ignored_fields);
|
|
3900
|
+
},
|
|
3901
|
+
async deleteRecords(baseToken, tableId, recordIds) {
|
|
3902
|
+
await call(request, {
|
|
3903
|
+
method: "POST",
|
|
3904
|
+
url: `${tablePath(baseToken, tableId)}/records/batch_delete`,
|
|
3905
|
+
data: { record_id_list: recordIds }
|
|
3906
|
+
});
|
|
3907
|
+
}
|
|
3908
|
+
};
|
|
3909
|
+
}
|
|
3910
|
+
//#endregion
|
|
3911
|
+
//#region src/feishu/wiki/sheet-api.ts
|
|
3912
|
+
/**
|
|
3913
|
+
* Feishu spreadsheet (电子表格) calls used by the wiki sheet tools, made with
|
|
3914
|
+
* the app identity (tenant access token).
|
|
3915
|
+
*
|
|
3916
|
+
* Everything goes through the `sheet_ai/v2` tool endpoints: `POST
|
|
3917
|
+
* spreadsheets/:token/tools/invoke_read` (or `invoke_write`) with body
|
|
3918
|
+
* `{tool_name, input}`, where `input` is a JSON string that also carries
|
|
3919
|
+
* `excel_id`, and the result is the JSON string `data.output`. Read tools sent
|
|
3920
|
+
* to `invoke_write` (or the reverse) are rejected by the gateway. Writes carry
|
|
3921
|
+
* no base revision and no idempotency key; callers check the workbook
|
|
3922
|
+
* revision before writing.
|
|
3923
|
+
*
|
|
3924
|
+
* @module dsh-channels/feishu/wiki/sheet-api
|
|
3925
|
+
*/
|
|
3926
|
+
function invokePath(token, kind) {
|
|
3927
|
+
return `/open-apis/sheet_ai/v2/spreadsheets/${segment(token)}/tools/invoke_${kind}`;
|
|
3928
|
+
}
|
|
3929
|
+
async function invoke(request, token, kind, toolName, input) {
|
|
3930
|
+
const data = await call(request, {
|
|
3931
|
+
method: "POST",
|
|
3932
|
+
url: invokePath(token, kind),
|
|
3933
|
+
data: {
|
|
3934
|
+
tool_name: toolName,
|
|
3935
|
+
input: JSON.stringify({
|
|
3936
|
+
excel_id: token,
|
|
3937
|
+
...input
|
|
3938
|
+
})
|
|
3939
|
+
}
|
|
3940
|
+
});
|
|
3941
|
+
if (typeof data.output !== "string" || data.output === "") return {};
|
|
3942
|
+
let output;
|
|
3943
|
+
try {
|
|
3944
|
+
output = JSON.parse(data.output);
|
|
3945
|
+
} catch (error) {
|
|
3946
|
+
throw new FeishuApiError(`${toolName}: output is not JSON: ${error instanceof Error ? error.message : String(error)}`);
|
|
3947
|
+
}
|
|
3948
|
+
if (Array.isArray(output)) return { sheets: output };
|
|
3949
|
+
if (typeof output !== "object" || output === null) throw new FeishuApiError(`${toolName}: output is not an object`);
|
|
3950
|
+
return output;
|
|
3951
|
+
}
|
|
3952
|
+
function optionalString(value) {
|
|
3953
|
+
return typeof value === "string" && value !== "" ? value : void 0;
|
|
3954
|
+
}
|
|
3955
|
+
function optionalNumber(value) {
|
|
3956
|
+
return typeof value === "number" && Number.isFinite(value) ? value : void 0;
|
|
3957
|
+
}
|
|
3958
|
+
function sheetInfo(raw, position) {
|
|
3959
|
+
const sheetId = optionalString(raw.sheet_id);
|
|
3960
|
+
if (sheetId === void 0) return void 0;
|
|
3961
|
+
const rowCount = optionalNumber(raw.row_count);
|
|
3962
|
+
const columnCount = optionalNumber(raw.column_count);
|
|
3963
|
+
return {
|
|
3964
|
+
sheetId,
|
|
3965
|
+
title: optionalString(raw.title) ?? optionalString(raw.sheet_name) ?? "",
|
|
3966
|
+
index: optionalNumber(raw.index) ?? position,
|
|
3967
|
+
resourceType: optionalString(raw.resource_type) ?? "sheet",
|
|
3968
|
+
hidden: raw.hidden === true || raw.is_hidden === true,
|
|
3969
|
+
...rowCount === void 0 ? {} : { rowCount },
|
|
3970
|
+
...columnCount === void 0 ? {} : { columnCount }
|
|
3971
|
+
};
|
|
3972
|
+
}
|
|
3973
|
+
function cellOf(value) {
|
|
3974
|
+
if (value === null) return {};
|
|
3975
|
+
if (typeof value === "string" && value.startsWith("=")) return { formula: value };
|
|
3976
|
+
return { value };
|
|
3977
|
+
}
|
|
3978
|
+
/**
|
|
3979
|
+
* {@link SheetApi} over the SDK's generic authenticated request.
|
|
3980
|
+
*
|
|
3981
|
+
* @param request `Client.request` of the connected SDK client.
|
|
3982
|
+
* @returns The API.
|
|
3983
|
+
*/
|
|
3984
|
+
function formulaError(raw) {
|
|
3985
|
+
if (typeof raw !== "object" || raw === null) return [];
|
|
3986
|
+
const entry = raw;
|
|
3987
|
+
const cell = optionalString(entry.cell);
|
|
3988
|
+
if (cell === void 0) return [];
|
|
3989
|
+
const formula = optionalString(entry.formula);
|
|
3990
|
+
const errorType = optionalString(entry.error_type);
|
|
3991
|
+
const detail = optionalString(entry.detail);
|
|
3992
|
+
return [{
|
|
3993
|
+
cell,
|
|
3994
|
+
...formula === void 0 ? {} : { formula },
|
|
3995
|
+
...errorType === void 0 ? {} : { errorType },
|
|
3996
|
+
...detail === void 0 ? {} : { detail }
|
|
3997
|
+
}];
|
|
3998
|
+
}
|
|
3999
|
+
function createSheetApi(request) {
|
|
4000
|
+
return {
|
|
4001
|
+
async workbook(token) {
|
|
4002
|
+
const output = await invoke(request, token, "read", "get_workbook_structure", {});
|
|
4003
|
+
const sheets = (Array.isArray(output.sheets) ? output.sheets : []).flatMap((raw, position) => {
|
|
4004
|
+
const info = typeof raw === "object" && raw !== null ? sheetInfo(raw, position) : void 0;
|
|
4005
|
+
return info === void 0 ? [] : [info];
|
|
4006
|
+
});
|
|
4007
|
+
const revision = optionalNumber(output.revision);
|
|
4008
|
+
return {
|
|
4009
|
+
...revision === void 0 ? {} : { revision },
|
|
4010
|
+
sheets
|
|
4011
|
+
};
|
|
4012
|
+
},
|
|
4013
|
+
async readCsv(token, read) {
|
|
4014
|
+
const output = await invoke(request, token, "read", "get_range_as_csv", {
|
|
4015
|
+
sheet_id: read.sheetId,
|
|
4016
|
+
range: read.range,
|
|
4017
|
+
max_chars: read.maxChars,
|
|
4018
|
+
max_rows: 1e9,
|
|
4019
|
+
skip_hidden: false
|
|
4020
|
+
});
|
|
4021
|
+
const actualRange = optionalString(output.actual_range);
|
|
4022
|
+
const currentRegion = optionalString(output.current_region);
|
|
4023
|
+
const warning = optionalString(output.warning_message);
|
|
4024
|
+
return {
|
|
4025
|
+
csv: typeof output.annotated_csv === "string" ? output.annotated_csv : "",
|
|
4026
|
+
...actualRange === void 0 ? {} : { actualRange },
|
|
4027
|
+
...currentRegion === void 0 ? {} : { currentRegion },
|
|
4028
|
+
hasMore: output.has_more === true,
|
|
4029
|
+
...warning === void 0 ? {} : { warning }
|
|
4030
|
+
};
|
|
4031
|
+
},
|
|
4032
|
+
async writeCells(token, write) {
|
|
4033
|
+
const output = await invoke(request, token, "write", "set_cell_range", {
|
|
4034
|
+
sheet_id: write.sheetId,
|
|
4035
|
+
range: write.range,
|
|
4036
|
+
cells: write.rows.map((row) => row.map(cellOf)),
|
|
4037
|
+
...write.allowOverwrite === false ? { allow_overwrite: false } : {}
|
|
4038
|
+
});
|
|
4039
|
+
return { formulaErrors: (Array.isArray(output.formula_errors) ? output.formula_errors : []).flatMap(formulaError) };
|
|
4040
|
+
}
|
|
4041
|
+
};
|
|
4042
|
+
}
|
|
4043
|
+
//#endregion
|
|
3476
4044
|
//#region src/feishu-channel.ts
|
|
3477
4045
|
/**
|
|
3478
4046
|
* Thin wrapper over the official SDK's `createLarkChannel` high-level module.
|
|
@@ -3665,6 +4233,9 @@ var LarkChannelAdapter = class {
|
|
|
3665
4233
|
requireMention;
|
|
3666
4234
|
dmMode;
|
|
3667
4235
|
slashCommands;
|
|
4236
|
+
wiki;
|
|
4237
|
+
sheets;
|
|
4238
|
+
bases;
|
|
3668
4239
|
constructor(options) {
|
|
3669
4240
|
this.channel = createLarkChannel({
|
|
3670
4241
|
appId: options.appId,
|
|
@@ -3683,6 +4254,10 @@ var LarkChannelAdapter = class {
|
|
|
3683
4254
|
this.dmMode = options.dmMode;
|
|
3684
4255
|
const rawClient = this.channel.rawClient;
|
|
3685
4256
|
this.slashCommands = slashCommandApi(async (input) => await rawClient.request(input));
|
|
4257
|
+
const wikiRequest = async (input) => await rawClient.request(input);
|
|
4258
|
+
this.wiki = createWikiApi(wikiRequest);
|
|
4259
|
+
this.sheets = createSheetApi(wikiRequest);
|
|
4260
|
+
this.bases = createBaseApi(wikiRequest);
|
|
3686
4261
|
}
|
|
3687
4262
|
onMessage(handler) {
|
|
3688
4263
|
return this.channel.on("message", (message) => {
|
|
@@ -4830,7 +5405,7 @@ function resolveAttachmentService(ctx) {
|
|
|
4830
5405
|
if (isAttachmentStore(viaGet)) return viaGet;
|
|
4831
5406
|
} catch {}
|
|
4832
5407
|
}
|
|
4833
|
-
const FAILURE_REASONS$
|
|
5408
|
+
const FAILURE_REASONS$2 = {
|
|
4834
5409
|
TOO_MANY_IMAGES: "图片数量超过单条消息上限",
|
|
4835
5410
|
IMAGES_TOO_LARGE: "图片总字节超过单条消息上限",
|
|
4836
5411
|
UNSUPPORTED_IMAGE_TYPE: "不支持的图片格式(仅支持 PNG/JPEG/WebP/GIF)",
|
|
@@ -4878,7 +5453,7 @@ function isAttachmentErrorCode(code) {
|
|
|
4878
5453
|
}
|
|
4879
5454
|
/** User/model-facing wording for one stable failure code. */
|
|
4880
5455
|
function attachmentFailureReason(code) {
|
|
4881
|
-
return FAILURE_REASONS$
|
|
5456
|
+
return FAILURE_REASONS$2[code];
|
|
4882
5457
|
}
|
|
4883
5458
|
/**
|
|
4884
5459
|
* Extract a host attachment failure code from an arbitrary thrown value.
|
|
@@ -4993,7 +5568,7 @@ var NativeAttachmentStore = class {
|
|
|
4993
5568
|
if (mediaType === void 0) return {
|
|
4994
5569
|
ok: false,
|
|
4995
5570
|
code: "UNSUPPORTED_IMAGE_TYPE",
|
|
4996
|
-
reason: FAILURE_REASONS$
|
|
5571
|
+
reason: FAILURE_REASONS$2.UNSUPPORTED_IMAGE_TYPE
|
|
4997
5572
|
};
|
|
4998
5573
|
const limits = mounted.limits;
|
|
4999
5574
|
if (limits !== void 0) try {
|
|
@@ -5007,7 +5582,7 @@ var NativeAttachmentStore = class {
|
|
|
5007
5582
|
if (mediaTypes !== void 0 && mediaTypes.length > 0 && !mediaTypes.includes(mediaType)) return {
|
|
5008
5583
|
ok: false,
|
|
5009
5584
|
code: "UNSUPPORTED_IMAGE_TYPE",
|
|
5010
|
-
reason: FAILURE_REASONS$
|
|
5585
|
+
reason: FAILURE_REASONS$2.UNSUPPORTED_IMAGE_TYPE
|
|
5011
5586
|
};
|
|
5012
5587
|
} catch {}
|
|
5013
5588
|
const name = sanitizeFileName$1(input.name);
|
|
@@ -5048,7 +5623,7 @@ var NativeAttachmentStore = class {
|
|
|
5048
5623
|
if (input.signal?.aborted === true) return {
|
|
5049
5624
|
ok: false,
|
|
5050
5625
|
code: "ABORTED",
|
|
5051
|
-
reason: FAILURE_REASONS$
|
|
5626
|
+
reason: FAILURE_REASONS$2.ABORTED
|
|
5052
5627
|
};
|
|
5053
5628
|
const name = sanitizeFileName$1(input.name) ?? "file.bin";
|
|
5054
5629
|
try {
|
|
@@ -5126,18 +5701,18 @@ var NativeAttachmentStore = class {
|
|
|
5126
5701
|
if (signal?.aborted === true || isAbortError$2(error)) return {
|
|
5127
5702
|
ok: false,
|
|
5128
5703
|
code: "ABORTED",
|
|
5129
|
-
reason: FAILURE_REASONS$
|
|
5704
|
+
reason: FAILURE_REASONS$2.ABORTED
|
|
5130
5705
|
};
|
|
5131
5706
|
const code = attachmentErrorCodeOf(error, service);
|
|
5132
5707
|
if (code !== void 0) return {
|
|
5133
5708
|
ok: false,
|
|
5134
5709
|
code,
|
|
5135
|
-
reason: FAILURE_REASONS$
|
|
5710
|
+
reason: FAILURE_REASONS$2[code]
|
|
5136
5711
|
};
|
|
5137
5712
|
return {
|
|
5138
5713
|
ok: false,
|
|
5139
5714
|
code: "ATTACHMENT_STORE_ERROR",
|
|
5140
|
-
reason: `${FAILURE_REASONS$
|
|
5715
|
+
reason: `${FAILURE_REASONS$2.ATTACHMENT_STORE_ERROR}:${errorMessage(error)}`
|
|
5141
5716
|
};
|
|
5142
5717
|
}
|
|
5143
5718
|
};
|
|
@@ -5162,7 +5737,7 @@ function readPathOf(ref, kind, service) {
|
|
|
5162
5737
|
if (code !== void 0) return {
|
|
5163
5738
|
ok: false,
|
|
5164
5739
|
code,
|
|
5165
|
-
reason: FAILURE_REASONS$
|
|
5740
|
+
reason: FAILURE_REASONS$2[code]
|
|
5166
5741
|
};
|
|
5167
5742
|
return {
|
|
5168
5743
|
ok: false,
|
|
@@ -5361,7 +5936,7 @@ var InboundAttachmentStore = class {
|
|
|
5361
5936
|
if (resource.folder === true) {
|
|
5362
5937
|
failures.push({
|
|
5363
5938
|
name,
|
|
5364
|
-
reason: FAILURE_REASONS$
|
|
5939
|
+
reason: FAILURE_REASONS$2.FOLDER_UNSUPPORTED
|
|
5365
5940
|
});
|
|
5366
5941
|
continue;
|
|
5367
5942
|
}
|
|
@@ -6290,9 +6865,26 @@ var SessionManager = class {
|
|
|
6290
6865
|
const EXPIRED_ROUTE_TEXT = "该话题已超过路由保留时间,并已与原 Session 解绑。为避免误接续上下文,插件不会在此话题创建新 Session;请从私聊或群聊主会话发起新消息(群聊请 @ 机器人)创建新话题。";
|
|
6291
6866
|
var CardDelivery = class {
|
|
6292
6867
|
deps;
|
|
6868
|
+
/** Open `replacingCard` scopes per message id; overlapping clicks nest. */
|
|
6869
|
+
inPlace = /* @__PURE__ */ new Map();
|
|
6293
6870
|
constructor(deps) {
|
|
6294
6871
|
this.deps = deps;
|
|
6295
6872
|
}
|
|
6873
|
+
/**
|
|
6874
|
+
* Run `operation` with every `standaloneReply` anchored to `messageId`
|
|
6875
|
+
* patched into that (bot-sent card) message instead of replying to it.
|
|
6876
|
+
* Used for picker clicks, whose synthetic message id is the picker card.
|
|
6877
|
+
*/
|
|
6878
|
+
async replacingCard(messageId, operation) {
|
|
6879
|
+
this.inPlace.set(messageId, (this.inPlace.get(messageId) ?? 0) + 1);
|
|
6880
|
+
try {
|
|
6881
|
+
await operation();
|
|
6882
|
+
} finally {
|
|
6883
|
+
const remaining = (this.inPlace.get(messageId) ?? 1) - 1;
|
|
6884
|
+
if (remaining === 0) this.inPlace.delete(messageId);
|
|
6885
|
+
else this.inPlace.set(messageId, remaining);
|
|
6886
|
+
}
|
|
6887
|
+
}
|
|
6296
6888
|
/** The live channel; throws instead of silently dropping a delivery. */
|
|
6297
6889
|
requireChannel() {
|
|
6298
6890
|
const channel = this.deps.channel();
|
|
@@ -6326,13 +6918,22 @@ var CardDelivery = class {
|
|
|
6326
6918
|
/**
|
|
6327
6919
|
* Shared delivery policy for command-plane cards: reply in a new topic by
|
|
6328
6920
|
* default, then degrade to a plain reply when Feishu refuses the topic.
|
|
6921
|
+
* `createThread: false` replies in the chat timeline, or inside the topic
|
|
6922
|
+
* the message already belongs to. Inside `replacingCard` the card patches
|
|
6923
|
+
* the anchored card instead, and falls back to a reply if the patch fails.
|
|
6329
6924
|
*/
|
|
6330
6925
|
async standaloneReply(msg, card, options = {}) {
|
|
6331
6926
|
const channel = this.deps.channel();
|
|
6332
6927
|
if (!this.deps.started() || channel === void 0) return;
|
|
6928
|
+
if (this.inPlace.has(msg.messageId)) try {
|
|
6929
|
+
await channel.updateCard(msg.messageId, card);
|
|
6930
|
+
return;
|
|
6931
|
+
} catch (error) {
|
|
6932
|
+
this.deps.warn("dsh-channels: in-place update of card %s failed, replying instead: %s", msg.messageId, String(error));
|
|
6933
|
+
}
|
|
6333
6934
|
const createThread = options.createThread ?? true;
|
|
6334
6935
|
try {
|
|
6335
|
-
await channel.send(msg.chatId, { card }, createThread ? {
|
|
6936
|
+
await channel.send(msg.chatId, { card }, createThread || msg.threadId !== void 0 ? {
|
|
6336
6937
|
replyTo: msg.messageId,
|
|
6337
6938
|
replyInThread: true
|
|
6338
6939
|
} : { replyTo: msg.messageId });
|
|
@@ -6437,7 +7038,7 @@ function messageReadFailureReason(code) {
|
|
|
6437
7038
|
default: return "读取飞书消息失败";
|
|
6438
7039
|
}
|
|
6439
7040
|
}
|
|
6440
|
-
function failure$
|
|
7041
|
+
function failure$3(code, reason = messageReadFailureReason(code)) {
|
|
6441
7042
|
return {
|
|
6442
7043
|
ok: false,
|
|
6443
7044
|
code,
|
|
@@ -6770,7 +7371,10 @@ function messageTextOf(item) {
|
|
|
6770
7371
|
return name === void 0 ? "[文件夹]" : `[文件夹 ${name}]`;
|
|
6771
7372
|
}
|
|
6772
7373
|
case "merge_forward": return "[合并转发]";
|
|
6773
|
-
case "interactive":
|
|
7374
|
+
case "interactive": {
|
|
7375
|
+
const text = content === void 0 ? "" : cardText(content);
|
|
7376
|
+
return text === "" ? "[卡片消息]" : `[卡片] ${text}`;
|
|
7377
|
+
}
|
|
6774
7378
|
case "audio": return "[语音]";
|
|
6775
7379
|
case "video": return "[视频]";
|
|
6776
7380
|
case "sticker": return "[表情]";
|
|
@@ -6778,6 +7382,84 @@ function messageTextOf(item) {
|
|
|
6778
7382
|
default: return item.rawContent === void 0 || item.rawContent === "" ? `[${item.msgType} 消息]` : sanitizeExcerpt(item.rawContent);
|
|
6779
7383
|
}
|
|
6780
7384
|
}
|
|
7385
|
+
/** Deepest card node visited by {@link cardText}; real cards nest far less. */
|
|
7386
|
+
const MAX_CARD_DEPTH = 12;
|
|
7387
|
+
/**
|
|
7388
|
+
* Card keys whose values are never reader-visible text: callback payloads,
|
|
7389
|
+
* link targets, and layout/style settings.
|
|
7390
|
+
*/
|
|
7391
|
+
const CARD_SKIPPED_KEYS = /* @__PURE__ */ new Set([
|
|
7392
|
+
"value",
|
|
7393
|
+
"behaviors",
|
|
7394
|
+
"url",
|
|
7395
|
+
"href",
|
|
7396
|
+
"multi_url",
|
|
7397
|
+
"image_key",
|
|
7398
|
+
"img_key",
|
|
7399
|
+
"icon",
|
|
7400
|
+
"style",
|
|
7401
|
+
"config",
|
|
7402
|
+
"confirm",
|
|
7403
|
+
"i18n_elements",
|
|
7404
|
+
"i18n_header",
|
|
7405
|
+
"user_id",
|
|
7406
|
+
"open_id"
|
|
7407
|
+
]);
|
|
7408
|
+
/**
|
|
7409
|
+
* Flatten one card body into text. Handles both the simplified structure the
|
|
7410
|
+
* message read APIs return by default (`{ title, elements: [[...]] }`) and a
|
|
7411
|
+
* sent card's JSON 1.0/2.0 (`header.title`, `elements` or `body.elements`):
|
|
7412
|
+
* every `text`/`content` string and `plain_text`/`lark_md` object in reading
|
|
7413
|
+
* order, images as `[图片]`. A Card 1.0 localized only through
|
|
7414
|
+
* `i18n_header`/`i18n_elements` renders one locale. Returns `''` when no text
|
|
7415
|
+
* is found.
|
|
7416
|
+
*/
|
|
7417
|
+
function cardText(content) {
|
|
7418
|
+
const parts = [];
|
|
7419
|
+
const title = stringField$1(content, "title");
|
|
7420
|
+
if (title !== void 0) parts.push(title);
|
|
7421
|
+
collectCardText(asRecord$2(content.header) ?? localizedBranch(content.i18n_header), parts, 0);
|
|
7422
|
+
collectCardText(content.elements ?? asRecord$2(content.body)?.elements ?? localizedBranch(content.i18n_elements), parts, 0);
|
|
7423
|
+
return parts.map((part) => part.trim()).filter((part) => part !== "").join(" ");
|
|
7424
|
+
}
|
|
7425
|
+
/**
|
|
7426
|
+
* One locale branch of a Card 1.0 `i18n_*` map: `zh_cn` when present,
|
|
7427
|
+
* otherwise the first branch, so a card localized only through `i18n_*`
|
|
7428
|
+
* still renders once instead of once per locale.
|
|
7429
|
+
*/
|
|
7430
|
+
function localizedBranch(value) {
|
|
7431
|
+
const branches = asRecord$2(value);
|
|
7432
|
+
if (branches === void 0) return void 0;
|
|
7433
|
+
return branches.zh_cn ?? Object.values(branches)[0];
|
|
7434
|
+
}
|
|
7435
|
+
/** Append the reader-visible text of one card node to `parts`. */
|
|
7436
|
+
function collectCardText(node, parts, depth) {
|
|
7437
|
+
if (depth > MAX_CARD_DEPTH) return;
|
|
7438
|
+
if (Array.isArray(node)) {
|
|
7439
|
+
for (const child of node) collectCardText(child, parts, depth + 1);
|
|
7440
|
+
return;
|
|
7441
|
+
}
|
|
7442
|
+
const record = asRecord$2(node);
|
|
7443
|
+
if (record === void 0) return;
|
|
7444
|
+
const tag = stringField$1(record, "tag");
|
|
7445
|
+
if (tag === "img" || tag === "image") {
|
|
7446
|
+
parts.push("[图片]");
|
|
7447
|
+
return;
|
|
7448
|
+
}
|
|
7449
|
+
if (tag === "at") {
|
|
7450
|
+
const name = stringField$1(record, "user_name");
|
|
7451
|
+
parts.push(name === void 0 ? "@" : `@${name}`);
|
|
7452
|
+
return;
|
|
7453
|
+
}
|
|
7454
|
+
for (const [key, value] of Object.entries(record)) {
|
|
7455
|
+
if (CARD_SKIPPED_KEYS.has(key)) continue;
|
|
7456
|
+
if (typeof value === "string") {
|
|
7457
|
+
if (key === "text" || key === "content") parts.push(value);
|
|
7458
|
+
continue;
|
|
7459
|
+
}
|
|
7460
|
+
collectCardText(value, parts, depth + 1);
|
|
7461
|
+
}
|
|
7462
|
+
}
|
|
6781
7463
|
/** Flatten one post body into text; inline images/files become placeholders. */
|
|
6782
7464
|
function postText(content) {
|
|
6783
7465
|
const lines = [];
|
|
@@ -7030,7 +7712,7 @@ var MessageReader = class {
|
|
|
7030
7712
|
* flat container + children list the P0 probe documented.
|
|
7031
7713
|
*/
|
|
7032
7714
|
async readMessage(messageId, options = {}) {
|
|
7033
|
-
if (messageId === "") return failure$
|
|
7715
|
+
if (messageId === "") return failure$3("INVALID_ARGUMENT", "message_id 不能为空");
|
|
7034
7716
|
return await this.readWith((channel) => {
|
|
7035
7717
|
const get = channel.messageGet;
|
|
7036
7718
|
if (get === void 0) throw new Error("当前飞书通道不支持读取消息内容(message.get 不可用)");
|
|
@@ -7043,19 +7725,19 @@ var MessageReader = class {
|
|
|
7043
7725
|
* calls so the same public behavior is available everywhere.
|
|
7044
7726
|
*/
|
|
7045
7727
|
async readMessages(messageIds, options = {}) {
|
|
7046
|
-
if (messageIds.length === 0) return failure$
|
|
7047
|
-
if (messageIds.length > 50) return failure$
|
|
7728
|
+
if (messageIds.length === 0) return failure$3("INVALID_ARGUMENT", "message_ids 不能为空");
|
|
7729
|
+
if (messageIds.length > 50) return failure$3("INVALID_ARGUMENT", `一次最多批量读取 50 条消息(本次 ${messageIds.length} 条)`);
|
|
7048
7730
|
const deduped = [...new Set(messageIds)];
|
|
7049
|
-
if (options.signal?.aborted === true) return failure$
|
|
7731
|
+
if (options.signal?.aborted === true) return failure$3("ABORTED");
|
|
7050
7732
|
const scope = createReadScope(options);
|
|
7051
7733
|
try {
|
|
7052
7734
|
const channel = this.channelOrUndefined();
|
|
7053
|
-
if (channel === void 0) return failure$
|
|
7054
|
-
if (scope.signal.aborted) return failure$
|
|
7735
|
+
if (channel === void 0) return failure$3("CHANNEL_UNAVAILABLE");
|
|
7736
|
+
if (scope.signal.aborted) return failure$3("ABORTED");
|
|
7055
7737
|
if (channel.messageMget !== void 0) {
|
|
7056
7738
|
const response = await cancellable$1(Promise.resolve(channel.messageMget.call(channel, deduped)), scope.signal);
|
|
7057
7739
|
const items = parseMessageItems(response);
|
|
7058
|
-
if (items === void 0) return failureFromResponse(response) ?? failure$
|
|
7740
|
+
if (items === void 0) return failureFromResponse(response) ?? failure$3("MESSAGE_READ_FAILED", "飞书批量读取消息返回了无法识别的结果");
|
|
7059
7741
|
return {
|
|
7060
7742
|
ok: true,
|
|
7061
7743
|
value: items
|
|
@@ -7063,12 +7745,12 @@ var MessageReader = class {
|
|
|
7063
7745
|
}
|
|
7064
7746
|
const items = [];
|
|
7065
7747
|
for (const id of deduped) {
|
|
7066
|
-
if (scope.signal.aborted) return failure$
|
|
7748
|
+
if (scope.signal.aborted) return failure$3("ABORTED");
|
|
7067
7749
|
const get = channel.messageGet;
|
|
7068
|
-
if (get === void 0) return failure$
|
|
7750
|
+
if (get === void 0) return failure$3("MESSAGE_READ_FAILED", "当前飞书通道不支持读取消息内容(message.get 不可用)");
|
|
7069
7751
|
const response = await cancellable$1(Promise.resolve(get.call(channel, id)), scope.signal);
|
|
7070
7752
|
const parsed = parseMessageItems(response);
|
|
7071
|
-
if (parsed === void 0) return failureFromResponse(response) ?? failure$
|
|
7753
|
+
if (parsed === void 0) return failureFromResponse(response) ?? failure$3("MESSAGE_READ_FAILED", "飞书读取消息返回了无法识别的结果");
|
|
7072
7754
|
items.push(...parsed);
|
|
7073
7755
|
}
|
|
7074
7756
|
return {
|
|
@@ -7083,12 +7765,12 @@ var MessageReader = class {
|
|
|
7083
7765
|
}
|
|
7084
7766
|
/** List one topic's messages (`container_id_type=thread`). */
|
|
7085
7767
|
async listThread(threadId, options = {}) {
|
|
7086
|
-
if (threadId === "") return failure$
|
|
7768
|
+
if (threadId === "") return failure$3("INVALID_ARGUMENT", "thread id 不能为空");
|
|
7087
7769
|
return await this.listContainer("thread", threadId, options);
|
|
7088
7770
|
}
|
|
7089
7771
|
/** List one chat's main timeline (`container_id_type=chat`). */
|
|
7090
7772
|
async listChat(chatId, options = {}) {
|
|
7091
|
-
if (chatId === "") return failure$
|
|
7773
|
+
if (chatId === "") return failure$3("INVALID_ARGUMENT", "chat id 不能为空");
|
|
7092
7774
|
return await this.listContainer("chat", chatId, options);
|
|
7093
7775
|
}
|
|
7094
7776
|
/**
|
|
@@ -7129,14 +7811,14 @@ var MessageReader = class {
|
|
|
7129
7811
|
}
|
|
7130
7812
|
/** Shared call/parse/failure plumbing of every wrapper. */
|
|
7131
7813
|
async readWith(operation, parse, options) {
|
|
7132
|
-
if (options.signal?.aborted === true) return failure$
|
|
7814
|
+
if (options.signal?.aborted === true) return failure$3("ABORTED");
|
|
7133
7815
|
const channel = this.channelOrUndefined();
|
|
7134
|
-
if (channel === void 0) return failure$
|
|
7816
|
+
if (channel === void 0) return failure$3("CHANNEL_UNAVAILABLE");
|
|
7135
7817
|
const scope = createReadScope(options);
|
|
7136
7818
|
try {
|
|
7137
7819
|
const response = await cancellable$1(operation(channel), scope.signal);
|
|
7138
7820
|
const parsed = parse(response);
|
|
7139
|
-
if (parsed === void 0) return failureFromResponse(response) ?? failure$
|
|
7821
|
+
if (parsed === void 0) return failureFromResponse(response) ?? failure$3("MESSAGE_READ_FAILED", "飞书消息接口返回了无法识别的结果");
|
|
7140
7822
|
return {
|
|
7141
7823
|
ok: true,
|
|
7142
7824
|
value: parsed
|
|
@@ -7296,8 +7978,8 @@ function failureFromResponse(response) {
|
|
|
7296
7978
|
const msg = stringField$1(root, "msg") ?? stringField$1(body, "msg");
|
|
7297
7979
|
const permission = code === 99991672 || code === 99991679 || code === 99991663 || msg !== void 0 && /permission|scope|forbidden|not authorized|无权限/iu.test(msg);
|
|
7298
7980
|
const detail = sanitizePlainText(`code=${code}${msg === void 0 ? "" : ` ${msg}`}`);
|
|
7299
|
-
if (permission) return failure$
|
|
7300
|
-
return failure$
|
|
7981
|
+
if (permission) return failure$3("PERMISSION_DENIED", `${messageReadFailureReason("PERMISSION_DENIED")}(${detail})`);
|
|
7982
|
+
return failure$3("MESSAGE_READ_FAILED", `${messageReadFailureReason("MESSAGE_READ_FAILED")}:${detail}`);
|
|
7301
7983
|
}
|
|
7302
7984
|
/**
|
|
7303
7985
|
* Map one thrown Feishu/HTTP error into a structured failure. Permission
|
|
@@ -7305,8 +7987,8 @@ function failureFromResponse(response) {
|
|
|
7305
7987
|
* without treating the turn as failed (AC6).
|
|
7306
7988
|
*/
|
|
7307
7989
|
function classifyReadError(error, timedOut = false) {
|
|
7308
|
-
if (timedOut) return failure$
|
|
7309
|
-
if (isAbortError$1(error)) return failure$
|
|
7990
|
+
if (timedOut) return failure$3("TIMEOUT", messageReadFailureReason("TIMEOUT"));
|
|
7991
|
+
if (isAbortError$1(error)) return failure$3("ABORTED", messageReadFailureReason("ABORTED"));
|
|
7310
7992
|
const record = asRecord$2(error);
|
|
7311
7993
|
const response = asRecord$2(record?.response);
|
|
7312
7994
|
const responseData = asRecord$2(response?.data);
|
|
@@ -7316,8 +7998,8 @@ function classifyReadError(error, timedOut = false) {
|
|
|
7316
7998
|
const message = apiMessage ?? (error instanceof Error ? error.message : void 0);
|
|
7317
7999
|
const permission = status === 403 || status === 401 || code === 99991672 || code === 99991679 || code === 99991663 || message !== void 0 && /permission|scope|forbidden|not authorized|无权限/iu.test(message);
|
|
7318
8000
|
const detail = code !== void 0 || apiMessage !== void 0 ? sanitizePlainText([code === void 0 ? void 0 : `code=${code}`, apiMessage].filter(Boolean).join(" ")) : describeReadError(error);
|
|
7319
|
-
if (permission) return failure$
|
|
7320
|
-
return failure$
|
|
8001
|
+
if (permission) return failure$3("PERMISSION_DENIED", `${messageReadFailureReason("PERMISSION_DENIED")}(${detail})`);
|
|
8002
|
+
return failure$3("MESSAGE_READ_FAILED", `${messageReadFailureReason("MESSAGE_READ_FAILED")}:${detail}`);
|
|
7321
8003
|
}
|
|
7322
8004
|
//#endregion
|
|
7323
8005
|
//#region src/feishu/tools.ts
|
|
@@ -7460,7 +8142,7 @@ function attachmentFetchFailureReason(code) {
|
|
|
7460
8142
|
const host = attachmentFailureReason(code);
|
|
7461
8143
|
return typeof host === "string" && host.length > 0 ? host : FETCH_FAILURE_REASONS.INTERNAL_ERROR ?? "附件获取失败";
|
|
7462
8144
|
}
|
|
7463
|
-
function failure$
|
|
8145
|
+
function failure$2(code, reason) {
|
|
7464
8146
|
return {
|
|
7465
8147
|
ok: false,
|
|
7466
8148
|
code,
|
|
@@ -7692,9 +8374,9 @@ var AttachmentFetchToolRuntime = class {
|
|
|
7692
8374
|
}
|
|
7693
8375
|
const args = parsed.data;
|
|
7694
8376
|
const messageId = args.message_id;
|
|
7695
|
-
if (!this.enabled()) return callFailure(messageId, failure$
|
|
8377
|
+
if (!this.enabled()) return callFailure(messageId, failure$2("TOOL_DISABLED"));
|
|
7696
8378
|
const route = this.deps.routeOfSession(sessionId);
|
|
7697
|
-
if (route === void 0) return callFailure(messageId, failure$
|
|
8379
|
+
if (route === void 0) return callFailure(messageId, failure$2("ROUTE_NOT_FOUND"));
|
|
7698
8380
|
const config = this.deps.config().feishuTools;
|
|
7699
8381
|
const scope = createAbortScope(exec.signal, config.attachmentFetchTimeoutMs);
|
|
7700
8382
|
const budget = {
|
|
@@ -7716,8 +8398,8 @@ var AttachmentFetchToolRuntime = class {
|
|
|
7716
8398
|
try {
|
|
7717
8399
|
return await this.fetch(call);
|
|
7718
8400
|
} catch (error) {
|
|
7719
|
-
if (budget.exceeded) return callFailure(messageId, failure$
|
|
7720
|
-
if (scope.signal.aborted) return callFailure(messageId, failure$
|
|
8401
|
+
if (budget.exceeded) return callFailure(messageId, failure$2("BYTES_LIMIT"));
|
|
8402
|
+
if (scope.signal.aborted) return callFailure(messageId, failure$2(scope.timeout.value ? "TIMEOUT" : "ABORTED"));
|
|
7721
8403
|
const reason = `${attachmentFetchFailureReason("INTERNAL_ERROR")}:${describeUnexpectedError(error)}`;
|
|
7722
8404
|
this.deps.warn("dsh-channels: %s failed unexpectedly: %s", ATTACHMENT_FETCH_TOOL_NAME, reason);
|
|
7723
8405
|
return callFailure(messageId, {
|
|
@@ -7731,11 +8413,11 @@ var AttachmentFetchToolRuntime = class {
|
|
|
7731
8413
|
}
|
|
7732
8414
|
async fetch(call) {
|
|
7733
8415
|
const messageId = call.args.message_id;
|
|
7734
|
-
if (call.signal.aborted) return callFailure(messageId, failure$
|
|
8416
|
+
if (call.signal.aborted) return callFailure(messageId, failure$2(call.timeout.value ? "TIMEOUT" : "ABORTED"));
|
|
7735
8417
|
const known = await this.resolveKnownMessage(call, messageId);
|
|
7736
8418
|
if (known === void 0) {
|
|
7737
|
-
if (call.signal.aborted) return callFailure(messageId, failure$
|
|
7738
|
-
return callFailure(messageId, failure$
|
|
8419
|
+
if (call.signal.aborted) return callFailure(messageId, failure$2(call.timeout.value ? "TIMEOUT" : "ABORTED"));
|
|
8420
|
+
return callFailure(messageId, failure$2("AUTHORIZATION_DENIED"));
|
|
7739
8421
|
}
|
|
7740
8422
|
let resources = [...known.resources];
|
|
7741
8423
|
if (resources.length === 0) {
|
|
@@ -7752,11 +8434,11 @@ var AttachmentFetchToolRuntime = class {
|
|
|
7752
8434
|
const shown = knownKeys.slice(0, 10);
|
|
7753
8435
|
const hidden = knownKeys.length - shown.length;
|
|
7754
8436
|
const available = knownKeys.length === 0 ? "" : `(可用:${shown.join("、")}${hidden > 0 ? ` 等 ${knownKeys.length} 个` : ""})`;
|
|
7755
|
-
return callFailure(messageId, failure$
|
|
8437
|
+
return callFailure(messageId, failure$2("UNKNOWN_FILE_KEY", `${attachmentFetchFailureReason("UNKNOWN_FILE_KEY")}:${displayText$1(key)}${available}`));
|
|
7756
8438
|
}
|
|
7757
8439
|
}
|
|
7758
|
-
if (resources.length === 0) return callFailure(messageId, failure$
|
|
7759
|
-
if (resources.length > call.limits.maxResources) return callFailure(messageId, failure$
|
|
8440
|
+
if (resources.length === 0) return callFailure(messageId, failure$2("NO_RESOURCES"));
|
|
8441
|
+
if (resources.length > call.limits.maxResources) return callFailure(messageId, failure$2("RESOURCE_LIMIT", `${attachmentFetchFailureReason("RESOURCE_LIMIT")}(本消息 ${resources.length} 个,单次上限 ${call.limits.maxResources} 个;请用 file_key 分批获取)`));
|
|
7760
8442
|
this.rememberKnownMessage(call, known, allResources);
|
|
7761
8443
|
const results = [];
|
|
7762
8444
|
for (let index = 0; index < resources.length; index += 1) {
|
|
@@ -7768,12 +8450,12 @@ var AttachmentFetchToolRuntime = class {
|
|
|
7768
8450
|
break;
|
|
7769
8451
|
}
|
|
7770
8452
|
if (call.budget.exceeded) {
|
|
7771
|
-
results.push(failedItem(resource, failure$
|
|
7772
|
-
for (const rest of resources.slice(index + 1)) results.push(failedItem(rest, failure$
|
|
8453
|
+
results.push(failedItem(resource, failure$2("BYTES_LIMIT")));
|
|
8454
|
+
for (const rest of resources.slice(index + 1)) results.push(failedItem(rest, failure$2("BYTES_LIMIT")));
|
|
7773
8455
|
break;
|
|
7774
8456
|
}
|
|
7775
8457
|
if (call.channel === void 0) {
|
|
7776
|
-
results.push(failedItem(resource, failure$
|
|
8458
|
+
results.push(failedItem(resource, failure$2("CHANNEL_UNAVAILABLE")));
|
|
7777
8459
|
continue;
|
|
7778
8460
|
}
|
|
7779
8461
|
results.push(await this.fetchResource(call, known, resource));
|
|
@@ -7825,18 +8507,18 @@ var AttachmentFetchToolRuntime = class {
|
|
|
7825
8507
|
*/
|
|
7826
8508
|
async expandMessageResources(call, message) {
|
|
7827
8509
|
const channel = call.channel;
|
|
7828
|
-
if (channel === void 0) return failure$
|
|
8510
|
+
if (channel === void 0) return failure$2("CHANNEL_UNAVAILABLE");
|
|
7829
8511
|
const messageGet = channel.messageGet;
|
|
7830
|
-
if (messageGet === void 0) return failure$
|
|
8512
|
+
if (messageGet === void 0) return failure$2("MESSAGE_READ_FAILED", `${attachmentFetchFailureReason("MESSAGE_READ_FAILED")}:当前飞书通道不支持读取消息内容,无法展开附件`);
|
|
7831
8513
|
let response;
|
|
7832
8514
|
try {
|
|
7833
8515
|
response = await cancellable(Promise.resolve(messageGet.call(channel, message.messageId)), call.signal);
|
|
7834
8516
|
} catch (error) {
|
|
7835
8517
|
if (isAbortError(error)) throw error;
|
|
7836
|
-
return failure$
|
|
8518
|
+
return failure$2("MESSAGE_READ_FAILED", `${attachmentFetchFailureReason("MESSAGE_READ_FAILED")}:${describeFetchError(error)}`);
|
|
7837
8519
|
}
|
|
7838
8520
|
const items = rawMessageItems(response);
|
|
7839
|
-
if (items === void 0) return failure$
|
|
8521
|
+
if (items === void 0) return failure$2("MESSAGE_READ_FAILED", `${attachmentFetchFailureReason("MESSAGE_READ_FAILED")}:返回内容无法识别`);
|
|
7840
8522
|
return {
|
|
7841
8523
|
ok: true,
|
|
7842
8524
|
resources: message.kind === "merge_forward" ? mergedForwardResources(items) : messageResourcesOfItems(items)
|
|
@@ -7844,17 +8526,17 @@ var AttachmentFetchToolRuntime = class {
|
|
|
7844
8526
|
}
|
|
7845
8527
|
async fetchResource(call, message, resource) {
|
|
7846
8528
|
const downloadId = resource.containerMessageId ?? message.messageId;
|
|
7847
|
-
if (resource.folder === true) return failedItem(resource, failure$
|
|
8529
|
+
if (resource.folder === true) return failedItem(resource, failure$2("FOLDER_UNSUPPORTED"));
|
|
7848
8530
|
if (resource.type === "image") return await this.fetchImage(call, downloadId, resource);
|
|
7849
8531
|
return await this.fetchFile(call, downloadId, resource);
|
|
7850
8532
|
}
|
|
7851
8533
|
async fetchImage(call, downloadId, resource) {
|
|
7852
8534
|
const channel = call.channel;
|
|
7853
|
-
if (channel === void 0) return failedItem(resource, failure$
|
|
8535
|
+
if (channel === void 0) return failedItem(resource, failure$2("CHANNEL_UNAVAILABLE"));
|
|
7854
8536
|
const remaining = call.budget.maxBytes - call.budget.usedBytes;
|
|
7855
8537
|
if (remaining <= 0) {
|
|
7856
8538
|
call.budget.exceeded = true;
|
|
7857
|
-
return failedItem(resource, failure$
|
|
8539
|
+
return failedItem(resource, failure$2("BYTES_LIMIT"));
|
|
7858
8540
|
}
|
|
7859
8541
|
const imageCap = call.imageCap;
|
|
7860
8542
|
const limit = Math.min(imageCap, remaining);
|
|
@@ -7870,9 +8552,9 @@ var AttachmentFetchToolRuntime = class {
|
|
|
7870
8552
|
} catch (error) {
|
|
7871
8553
|
if (error instanceof BudgetExceededError) {
|
|
7872
8554
|
call.budget.exceeded = true;
|
|
7873
|
-
return failedItem(resource, failure$
|
|
8555
|
+
return failedItem(resource, failure$2("BYTES_LIMIT"));
|
|
7874
8556
|
}
|
|
7875
|
-
if (error instanceof ImageTooLargeError) return failedItem(resource, failure$
|
|
8557
|
+
if (error instanceof ImageTooLargeError) return failedItem(resource, failure$2("IMAGE_TOO_LARGE"));
|
|
7876
8558
|
return downloadFailureItem(resource, error, call);
|
|
7877
8559
|
}
|
|
7878
8560
|
let saved;
|
|
@@ -7902,10 +8584,10 @@ var AttachmentFetchToolRuntime = class {
|
|
|
7902
8584
|
}
|
|
7903
8585
|
async fetchFile(call, downloadId, resource) {
|
|
7904
8586
|
const channel = call.channel;
|
|
7905
|
-
if (channel === void 0) return failedItem(resource, failure$
|
|
8587
|
+
if (channel === void 0) return failedItem(resource, failure$2("CHANNEL_UNAVAILABLE"));
|
|
7906
8588
|
if (call.budget.maxBytes <= call.budget.usedBytes) {
|
|
7907
8589
|
call.budget.exceeded = true;
|
|
7908
|
-
return failedItem(resource, failure$
|
|
8590
|
+
return failedItem(resource, failure$2("BYTES_LIMIT"));
|
|
7909
8591
|
}
|
|
7910
8592
|
let stream;
|
|
7911
8593
|
try {
|
|
@@ -7922,11 +8604,11 @@ var AttachmentFetchToolRuntime = class {
|
|
|
7922
8604
|
...resource.fileName === void 0 ? {} : { name: resource.fileName }
|
|
7923
8605
|
}), call.signal);
|
|
7924
8606
|
} catch (error) {
|
|
7925
|
-
if (call.budget.exceeded) return failedItem(resource, failure$
|
|
8607
|
+
if (call.budget.exceeded) return failedItem(resource, failure$2("BYTES_LIMIT"));
|
|
7926
8608
|
return downloadFailureItem(resource, error, call);
|
|
7927
8609
|
}
|
|
7928
8610
|
if (!saved.ok) {
|
|
7929
|
-
if (call.budget.exceeded) return failedItem(resource, failure$
|
|
8611
|
+
if (call.budget.exceeded) return failedItem(resource, failure$2("BYTES_LIMIT"));
|
|
7930
8612
|
if (call.signal.aborted) return cancelledItem(resource, call.timeout.value);
|
|
7931
8613
|
return failedItem(resource, saved);
|
|
7932
8614
|
}
|
|
@@ -7943,9 +8625,9 @@ var AttachmentFetchToolRuntime = class {
|
|
|
7943
8625
|
};
|
|
7944
8626
|
/** Map one thrown download/store error into a structured item. */
|
|
7945
8627
|
function downloadFailureItem(resource, error, call) {
|
|
7946
|
-
if (call.budget.exceeded) return failedItem(resource, failure$
|
|
8628
|
+
if (call.budget.exceeded) return failedItem(resource, failure$2("BYTES_LIMIT"));
|
|
7947
8629
|
if (call.signal.aborted) return cancelledItem(resource, call.timeout.value);
|
|
7948
|
-
return failedItem(resource, failure$
|
|
8630
|
+
return failedItem(resource, failure$2("DOWNLOAD_FAILED", `${attachmentFetchFailureReason("DOWNLOAD_FAILED")}:${describeFetchError(error)}`));
|
|
7949
8631
|
}
|
|
7950
8632
|
/**
|
|
7951
8633
|
* Run one promise with cooperative cancellation: an aborted signal rejects the
|
|
@@ -8076,7 +8758,7 @@ function failedItem(resource, result) {
|
|
|
8076
8758
|
};
|
|
8077
8759
|
}
|
|
8078
8760
|
function cancelledItem(resource, timedOut) {
|
|
8079
|
-
return failedItem(resource, failure$
|
|
8761
|
+
return failedItem(resource, failure$2(timedOut ? "TIMEOUT" : "ABORTED"));
|
|
8080
8762
|
}
|
|
8081
8763
|
function callFailure(messageId, result) {
|
|
8082
8764
|
return {
|
|
@@ -9006,7 +9688,7 @@ const SEND_FILE_PARAMETERS = {
|
|
|
9006
9688
|
required: ["path"],
|
|
9007
9689
|
additionalProperties: false
|
|
9008
9690
|
};
|
|
9009
|
-
const FAILURE_REASONS = {
|
|
9691
|
+
const FAILURE_REASONS$1 = {
|
|
9010
9692
|
TOOL_DISABLED: "feishu_send_file 已在插件配置中关闭",
|
|
9011
9693
|
ROUTE_NOT_FOUND: "当前会话没有绑定飞书话题或会话,无法确定发送目标",
|
|
9012
9694
|
CHANNEL_UNAVAILABLE: "飞书连接当前不可用,请稍后重试",
|
|
@@ -9099,7 +9781,7 @@ var SendFileToolRuntime = class {
|
|
|
9099
9781
|
try {
|
|
9100
9782
|
tools.register(this.definitionFor(sessionId));
|
|
9101
9783
|
} catch (error) {
|
|
9102
|
-
this.warnMountFailure(`dsh-channels: registering ${SEND_FILE_TOOL_NAME} failed: ${describeError(error)}`);
|
|
9784
|
+
this.warnMountFailure(`dsh-channels: registering ${SEND_FILE_TOOL_NAME} failed: ${describeError$1(error)}`);
|
|
9103
9785
|
}
|
|
9104
9786
|
}
|
|
9105
9787
|
warnMountFailure(message) {
|
|
@@ -9130,14 +9812,14 @@ var SendFileToolRuntime = class {
|
|
|
9130
9812
|
throw new Error(`${SEND_FILE_TOOL_NAME}: invalid ${where}: ${issue?.message ?? "invalid arguments"}`);
|
|
9131
9813
|
}
|
|
9132
9814
|
const args = parsed.data;
|
|
9133
|
-
if (!this.enabled()) return failure("TOOL_DISABLED");
|
|
9815
|
+
if (!this.enabled()) return failure$1("TOOL_DISABLED");
|
|
9134
9816
|
const target = this.deps.targetOfSession(sessionId);
|
|
9135
|
-
if (target === void 0) return failure("ROUTE_NOT_FOUND");
|
|
9817
|
+
if (target === void 0) return failure$1("ROUTE_NOT_FOUND");
|
|
9136
9818
|
const loaded = await loadWorkspaceFile(target.rootDir, args);
|
|
9137
9819
|
if (!loaded.ok) return loaded;
|
|
9138
|
-
if (exec.signal.aborted) return failure("ABORTED");
|
|
9820
|
+
if (exec.signal.aborted) return failure$1("ABORTED");
|
|
9139
9821
|
const channel = this.deps.channel();
|
|
9140
|
-
if (channel === void 0) return failure("CHANNEL_UNAVAILABLE");
|
|
9822
|
+
if (channel === void 0) return failure$1("CHANNEL_UNAVAILABLE");
|
|
9141
9823
|
const { kind, fileName, data } = loaded;
|
|
9142
9824
|
const media = kind === "image" ? {
|
|
9143
9825
|
kind,
|
|
@@ -9161,14 +9843,14 @@ var SendFileToolRuntime = class {
|
|
|
9161
9843
|
};
|
|
9162
9844
|
} catch (error) {
|
|
9163
9845
|
if (exec.signal.aborted) {
|
|
9164
|
-
sent?.catch((late) => this.deps.warn("dsh-channels: %s failed after abort: %s", SEND_FILE_TOOL_NAME, describeError(late)));
|
|
9165
|
-
return failure("ABORTED");
|
|
9846
|
+
sent?.catch((late) => this.deps.warn("dsh-channels: %s failed after abort: %s", SEND_FILE_TOOL_NAME, describeError$1(late)));
|
|
9847
|
+
return failure$1("ABORTED");
|
|
9166
9848
|
}
|
|
9167
|
-
const detail = describeError(error);
|
|
9168
|
-
if (isPermissionError(error)) return failure("PERMISSION_DENIED", `${FAILURE_REASONS.PERMISSION_DENIED}(${detail})`);
|
|
9169
|
-
if (error instanceof FeishuApiError && error.code !== void 0 && REPLY_TARGET_GONE_CODES.has(error.code)) return failure("REPLY_TARGET_GONE");
|
|
9849
|
+
const detail = describeError$1(error);
|
|
9850
|
+
if (isPermissionError(error)) return failure$1("PERMISSION_DENIED", `${FAILURE_REASONS$1.PERMISSION_DENIED}(${detail})`);
|
|
9851
|
+
if (error instanceof FeishuApiError && error.code !== void 0 && REPLY_TARGET_GONE_CODES.has(error.code)) return failure$1("REPLY_TARGET_GONE");
|
|
9170
9852
|
this.deps.warn("dsh-channels: %s failed: %s", SEND_FILE_TOOL_NAME, detail);
|
|
9171
|
-
return failure("SEND_FAILED", `${FAILURE_REASONS.SEND_FAILED}:${detail}`);
|
|
9853
|
+
return failure$1("SEND_FAILED", `${FAILURE_REASONS$1.SEND_FAILED}:${detail}`);
|
|
9172
9854
|
}
|
|
9173
9855
|
}
|
|
9174
9856
|
};
|
|
@@ -9184,41 +9866,41 @@ async function loadWorkspaceFile(rootDir, args) {
|
|
|
9184
9866
|
try {
|
|
9185
9867
|
root = await realpath(rootDir);
|
|
9186
9868
|
} catch (error) {
|
|
9187
|
-
return failure("FILE_NOT_FOUND", `当前话题工作目录不可用:${describeError(error)}`);
|
|
9869
|
+
return failure$1("FILE_NOT_FOUND", `当前话题工作目录不可用:${describeError$1(error)}`);
|
|
9188
9870
|
}
|
|
9189
9871
|
let real;
|
|
9190
9872
|
try {
|
|
9191
9873
|
real = await realpath(path.resolve(root, args.path));
|
|
9192
9874
|
} catch {
|
|
9193
|
-
return isInside(root, path.resolve(root, args.path)) ? failure("FILE_NOT_FOUND") : failure("PATH_OUTSIDE_WORKSPACE");
|
|
9875
|
+
return isInside(root, path.resolve(root, args.path)) ? failure$1("FILE_NOT_FOUND") : failure$1("PATH_OUTSIDE_WORKSPACE");
|
|
9194
9876
|
}
|
|
9195
|
-
if (!isInside(root, real)) return failure("PATH_OUTSIDE_WORKSPACE");
|
|
9877
|
+
if (!isInside(root, real)) return failure$1("PATH_OUTSIDE_WORKSPACE");
|
|
9196
9878
|
const requested = args.kind ?? "auto";
|
|
9197
9879
|
const ceiling = requested === "image" ? FEISHU_IMAGE_MAX_BYTES : FEISHU_FILE_MAX_BYTES;
|
|
9198
9880
|
let handle;
|
|
9199
9881
|
try {
|
|
9200
9882
|
handle = await open(real, constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0));
|
|
9201
9883
|
} catch (error) {
|
|
9202
|
-
return failure("FILE_NOT_FOUND", `${FAILURE_REASONS.FILE_NOT_FOUND}:${describeError(error)}`);
|
|
9884
|
+
return failure$1("FILE_NOT_FOUND", `${FAILURE_REASONS$1.FILE_NOT_FOUND}:${describeError$1(error)}`);
|
|
9203
9885
|
}
|
|
9204
9886
|
let data;
|
|
9205
9887
|
try {
|
|
9206
9888
|
const info = await handle.stat();
|
|
9207
|
-
if (!info.isFile()) return failure("NOT_A_FILE");
|
|
9889
|
+
if (!info.isFile()) return failure$1("NOT_A_FILE");
|
|
9208
9890
|
const recheck = await realpath(real).then((again) => isInside(root, again) ? stat(again) : void 0, () => void 0);
|
|
9209
|
-
if (recheck === void 0 || recheck.dev !== info.dev || recheck.ino !== info.ino) return failure("PATH_OUTSIDE_WORKSPACE");
|
|
9210
|
-
if (info.size === 0) return failure("EMPTY_FILE");
|
|
9891
|
+
if (recheck === void 0 || recheck.dev !== info.dev || recheck.ino !== info.ino) return failure$1("PATH_OUTSIDE_WORKSPACE");
|
|
9892
|
+
if (info.size === 0) return failure$1("EMPTY_FILE");
|
|
9211
9893
|
if (info.size > ceiling) return tooLarge(requested === "image" ? "image" : "file", info.size);
|
|
9212
9894
|
data = await handle.readFile();
|
|
9213
9895
|
} catch (error) {
|
|
9214
|
-
return failure("FILE_NOT_FOUND", `${FAILURE_REASONS.FILE_NOT_FOUND}:${describeError(error)}`);
|
|
9896
|
+
return failure$1("FILE_NOT_FOUND", `${FAILURE_REASONS$1.FILE_NOT_FOUND}:${describeError$1(error)}`);
|
|
9215
9897
|
} finally {
|
|
9216
9898
|
await handle.close().catch(() => void 0);
|
|
9217
9899
|
}
|
|
9218
|
-
if (data.length === 0) return failure("EMPTY_FILE");
|
|
9900
|
+
if (data.length === 0) return failure$1("EMPTY_FILE");
|
|
9219
9901
|
if (data.length > ceiling) return tooLarge(requested === "image" ? "image" : "file", data.length);
|
|
9220
9902
|
const isImage = isFeishuImage(data);
|
|
9221
|
-
if (requested === "image" && !isImage) return failure("NOT_AN_IMAGE");
|
|
9903
|
+
if (requested === "image" && !isImage) return failure$1("NOT_AN_IMAGE");
|
|
9222
9904
|
return {
|
|
9223
9905
|
ok: true,
|
|
9224
9906
|
kind: requested === "file" ? "file" : requested === "image" ? "image" : isImage && data.length <= 10485760 ? "image" : "file",
|
|
@@ -9248,19 +9930,19 @@ function sanitizeFileName(value) {
|
|
|
9248
9930
|
function tooLarge(kind, bytes) {
|
|
9249
9931
|
const limit = kind === "image" ? FEISHU_IMAGE_MAX_BYTES : FEISHU_FILE_MAX_BYTES;
|
|
9250
9932
|
const hint = kind === "image" ? ";可用 kind=file 以文件发送(上限 30 MB)" : ";请压缩或拆分后再发送";
|
|
9251
|
-
return failure("FILE_TOO_LARGE", `${FAILURE_REASONS.FILE_TOO_LARGE}:${formatBytes(bytes)} > ${kind === "image" ? "图片" : "文件"}上限 ${formatBytes(limit)}${hint}`);
|
|
9933
|
+
return failure$1("FILE_TOO_LARGE", `${FAILURE_REASONS$1.FILE_TOO_LARGE}:${formatBytes(bytes)} > ${kind === "image" ? "图片" : "文件"}上限 ${formatBytes(limit)}${hint}`);
|
|
9252
9934
|
}
|
|
9253
|
-
function failure(code, reason) {
|
|
9935
|
+
function failure$1(code, reason) {
|
|
9254
9936
|
return {
|
|
9255
9937
|
ok: false,
|
|
9256
9938
|
code,
|
|
9257
|
-
reason: reason ?? FAILURE_REASONS[code]
|
|
9939
|
+
reason: reason ?? FAILURE_REASONS$1[code]
|
|
9258
9940
|
};
|
|
9259
9941
|
}
|
|
9260
9942
|
/** Feishu codes for a recalled/deleted reply target (the SDK's `target_revoked`). */
|
|
9261
9943
|
const REPLY_TARGET_GONE_CODES = /* @__PURE__ */ new Set([230020, 230017]);
|
|
9262
9944
|
/** Feishu codes for a missing app scope. */
|
|
9263
|
-
const PERMISSION_CODES = /* @__PURE__ */ new Set([
|
|
9945
|
+
const PERMISSION_CODES$1 = /* @__PURE__ */ new Set([
|
|
9264
9946
|
99991672,
|
|
9265
9947
|
99991679,
|
|
9266
9948
|
230027
|
|
@@ -9295,13 +9977,13 @@ function feishuBody(error) {
|
|
|
9295
9977
|
function isPermissionError(error) {
|
|
9296
9978
|
return errorChain(error).some((item) => {
|
|
9297
9979
|
const body = feishuBody(item);
|
|
9298
|
-
if (body?.code !== void 0 && PERMISSION_CODES.has(body.code)) return true;
|
|
9980
|
+
if (body?.code !== void 0 && PERMISSION_CODES$1.has(body.code)) return true;
|
|
9299
9981
|
const text = `${body?.msg ?? ""} ${item instanceof Error ? item.message : ""}`;
|
|
9300
9982
|
return /status code 403|access denied|scope|permission/iu.test(text);
|
|
9301
9983
|
});
|
|
9302
9984
|
}
|
|
9303
9985
|
/** Readable, bounded text for a thrown value and its Feishu cause. */
|
|
9304
|
-
function describeError(error) {
|
|
9986
|
+
function describeError$1(error) {
|
|
9305
9987
|
const parts = [];
|
|
9306
9988
|
for (const item of errorChain(error)) {
|
|
9307
9989
|
const body = feishuBody(item);
|
|
@@ -9370,33 +10052,2321 @@ function presentSendFileResult(value) {
|
|
|
9370
10052
|
return `发送失败(${value.code}):${value.reason}`;
|
|
9371
10053
|
}
|
|
9372
10054
|
//#endregion
|
|
9373
|
-
//#region src/
|
|
10055
|
+
//#region src/cards/wiki.ts
|
|
9374
10056
|
/**
|
|
9375
|
-
*
|
|
9376
|
-
*
|
|
9377
|
-
*
|
|
9378
|
-
* Everything here is channel-plane policy; the command plane (slash commands)
|
|
9379
|
-
* and the answerers stay in the service and are reached through narrow ports.
|
|
9380
|
-
* Dependency reads go through accessors so the moved logic stays readable.
|
|
10057
|
+
* Wiki (知识库) authorization cards: the topic owner's consent before the
|
|
10058
|
+
* model opens, creates, or edits a wiki document.
|
|
9381
10059
|
*
|
|
9382
|
-
* @module dsh-channels/
|
|
10060
|
+
* @module dsh-channels/cards/wiki
|
|
9383
10061
|
*/
|
|
9384
|
-
/**
|
|
9385
|
-
const
|
|
9386
|
-
|
|
9387
|
-
|
|
9388
|
-
|
|
10062
|
+
/** Cap on one rendered detail block so the card stays readable on a phone. */
|
|
10063
|
+
const DETAIL_MAX_CHARS = 800;
|
|
10064
|
+
const ACTION_LABELS = {
|
|
10065
|
+
read: "读取文档",
|
|
10066
|
+
create: "新建文档",
|
|
10067
|
+
edit: "修改文档"
|
|
9389
10068
|
};
|
|
9390
|
-
|
|
9391
|
-
|
|
9392
|
-
|
|
9393
|
-
|
|
9394
|
-
|
|
9395
|
-
|
|
9396
|
-
|
|
9397
|
-
|
|
9398
|
-
|
|
9399
|
-
|
|
10069
|
+
const SETTLED_LINES = {
|
|
10070
|
+
allowed: "✅ 已允许",
|
|
10071
|
+
denied: "❌ 已拒绝",
|
|
10072
|
+
expired: "⏱ 已超时未处理,自动拒绝",
|
|
10073
|
+
cancelled: "↩️ 请求已取消"
|
|
10074
|
+
};
|
|
10075
|
+
/** Clip one value to `max` characters, marking the cut. */
|
|
10076
|
+
function clip(value, max) {
|
|
10077
|
+
return value.length <= max ? value : `${value.slice(0, max)}…(已截断 ${value.length - max} 个字符)`;
|
|
10078
|
+
}
|
|
10079
|
+
/** One line of Markdown text with link/markup characters neutralized. */
|
|
10080
|
+
function plain(value) {
|
|
10081
|
+
return value.replace(/[\u0000-\u001f\u007f]+/gu, " ").replace(/([[\]()*_`~<>])/gu, "\\$1").trim();
|
|
10082
|
+
}
|
|
10083
|
+
/** The question summary shared by the pending and settled variants. */
|
|
10084
|
+
function questionElements(question) {
|
|
10085
|
+
const title = question.title === "" ? "(无标题)" : plain(question.title);
|
|
10086
|
+
const titleLine = question.url === void 0 || question.action === "create" ? title : `[${title}](${question.url})`;
|
|
10087
|
+
const elements = [markdown([
|
|
10088
|
+
`机器人请求以应用身份**${ACTION_LABELS[question.action]}**。`,
|
|
10089
|
+
`**文档**:${titleLine}`,
|
|
10090
|
+
`**位置**:${plain(question.location)}`
|
|
10091
|
+
].join("\n"))];
|
|
10092
|
+
for (const detail of question.details) elements.push(markdown(clip(detail, DETAIL_MAX_CHARS)));
|
|
10093
|
+
return elements;
|
|
10094
|
+
}
|
|
10095
|
+
function consentButton(question, decision) {
|
|
10096
|
+
return textButton({
|
|
10097
|
+
label: decision === "allow" ? "✅ 允许" : "❌ 拒绝",
|
|
10098
|
+
type: decision === "allow" ? "primary" : "danger",
|
|
10099
|
+
width: "fill",
|
|
10100
|
+
value: {
|
|
10101
|
+
decision,
|
|
10102
|
+
token: question.token
|
|
10103
|
+
}
|
|
10104
|
+
});
|
|
10105
|
+
}
|
|
10106
|
+
/**
|
|
10107
|
+
* The pending consent card, sent as a reply in the session's topic.
|
|
10108
|
+
*
|
|
10109
|
+
* @param question The consent asked for.
|
|
10110
|
+
* @param options Rendering options.
|
|
10111
|
+
* @returns Card JSON 2.0.
|
|
10112
|
+
*/
|
|
10113
|
+
function buildWikiConsentCard(question, options = {}) {
|
|
10114
|
+
const minutes = Math.max(1, Math.round(question.timeoutMs / 6e4));
|
|
10115
|
+
const scope = question.action === "read" ? "允许后,本话题内再次读取这篇文档不再询问" : "仅对这一次操作生效";
|
|
10116
|
+
const elements = [
|
|
10117
|
+
...questionElements(question),
|
|
10118
|
+
divider(),
|
|
10119
|
+
note(`⏱ ${minutes} 分钟内有效,超时自动拒绝 · ${scope}`)
|
|
10120
|
+
];
|
|
10121
|
+
if (options.unauthorizedNote === true) elements.push(note("ℹ️ 仅发起该话题的用户可操作此卡片"));
|
|
10122
|
+
elements.push(columnSet([consentButton(question, "allow"), consentButton(question, "deny")]));
|
|
10123
|
+
return standaloneCard("**📚 知识库授权**", elements);
|
|
10124
|
+
}
|
|
10125
|
+
/**
|
|
10126
|
+
* The terminal replacement after the consent settles; no controls.
|
|
10127
|
+
*
|
|
10128
|
+
* @param question The consent that was asked for.
|
|
10129
|
+
* @param state How it settled.
|
|
10130
|
+
* @returns Card JSON 2.0.
|
|
10131
|
+
*/
|
|
10132
|
+
function buildWikiConsentSettledCard(question, state) {
|
|
10133
|
+
return standaloneCard(`**${SETTLED_LINES[state]}**`, [
|
|
10134
|
+
...questionElements(question),
|
|
10135
|
+
divider(),
|
|
10136
|
+
note(`📚 知识库${ACTION_LABELS[question.action]}`)
|
|
10137
|
+
]);
|
|
10138
|
+
}
|
|
10139
|
+
//#endregion
|
|
10140
|
+
//#region src/feishu/wiki/consent.ts
|
|
10141
|
+
/**
|
|
10142
|
+
* Topic-owner consent for wiki tool calls.
|
|
10143
|
+
*
|
|
10144
|
+
* A wiki tool that opens, creates, or edits a document asks the topic owner
|
|
10145
|
+
* on a consent card in the topic and waits for the answer. Only the topic
|
|
10146
|
+
* owner (`route.senderUserId`) can decide; a click by anyone else adds the
|
|
10147
|
+
* owner-only notice once. An unanswered card is denied after the configured
|
|
10148
|
+
* window (`approvalTimeoutMs`). A read consent is remembered per Session and
|
|
10149
|
+
* document, in memory only: it ends with the Session (`/renew`) or the
|
|
10150
|
+
* process. Create and edit consents are never remembered.
|
|
10151
|
+
*
|
|
10152
|
+
* @module dsh-channels/feishu/wiki/consent
|
|
10153
|
+
*/
|
|
10154
|
+
/** Consent cards for the wiki tools. */
|
|
10155
|
+
var WikiConsent = class {
|
|
10156
|
+
deps;
|
|
10157
|
+
/** One table per channel instance (cards are patched through their channel). */
|
|
10158
|
+
table;
|
|
10159
|
+
/** sessionId → document ids with a standing read consent. */
|
|
10160
|
+
readGrants = /* @__PURE__ */ new Map();
|
|
10161
|
+
constructor(deps) {
|
|
10162
|
+
this.deps = deps;
|
|
10163
|
+
}
|
|
10164
|
+
/**
|
|
10165
|
+
* Whether the Session already holds a read consent for one document.
|
|
10166
|
+
* @param sessionId The Session.
|
|
10167
|
+
* @param documentId Docx document id.
|
|
10168
|
+
* @returns True when reading needs no new card.
|
|
10169
|
+
*/
|
|
10170
|
+
hasReadGrant(sessionId, documentId) {
|
|
10171
|
+
return this.readGrants.get(sessionId)?.has(documentId) === true;
|
|
10172
|
+
}
|
|
10173
|
+
/**
|
|
10174
|
+
* Record a standing read consent (after an allowed read, or for a document
|
|
10175
|
+
* the topic owner just approved creating).
|
|
10176
|
+
* @param sessionId The Session.
|
|
10177
|
+
* @param documentId Docx document id.
|
|
10178
|
+
*/
|
|
10179
|
+
grantRead(sessionId, documentId) {
|
|
10180
|
+
let grants = this.readGrants.get(sessionId);
|
|
10181
|
+
if (grants === void 0) {
|
|
10182
|
+
grants = /* @__PURE__ */ new Set();
|
|
10183
|
+
this.readGrants.set(sessionId, grants);
|
|
10184
|
+
}
|
|
10185
|
+
grants.add(documentId);
|
|
10186
|
+
}
|
|
10187
|
+
/**
|
|
10188
|
+
* Ask the topic owner and wait for the decision. Never rejects.
|
|
10189
|
+
* @param request What to ask.
|
|
10190
|
+
* @returns The outcome; `unavailable` when no card could be delivered.
|
|
10191
|
+
*/
|
|
10192
|
+
request(request) {
|
|
10193
|
+
if (request.signal.aborted) return Promise.resolve("cancelled");
|
|
10194
|
+
const channel = this.deps.channel();
|
|
10195
|
+
if (channel === void 0) return Promise.resolve("unavailable");
|
|
10196
|
+
const table = this.tableFor(channel);
|
|
10197
|
+
const timeoutMs = this.deps.timeoutMs();
|
|
10198
|
+
const question = {
|
|
10199
|
+
token: randomUUID(),
|
|
10200
|
+
action: request.action,
|
|
10201
|
+
title: request.title,
|
|
10202
|
+
...request.url === void 0 ? {} : { url: request.url },
|
|
10203
|
+
location: request.location,
|
|
10204
|
+
details: request.details,
|
|
10205
|
+
timeoutMs
|
|
10206
|
+
};
|
|
10207
|
+
return new Promise((resolve) => {
|
|
10208
|
+
const entry = {
|
|
10209
|
+
token: question.token,
|
|
10210
|
+
sessionId: request.sessionId,
|
|
10211
|
+
route: request.route,
|
|
10212
|
+
messageId: void 0,
|
|
10213
|
+
settled: false,
|
|
10214
|
+
timer: void 0,
|
|
10215
|
+
signal: request.signal,
|
|
10216
|
+
onAbort: void 0,
|
|
10217
|
+
unauthorizedNoted: false,
|
|
10218
|
+
question,
|
|
10219
|
+
resolve
|
|
10220
|
+
};
|
|
10221
|
+
const onAbort = () => this.settle(table, entry, "cancelled", "cancelled");
|
|
10222
|
+
entry.onAbort = onAbort;
|
|
10223
|
+
request.signal.addEventListener("abort", onAbort, { once: true });
|
|
10224
|
+
table.add(entry);
|
|
10225
|
+
table.deliver(entry, {
|
|
10226
|
+
card: buildWikiConsentCard(question),
|
|
10227
|
+
onWithdrawn: (e) => table.patch(e, buildWikiConsentSettledCard(e.question, "cancelled")),
|
|
10228
|
+
onDelivered: (e) => table.armTimeout(e, timeoutMs, () => this.settle(table, e, "expired", "expired")),
|
|
10229
|
+
onFailure: (e) => this.settle(table, e, "unavailable")
|
|
10230
|
+
});
|
|
10231
|
+
});
|
|
10232
|
+
}
|
|
10233
|
+
/**
|
|
10234
|
+
* Route one card button callback to its pending consent; unknown cards are
|
|
10235
|
+
* ignored.
|
|
10236
|
+
* @param event The card action.
|
|
10237
|
+
*/
|
|
10238
|
+
onCardAction(event) {
|
|
10239
|
+
const table = this.table?.entries;
|
|
10240
|
+
if (table === void 0) return;
|
|
10241
|
+
const entry = table.resolveAction(event, (candidate) => {
|
|
10242
|
+
if (candidate.unauthorizedNoted) return;
|
|
10243
|
+
candidate.unauthorizedNoted = true;
|
|
10244
|
+
table.patch(candidate, buildWikiConsentCard(candidate.question, { unauthorizedNote: true }));
|
|
10245
|
+
});
|
|
10246
|
+
if (entry === void 0) return;
|
|
10247
|
+
const decision = event.action.value?.decision;
|
|
10248
|
+
if (decision === "allow") this.settle(table, entry, "allowed", "allowed");
|
|
10249
|
+
else if (decision === "deny") this.settle(table, entry, "denied", "denied");
|
|
10250
|
+
}
|
|
10251
|
+
/** Withdraw every pending card and forget every read consent (shutdown). */
|
|
10252
|
+
dispose() {
|
|
10253
|
+
const table = this.table?.entries;
|
|
10254
|
+
table?.withdrawAll((entry) => this.settle(table, entry, "cancelled", "cancelled"));
|
|
10255
|
+
this.table = void 0;
|
|
10256
|
+
this.readGrants.clear();
|
|
10257
|
+
}
|
|
10258
|
+
/** Pending consent count (diagnostics/tests). */
|
|
10259
|
+
pendingCount() {
|
|
10260
|
+
return this.table?.entries.count() ?? 0;
|
|
10261
|
+
}
|
|
10262
|
+
tableFor(channel) {
|
|
10263
|
+
if (this.table?.channel !== channel) this.table = {
|
|
10264
|
+
channel,
|
|
10265
|
+
entries: new PendingCardTable(channel, this.deps.onError)
|
|
10266
|
+
};
|
|
10267
|
+
return this.table.entries;
|
|
10268
|
+
}
|
|
10269
|
+
settle(table, entry, outcome, cardState) {
|
|
10270
|
+
if (table.close(entry, () => {
|
|
10271
|
+
if (cardState !== void 0) table.patch(entry, buildWikiConsentSettledCard(entry.question, cardState));
|
|
10272
|
+
})) entry.resolve(outcome);
|
|
10273
|
+
}
|
|
10274
|
+
};
|
|
10275
|
+
const FAILURE_REASONS = {
|
|
10276
|
+
TOOL_DISABLED: "知识库工具已在插件配置中关闭",
|
|
10277
|
+
ROUTE_NOT_FOUND: "当前会话没有绑定飞书话题,无法确定授权人",
|
|
10278
|
+
NOT_AUTHORIZED: "当前话题的发起人不在知识库白名单中;请管理员发送 /auth wiki add @该用户 后重试",
|
|
10279
|
+
CHANNEL_UNAVAILABLE: "飞书连接当前不可用,请稍后重试",
|
|
10280
|
+
INVALID_REFERENCE: "无法识别的知识库引用:请传知识库节点链接 https://<租户>.feishu.cn/wiki/<node_token>、node_token,或知识空间链接 https://<租户>.feishu.cn/wiki/space/<space_id>",
|
|
10281
|
+
INVALID_OPERATION: "操作参数不正确",
|
|
10282
|
+
OUTSIDE_ROOTS: "该位置不在插件配置的知识库范围(wikiRoots)内,不能访问",
|
|
10283
|
+
NOT_FOUND: "知识库节点不存在或已被删除",
|
|
10284
|
+
NO_ACCESS: "机器人对该知识库节点没有访问权限;请知识库管理员把应用(或包含机器人的群)加为该空间成员",
|
|
10285
|
+
UNSUPPORTED_TYPE: "这个工具不支持该类型的节点:飞书文档(docx)用 feishu_wiki_read / feishu_wiki_edit,电子表格(sheet)用 feishu_wiki_sheet_read / feishu_wiki_sheet_edit,多维表格(bitable)用 feishu_wiki_base_read / feishu_wiki_base_edit;其他类型暂不支持",
|
|
10286
|
+
NOT_OWNED: "只能修改由本应用创建(所有者是本应用)的文档",
|
|
10287
|
+
REVISION_CONFLICT: "文档在你读取之后被修改过;请重新用 feishu_wiki_read 读取最新内容,基于新的 revision_id 再修改",
|
|
10288
|
+
CONSENT_DENIED: "话题发起人拒绝了这次知识库操作",
|
|
10289
|
+
CONSENT_EXPIRED: "话题发起人没有在授权卡有效期内确认,已自动拒绝",
|
|
10290
|
+
CONSENT_UNAVAILABLE: "授权卡发送失败,无法征得话题发起人同意,已取消操作",
|
|
10291
|
+
PERMISSION_DENIED: "飞书拒绝调用:应用缺少知识库或文档权限(wiki:wiki、docx:document),请管理员在开发者后台开通并发布版本",
|
|
10292
|
+
CONTENT_FAILED: "文档已创建,但写入内容失败",
|
|
10293
|
+
EDIT_FAILED: "修改失败",
|
|
10294
|
+
EDIT_PARTIAL: "飞书报告操作只部分生效;已停止后续操作,请用 feishu_wiki_read 读取最新内容核验后再继续",
|
|
10295
|
+
API_FAILED: "飞书接口调用失败",
|
|
10296
|
+
ABORTED: "已取消"
|
|
10297
|
+
};
|
|
10298
|
+
function failure(code, reason) {
|
|
10299
|
+
return {
|
|
10300
|
+
ok: false,
|
|
10301
|
+
code,
|
|
10302
|
+
reason: reason ?? FAILURE_REASONS[code]
|
|
10303
|
+
};
|
|
10304
|
+
}
|
|
10305
|
+
function parseArgs(tool, schema, rawArgs) {
|
|
10306
|
+
const parsed = schema.safeParse(rawArgs);
|
|
10307
|
+
if (parsed.success) return parsed.data;
|
|
10308
|
+
const issue = parsed.error.issues[0];
|
|
10309
|
+
const where = issue === void 0 || issue.path.length === 0 ? "arguments" : issue.path.join(".");
|
|
10310
|
+
throw new Error(`${tool}: invalid ${where}: ${issue?.message ?? "invalid arguments"}`);
|
|
10311
|
+
}
|
|
10312
|
+
/** Run one gated call body, mapping thrown Feishu errors to failures. */
|
|
10313
|
+
async function guarded$1(deps, tool, body) {
|
|
10314
|
+
try {
|
|
10315
|
+
return await body();
|
|
10316
|
+
} catch (error) {
|
|
10317
|
+
const mapped = failureOf(error);
|
|
10318
|
+
if (mapped.code === "API_FAILED") deps.warn("dsh-channels: %s failed: %s", tool, mapped.reason);
|
|
10319
|
+
return mapped;
|
|
10320
|
+
}
|
|
10321
|
+
}
|
|
10322
|
+
/** Feishu codes: node missing/deleted, no resource access, missing app scope. */
|
|
10323
|
+
const NOT_FOUND_CODES = /* @__PURE__ */ new Set([
|
|
10324
|
+
131005,
|
|
10325
|
+
131012,
|
|
10326
|
+
131013,
|
|
10327
|
+
131014,
|
|
10328
|
+
131016
|
|
10329
|
+
]);
|
|
10330
|
+
const NO_ACCESS_CODES = /* @__PURE__ */ new Set([
|
|
10331
|
+
131006,
|
|
10332
|
+
1770032,
|
|
10333
|
+
1770002
|
|
10334
|
+
]);
|
|
10335
|
+
const PERMISSION_CODES = /* @__PURE__ */ new Set([99991672, 99991679]);
|
|
10336
|
+
function failureOf(error) {
|
|
10337
|
+
const detail = describeError(error);
|
|
10338
|
+
if (error instanceof FeishuApiError && error.code !== void 0) {
|
|
10339
|
+
if (NOT_FOUND_CODES.has(error.code)) return failure("NOT_FOUND", `${FAILURE_REASONS.NOT_FOUND}(${detail})`);
|
|
10340
|
+
if (NO_ACCESS_CODES.has(error.code)) return failure("NO_ACCESS", `${FAILURE_REASONS.NO_ACCESS}(${detail})`);
|
|
10341
|
+
if (PERMISSION_CODES.has(error.code)) return failure("PERMISSION_DENIED", `${FAILURE_REASONS.PERMISSION_DENIED}(${detail})`);
|
|
10342
|
+
}
|
|
10343
|
+
return failure("API_FAILED", `${FAILURE_REASONS.API_FAILED}:${detail}`);
|
|
10344
|
+
}
|
|
10345
|
+
function consentFailure(outcome) {
|
|
10346
|
+
switch (outcome) {
|
|
10347
|
+
case "allowed": return;
|
|
10348
|
+
case "denied": return failure("CONSENT_DENIED");
|
|
10349
|
+
case "expired": return failure("CONSENT_EXPIRED");
|
|
10350
|
+
case "cancelled": return failure("ABORTED");
|
|
10351
|
+
case "unavailable": return failure("CONSENT_UNAVAILABLE");
|
|
10352
|
+
}
|
|
10353
|
+
}
|
|
10354
|
+
/** Whether the app owns a node: Feishu reports the bot open_id as the owner. */
|
|
10355
|
+
function ownedByApp(node, botOpenId) {
|
|
10356
|
+
return botOpenId !== void 0 && botOpenId !== "" && node.owner === botOpenId;
|
|
10357
|
+
}
|
|
10358
|
+
/** A node's web link; falls back to the first root's origin. */
|
|
10359
|
+
function nodeUrl(roots, node) {
|
|
10360
|
+
if (node.url !== void 0) return node.url;
|
|
10361
|
+
const first = roots[0];
|
|
10362
|
+
return `${first === void 0 ? "https://feishu.cn" : new URL(first.link).origin}/wiki/${node.nodeToken}`;
|
|
10363
|
+
}
|
|
10364
|
+
/** One-line clipped preview with Markdown control characters neutralized. */
|
|
10365
|
+
function preview(value, max = 80) {
|
|
10366
|
+
const flat = value.replace(/\s+/gu, " ").trim().replace(/([[\]()*_`~<>])/gu, "\\$1");
|
|
10367
|
+
return flat.length <= max ? flat : `${flat.slice(0, max)}…`;
|
|
10368
|
+
}
|
|
10369
|
+
/** A fenced preview of the first characters of a content. */
|
|
10370
|
+
function previewBlock(content) {
|
|
10371
|
+
return `\`\`\`\n${(content.length <= 300 ? content : `${content.slice(0, 300)}\n…`).replace(/```/gu, "```")}\n\`\`\``;
|
|
10372
|
+
}
|
|
10373
|
+
function display(value) {
|
|
10374
|
+
return typeof value === "string" ? preview(value, 120) : "?";
|
|
10375
|
+
}
|
|
10376
|
+
/** Readable, bounded text for a thrown value. */
|
|
10377
|
+
function describeError(error) {
|
|
10378
|
+
const cleaned = (error instanceof FeishuApiError ? error.detail : error instanceof Error ? error.message : String(error)).replace(/[\u0000-\u001f\u007f]+/gu, " ").trim();
|
|
10379
|
+
return cleaned.length <= 300 ? cleaned : `${cleaned.slice(0, 300)}…`;
|
|
10380
|
+
}
|
|
10381
|
+
//#endregion
|
|
10382
|
+
//#region src/feishu/wiki/links.ts
|
|
10383
|
+
/**
|
|
10384
|
+
* Feishu wiki (知识库) link parsing: the `wikiRoots` allow-list entries and
|
|
10385
|
+
* the document references the model passes to the wiki tools.
|
|
10386
|
+
*
|
|
10387
|
+
* Pure module: no I/O. Hosts are not checked because tenants use their own
|
|
10388
|
+
* subdomains (`<tenant>.feishu.cn`) and the API never uses the link host.
|
|
10389
|
+
*
|
|
10390
|
+
* @module dsh-channels/feishu/wiki/links
|
|
10391
|
+
*/
|
|
10392
|
+
/** Feishu wiki space id: decimal digits. */
|
|
10393
|
+
const SPACE_ID_PATTERN = /^\d{1,32}$/u;
|
|
10394
|
+
/** Feishu wiki node / docx token: letters and digits. */
|
|
10395
|
+
const TOKEN_PATTERN = /^[A-Za-z0-9]{8,64}$/u;
|
|
10396
|
+
/** Readable reasons for {@link WikiRootLinkError}. */
|
|
10397
|
+
const WIKI_ROOT_LINK_ERRORS = {
|
|
10398
|
+
"not-a-url": "不是有效的链接",
|
|
10399
|
+
"not-https": "必须是 https 链接",
|
|
10400
|
+
"not-a-wiki-link": "只支持知识空间链接 https://<租户>.feishu.cn/wiki/space/<space_id> 或节点链接 https://<租户>.feishu.cn/wiki/<node_token>"
|
|
10401
|
+
};
|
|
10402
|
+
/**
|
|
10403
|
+
* Parse one `wikiRoots` entry.
|
|
10404
|
+
*
|
|
10405
|
+
* @param link Link as configured (surrounding whitespace is ignored).
|
|
10406
|
+
* @returns The root, or the reason it is invalid.
|
|
10407
|
+
*/
|
|
10408
|
+
function parseWikiRootLink(link) {
|
|
10409
|
+
const trimmed = link.trim();
|
|
10410
|
+
let url;
|
|
10411
|
+
try {
|
|
10412
|
+
url = new URL(trimmed);
|
|
10413
|
+
} catch {
|
|
10414
|
+
return {
|
|
10415
|
+
ok: false,
|
|
10416
|
+
error: "not-a-url"
|
|
10417
|
+
};
|
|
10418
|
+
}
|
|
10419
|
+
if (url.protocol !== "https:") return {
|
|
10420
|
+
ok: false,
|
|
10421
|
+
error: "not-https"
|
|
10422
|
+
};
|
|
10423
|
+
const segments = url.pathname.split("/").filter((segment) => segment !== "");
|
|
10424
|
+
if (segments[0] !== "wiki") return {
|
|
10425
|
+
ok: false,
|
|
10426
|
+
error: "not-a-wiki-link"
|
|
10427
|
+
};
|
|
10428
|
+
if (segments.length === 3 && segments[1] === "space" && SPACE_ID_PATTERN.test(segments[2])) return {
|
|
10429
|
+
ok: true,
|
|
10430
|
+
root: {
|
|
10431
|
+
kind: "space",
|
|
10432
|
+
spaceId: segments[2],
|
|
10433
|
+
link: trimmed
|
|
10434
|
+
}
|
|
10435
|
+
};
|
|
10436
|
+
if (segments.length === 2 && segments[1] !== "space" && TOKEN_PATTERN.test(segments[1])) return {
|
|
10437
|
+
ok: true,
|
|
10438
|
+
root: {
|
|
10439
|
+
kind: "node",
|
|
10440
|
+
nodeToken: segments[1],
|
|
10441
|
+
link: trimmed
|
|
10442
|
+
}
|
|
10443
|
+
};
|
|
10444
|
+
return {
|
|
10445
|
+
ok: false,
|
|
10446
|
+
error: "not-a-wiki-link"
|
|
10447
|
+
};
|
|
10448
|
+
}
|
|
10449
|
+
/**
|
|
10450
|
+
* Parse every configured root. Callers validate the config first, so an
|
|
10451
|
+
* invalid entry here is a programming error.
|
|
10452
|
+
*
|
|
10453
|
+
* @param links Validated `wikiRoots` entries.
|
|
10454
|
+
* @returns The parsed roots, in configuration order.
|
|
10455
|
+
*/
|
|
10456
|
+
function parseWikiRoots(links) {
|
|
10457
|
+
return links.map((link) => {
|
|
10458
|
+
const parsed = parseWikiRootLink(link);
|
|
10459
|
+
if (!parsed.ok) throw new Error(`dsh-channels: invalid wikiRoots entry ${link}: ${WIKI_ROOT_LINK_ERRORS[parsed.error]}`);
|
|
10460
|
+
return parsed.root;
|
|
10461
|
+
});
|
|
10462
|
+
}
|
|
10463
|
+
/**
|
|
10464
|
+
* Parse a model-supplied reference: a wiki node link, a docx link, a space
|
|
10465
|
+
* link, or a bare node token.
|
|
10466
|
+
*
|
|
10467
|
+
* @param input Raw tool argument.
|
|
10468
|
+
* @returns The reference, or `undefined` when it names nothing recognizable.
|
|
10469
|
+
*/
|
|
10470
|
+
function parseWikiRef(input) {
|
|
10471
|
+
const trimmed = input.trim();
|
|
10472
|
+
if (TOKEN_PATTERN.test(trimmed)) return {
|
|
10473
|
+
kind: "node",
|
|
10474
|
+
token: trimmed
|
|
10475
|
+
};
|
|
10476
|
+
let url;
|
|
10477
|
+
try {
|
|
10478
|
+
url = new URL(trimmed);
|
|
10479
|
+
} catch {
|
|
10480
|
+
return;
|
|
10481
|
+
}
|
|
10482
|
+
if (url.protocol !== "https:") return void 0;
|
|
10483
|
+
const segments = url.pathname.split("/").filter((segment) => segment !== "");
|
|
10484
|
+
if (segments[0] === "wiki" && segments.length === 3 && segments[1] === "space" && SPACE_ID_PATTERN.test(segments[2])) return {
|
|
10485
|
+
kind: "space",
|
|
10486
|
+
spaceId: segments[2]
|
|
10487
|
+
};
|
|
10488
|
+
if (segments.length === 2 && TOKEN_PATTERN.test(segments[1])) {
|
|
10489
|
+
if (segments[0] === "wiki" && segments[1] !== "space") return {
|
|
10490
|
+
kind: "node",
|
|
10491
|
+
token: segments[1]
|
|
10492
|
+
};
|
|
10493
|
+
if (segments[0] === "docx") return {
|
|
10494
|
+
kind: "docx",
|
|
10495
|
+
token: segments[1]
|
|
10496
|
+
};
|
|
10497
|
+
}
|
|
10498
|
+
}
|
|
10499
|
+
//#endregion
|
|
10500
|
+
//#region src/feishu/wiki/scope.ts
|
|
10501
|
+
/**
|
|
10502
|
+
* Longest parent chain walked for a node root. Feishu limits wiki depth well
|
|
10503
|
+
* below this; a longer chain is treated as outside.
|
|
10504
|
+
*/
|
|
10505
|
+
const MAX_PARENT_DEPTH = 64;
|
|
10506
|
+
/**
|
|
10507
|
+
* Find the configured root containing `node`.
|
|
10508
|
+
*
|
|
10509
|
+
* @param api Wiki endpoints (for parent lookups).
|
|
10510
|
+
* @param roots Configured roots.
|
|
10511
|
+
* @param node The node to check.
|
|
10512
|
+
* @returns The containing root, or `undefined` when none contains it.
|
|
10513
|
+
*/
|
|
10514
|
+
async function containingRoot(api, roots, node) {
|
|
10515
|
+
for (const root of roots) if (root.kind === "space" && root.spaceId === node.spaceId) return { root };
|
|
10516
|
+
const nodeRoots = roots.filter((root) => root.kind === "node");
|
|
10517
|
+
if (nodeRoots.length === 0) return void 0;
|
|
10518
|
+
const byToken = new Map(nodeRoots.map((root) => [root.nodeToken, root]));
|
|
10519
|
+
let current = node;
|
|
10520
|
+
for (let depth = 0; depth <= MAX_PARENT_DEPTH; depth += 1) {
|
|
10521
|
+
const match = byToken.get(current.nodeToken);
|
|
10522
|
+
if (match !== void 0) return { root: match };
|
|
10523
|
+
if (current.parentNodeToken === void 0) return void 0;
|
|
10524
|
+
current = await api.getNode(current.parentNodeToken, "wiki");
|
|
10525
|
+
}
|
|
10526
|
+
}
|
|
10527
|
+
/**
|
|
10528
|
+
* Whether a whole space (its top level) is inside the roots: only a space
|
|
10529
|
+
* root covers a space's top level.
|
|
10530
|
+
*
|
|
10531
|
+
* @param roots Configured roots.
|
|
10532
|
+
* @param spaceId The space.
|
|
10533
|
+
* @returns True when a space root names it.
|
|
10534
|
+
*/
|
|
10535
|
+
function spaceIsRoot(roots, spaceId) {
|
|
10536
|
+
return roots.some((root) => root.kind === "space" && root.spaceId === spaceId);
|
|
10537
|
+
}
|
|
10538
|
+
//#endregion
|
|
10539
|
+
//#region src/feishu/wiki/base-tools.ts
|
|
10540
|
+
/**
|
|
10541
|
+
* Wiki Base (多维表格) tools: `feishu_wiki_base_read` (switch
|
|
10542
|
+
* `feishuTools.wikiRead`) and `feishu_wiki_base_edit` (switch
|
|
10543
|
+
* `feishuTools.wikiWrite`).
|
|
10544
|
+
*
|
|
10545
|
+
* Both run through the same gates as the docx tools (whitelist, `wikiRoots`
|
|
10546
|
+
* containment, consent cards). Reading shares the docx read grant per Session
|
|
10547
|
+
* and document. Editing changes records only (no table or field structure):
|
|
10548
|
+
* on a base the app owns it may create, update, and delete records; on any
|
|
10549
|
+
* other base it may only create records, so existing data written by people
|
|
10550
|
+
* is never changed. Every edit asks on a consent card. Feishu offers no
|
|
10551
|
+
* revision precondition for record writes, and applies them asynchronously.
|
|
10552
|
+
*
|
|
10553
|
+
* @module dsh-channels/feishu/wiki/base-tools
|
|
10554
|
+
*/
|
|
10555
|
+
/** Model-facing tool names; stable across releases (session history references them). */
|
|
10556
|
+
const WIKI_BASE_READ_TOOL_NAME = "feishu_wiki_base_read";
|
|
10557
|
+
const WIKI_BASE_EDIT_TOOL_NAME = "feishu_wiki_base_edit";
|
|
10558
|
+
/** Record page size bounds of `feishu_wiki_base_read`. */
|
|
10559
|
+
const READ_DEFAULT_LIMIT = 50;
|
|
10560
|
+
const READ_MAX_LIMIT = 200;
|
|
10561
|
+
/** Records of each operation shown on a consent card. */
|
|
10562
|
+
const CARD_PREVIEW_RECORDS = 3;
|
|
10563
|
+
const refSchema$2 = z.string().trim().min(1).max(2048);
|
|
10564
|
+
const nameSchema = z.string().trim().min(1).max(200);
|
|
10565
|
+
const recordIdSchema = z.string().trim().min(1).max(64);
|
|
10566
|
+
const fieldMapSchema = z.record(z.string().min(1).max(200), z.unknown()).refine((map) => Object.keys(map).length > 0, "至少包含一个字段");
|
|
10567
|
+
const wikiBaseReadArgsSchema = z.object({
|
|
10568
|
+
doc: refSchema$2,
|
|
10569
|
+
table: nameSchema.optional(),
|
|
10570
|
+
filter: z.object({
|
|
10571
|
+
logic: z.enum(["and", "or"]).optional(),
|
|
10572
|
+
conditions: z.array(z.array(z.unknown()).min(2).max(3)).min(1).max(50)
|
|
10573
|
+
}).strict().optional(),
|
|
10574
|
+
sort: z.array(z.object({
|
|
10575
|
+
field: nameSchema,
|
|
10576
|
+
desc: z.boolean().optional()
|
|
10577
|
+
}).strict()).min(1).max(10).optional(),
|
|
10578
|
+
keyword: z.string().trim().min(1).max(200).optional(),
|
|
10579
|
+
search_fields: z.array(nameSchema).min(1).max(20).optional(),
|
|
10580
|
+
fields: z.array(nameSchema).min(1).max(100).optional(),
|
|
10581
|
+
view_id: nameSchema.optional(),
|
|
10582
|
+
offset: z.number().int().min(0).optional(),
|
|
10583
|
+
limit: z.number().int().min(1).max(READ_MAX_LIMIT).optional()
|
|
10584
|
+
}).strict();
|
|
10585
|
+
const baseOperationSchema = z.discriminatedUnion("command", [
|
|
10586
|
+
z.object({
|
|
10587
|
+
command: z.literal("create"),
|
|
10588
|
+
records: z.array(fieldMapSchema).min(1).max(200)
|
|
10589
|
+
}).strict(),
|
|
10590
|
+
z.object({
|
|
10591
|
+
command: z.literal("update"),
|
|
10592
|
+
records: z.array(z.object({
|
|
10593
|
+
record_id: recordIdSchema,
|
|
10594
|
+
fields: fieldMapSchema
|
|
10595
|
+
}).strict()).min(1).max(200)
|
|
10596
|
+
}).strict(),
|
|
10597
|
+
z.object({
|
|
10598
|
+
command: z.literal("delete"),
|
|
10599
|
+
record_ids: z.array(recordIdSchema).min(1).max(200)
|
|
10600
|
+
}).strict()
|
|
10601
|
+
]);
|
|
10602
|
+
const wikiBaseEditArgsSchema = z.object({
|
|
10603
|
+
doc: refSchema$2,
|
|
10604
|
+
table: nameSchema,
|
|
10605
|
+
operations: z.array(baseOperationSchema).min(1).max(10)
|
|
10606
|
+
}).strict();
|
|
10607
|
+
const READ_DESCRIPTION$2 = `读取知识库中的飞书多维表格(bitable)。doc 传知识库节点链接或 node_token。第一次读取某个多维表格会在话题里弹出授权卡,由话题发起人确认,确认后本话题内再次读取不再询问;等待期间不要重复调用。不传 table 时只返回数据表列表;传 table(table_id 或表名)时返回字段列表和记录。filter 形如 {"logic":"and","conditions":[["状态","intersects",["进行中"]],["金额",">=",100],["备注","non_empty"]]},运算符有 == != > >= < <= intersects(包含)disjoint(不包含)empty non_empty;日期条件用 "ExactDate(2026-08-07)",日期不支持 >=。sort 形如 [{"field":"更新时间","desc":true}];keyword 为关键词搜索,search_fields 指定搜索的字段(默认主字段);fields 只返回指定字段。分页用 offset/limit(limit 默认 ${READ_DEFAULT_LIMIT},最多 ${READ_MAX_LIMIT}),has_more 为 true 时用 next_offset 继续。writable 说明能做哪些修改:all 表示可增改删记录(本应用创建的表),create 表示只能新增记录,none 表示不能修改。`;
|
|
10608
|
+
const EDIT_DESCRIPTION$2 = `修改知识库中飞书多维表格(bitable)某个数据表的记录。先用 feishu_wiki_base_read 读取字段列表,按字段名写入。operations 按顺序执行(最多 10 个,每个最多 200 条记录):create(records 为字段名→值的对象数组)、update(records 为 {record_id, fields} 数组,只改给出的字段)、delete(record_ids)。本应用创建的多维表格可以增改删;其他多维表格只能新增记录,不能修改或删除已有记录。不会修改数据表和字段结构。单元格取值:文本为字符串;数字为数字;单选/多选为选项名数组(如 ["进行中"],单选也是数组);日期为 "2026-03-24 10:00" 或毫秒时间戳;复选框为布尔;人员为 [{"id":"ou_xxx"}];关联为 [{"id":"rec_xxx"}];null 清空。公式、查找引用、创建时间等字段只读。写入是异步生效的,立刻读取可能还看不到。每次修改都会在话题里弹出授权卡,由话题发起人确认后才执行;等待期间不要重复调用。`;
|
|
10609
|
+
const REF_DESCRIPTION$2 = "知识库节点链接 https://<租户>.feishu.cn/wiki/<node_token> 或 node_token";
|
|
10610
|
+
const TABLE_PROPERTY = {
|
|
10611
|
+
type: "string",
|
|
10612
|
+
description: "数据表 table_id 或表名"
|
|
10613
|
+
};
|
|
10614
|
+
const FIELD_MAP_PROPERTY = {
|
|
10615
|
+
type: "object",
|
|
10616
|
+
description: "字段名(或字段 id)→ 值",
|
|
10617
|
+
additionalProperties: true
|
|
10618
|
+
};
|
|
10619
|
+
const READ_PARAMETERS$2 = {
|
|
10620
|
+
type: "object",
|
|
10621
|
+
properties: {
|
|
10622
|
+
doc: {
|
|
10623
|
+
type: "string",
|
|
10624
|
+
description: REF_DESCRIPTION$2
|
|
10625
|
+
},
|
|
10626
|
+
table: {
|
|
10627
|
+
...TABLE_PROPERTY,
|
|
10628
|
+
description: "数据表 table_id 或表名;不传只列出数据表"
|
|
10629
|
+
},
|
|
10630
|
+
filter: {
|
|
10631
|
+
type: "object",
|
|
10632
|
+
properties: {
|
|
10633
|
+
logic: {
|
|
10634
|
+
type: "string",
|
|
10635
|
+
enum: ["and", "or"]
|
|
10636
|
+
},
|
|
10637
|
+
conditions: {
|
|
10638
|
+
type: "array",
|
|
10639
|
+
items: {
|
|
10640
|
+
type: "array",
|
|
10641
|
+
description: "[字段, 运算符, 值];empty / non_empty 不带值"
|
|
10642
|
+
}
|
|
10643
|
+
}
|
|
10644
|
+
},
|
|
10645
|
+
required: ["conditions"],
|
|
10646
|
+
additionalProperties: false
|
|
10647
|
+
},
|
|
10648
|
+
sort: {
|
|
10649
|
+
type: "array",
|
|
10650
|
+
items: {
|
|
10651
|
+
type: "object",
|
|
10652
|
+
properties: {
|
|
10653
|
+
field: { type: "string" },
|
|
10654
|
+
desc: { type: "boolean" }
|
|
10655
|
+
},
|
|
10656
|
+
required: ["field"],
|
|
10657
|
+
additionalProperties: false
|
|
10658
|
+
}
|
|
10659
|
+
},
|
|
10660
|
+
keyword: {
|
|
10661
|
+
type: "string",
|
|
10662
|
+
description: "关键词搜索"
|
|
10663
|
+
},
|
|
10664
|
+
search_fields: {
|
|
10665
|
+
type: "array",
|
|
10666
|
+
items: { type: "string" },
|
|
10667
|
+
description: "keyword 搜索的字段,默认主字段"
|
|
10668
|
+
},
|
|
10669
|
+
fields: {
|
|
10670
|
+
type: "array",
|
|
10671
|
+
items: { type: "string" },
|
|
10672
|
+
description: "只返回这些字段"
|
|
10673
|
+
},
|
|
10674
|
+
view_id: {
|
|
10675
|
+
type: "string",
|
|
10676
|
+
description: "按视图读取(应用视图的筛选与排序)"
|
|
10677
|
+
},
|
|
10678
|
+
offset: {
|
|
10679
|
+
type: "integer",
|
|
10680
|
+
minimum: 0
|
|
10681
|
+
},
|
|
10682
|
+
limit: {
|
|
10683
|
+
type: "integer",
|
|
10684
|
+
minimum: 1,
|
|
10685
|
+
maximum: READ_MAX_LIMIT
|
|
10686
|
+
}
|
|
10687
|
+
},
|
|
10688
|
+
required: ["doc"],
|
|
10689
|
+
additionalProperties: false
|
|
10690
|
+
};
|
|
10691
|
+
const EDIT_PARAMETERS$2 = {
|
|
10692
|
+
type: "object",
|
|
10693
|
+
properties: {
|
|
10694
|
+
doc: {
|
|
10695
|
+
type: "string",
|
|
10696
|
+
description: REF_DESCRIPTION$2
|
|
10697
|
+
},
|
|
10698
|
+
table: TABLE_PROPERTY,
|
|
10699
|
+
operations: {
|
|
10700
|
+
type: "array",
|
|
10701
|
+
minItems: 1,
|
|
10702
|
+
maxItems: 10,
|
|
10703
|
+
items: {
|
|
10704
|
+
type: "object",
|
|
10705
|
+
properties: {
|
|
10706
|
+
command: {
|
|
10707
|
+
type: "string",
|
|
10708
|
+
enum: [
|
|
10709
|
+
"create",
|
|
10710
|
+
"update",
|
|
10711
|
+
"delete"
|
|
10712
|
+
]
|
|
10713
|
+
},
|
|
10714
|
+
records: {
|
|
10715
|
+
type: "array",
|
|
10716
|
+
maxItems: 200,
|
|
10717
|
+
description: "create:字段映射数组;update:{record_id, fields} 数组",
|
|
10718
|
+
items: {
|
|
10719
|
+
type: "object",
|
|
10720
|
+
properties: {
|
|
10721
|
+
record_id: { type: "string" },
|
|
10722
|
+
fields: FIELD_MAP_PROPERTY
|
|
10723
|
+
},
|
|
10724
|
+
additionalProperties: true
|
|
10725
|
+
}
|
|
10726
|
+
},
|
|
10727
|
+
record_ids: {
|
|
10728
|
+
type: "array",
|
|
10729
|
+
maxItems: 200,
|
|
10730
|
+
items: { type: "string" },
|
|
10731
|
+
description: "delete 的记录 id"
|
|
10732
|
+
}
|
|
10733
|
+
},
|
|
10734
|
+
required: ["command"],
|
|
10735
|
+
additionalProperties: false
|
|
10736
|
+
}
|
|
10737
|
+
}
|
|
10738
|
+
},
|
|
10739
|
+
required: [
|
|
10740
|
+
"doc",
|
|
10741
|
+
"table",
|
|
10742
|
+
"operations"
|
|
10743
|
+
],
|
|
10744
|
+
additionalProperties: false
|
|
10745
|
+
};
|
|
10746
|
+
async function baseTarget(kit, context, doc) {
|
|
10747
|
+
const bases = context.bases;
|
|
10748
|
+
if (bases === void 0) return failure("CHANNEL_UNAVAILABLE");
|
|
10749
|
+
const ref = parseWikiRef(doc);
|
|
10750
|
+
if (ref === void 0) return failure("INVALID_REFERENCE");
|
|
10751
|
+
const node = await kit.containedNode(context, ref);
|
|
10752
|
+
if ("ok" in node) return node;
|
|
10753
|
+
if (node.objType !== "bitable") return failure("UNSUPPORTED_TYPE", `${failure("UNSUPPORTED_TYPE").reason}(该节点类型:${node.objType})`);
|
|
10754
|
+
return {
|
|
10755
|
+
context,
|
|
10756
|
+
bases,
|
|
10757
|
+
node,
|
|
10758
|
+
url: nodeUrl(context.roots, node)
|
|
10759
|
+
};
|
|
10760
|
+
}
|
|
10761
|
+
function writableOf(kit, target) {
|
|
10762
|
+
if (!kit.writeEnabled()) return "none";
|
|
10763
|
+
return ownedByApp(target.node, target.context.botOpenId) ? "all" : "create";
|
|
10764
|
+
}
|
|
10765
|
+
function pickTable(tables, wanted) {
|
|
10766
|
+
const match = tables.find((table) => table.tableId === wanted) ?? tables.find((table) => table.name === wanted);
|
|
10767
|
+
if (match !== void 0) return match;
|
|
10768
|
+
return `找不到数据表「${wanted}」;可用数据表:${tables.map((table) => `${table.name}(${table.tableId})`).join("、") || "无"}`;
|
|
10769
|
+
}
|
|
10770
|
+
function fieldView(field) {
|
|
10771
|
+
return {
|
|
10772
|
+
...field.properties,
|
|
10773
|
+
field_id: field.fieldId,
|
|
10774
|
+
name: field.name,
|
|
10775
|
+
type: field.type
|
|
10776
|
+
};
|
|
10777
|
+
}
|
|
10778
|
+
async function read$1(kit, sessionId, rawArgs, signal) {
|
|
10779
|
+
const args = parseArgs(WIKI_BASE_READ_TOOL_NAME, wikiBaseReadArgsSchema, rawArgs);
|
|
10780
|
+
const gate = kit.gate(sessionId, WIKI_BASE_READ_TOOL_NAME);
|
|
10781
|
+
if (!gate.ok) return gate;
|
|
10782
|
+
return await guarded$1(kit, WIKI_BASE_READ_TOOL_NAME, async () => {
|
|
10783
|
+
const target = await baseTarget(kit, gate.context, args.doc);
|
|
10784
|
+
if ("ok" in target) return target;
|
|
10785
|
+
const denied = await kit.readConsent(sessionId, target.context, target.node, signal);
|
|
10786
|
+
if (denied !== void 0) return denied;
|
|
10787
|
+
if (signal.aborted) return failure("ABORTED");
|
|
10788
|
+
const token = target.node.objToken;
|
|
10789
|
+
const tables = await target.bases.listTables(token);
|
|
10790
|
+
const base = {
|
|
10791
|
+
ok: true,
|
|
10792
|
+
title: target.node.title,
|
|
10793
|
+
node_token: target.node.nodeToken,
|
|
10794
|
+
url: target.url,
|
|
10795
|
+
tables: tables.map((table) => ({
|
|
10796
|
+
table_id: table.tableId,
|
|
10797
|
+
name: table.name
|
|
10798
|
+
})),
|
|
10799
|
+
writable: writableOf(kit, target)
|
|
10800
|
+
};
|
|
10801
|
+
if (args.table === void 0) return base;
|
|
10802
|
+
const table = pickTable(tables, args.table);
|
|
10803
|
+
if (typeof table === "string") return failure("INVALID_OPERATION", table);
|
|
10804
|
+
const fields = await target.bases.listFields(token, table.tableId);
|
|
10805
|
+
const offset = args.offset ?? 0;
|
|
10806
|
+
const query = {
|
|
10807
|
+
...args.filter === void 0 ? {} : { filter: args.filter },
|
|
10808
|
+
...args.sort === void 0 ? {} : { sort: args.sort },
|
|
10809
|
+
...args.fields === void 0 ? {} : { fields: args.fields },
|
|
10810
|
+
...args.view_id === void 0 ? {} : { viewId: args.view_id },
|
|
10811
|
+
offset,
|
|
10812
|
+
limit: args.limit ?? READ_DEFAULT_LIMIT
|
|
10813
|
+
};
|
|
10814
|
+
let page;
|
|
10815
|
+
if (args.keyword === void 0) page = await target.bases.listRecords(token, table.tableId, query);
|
|
10816
|
+
else {
|
|
10817
|
+
const primary = fields[0]?.name;
|
|
10818
|
+
const searchFields = args.search_fields ?? (primary === void 0 ? [] : [primary]);
|
|
10819
|
+
if (searchFields.length === 0) return failure("INVALID_OPERATION", "keyword 搜索需要 search_fields");
|
|
10820
|
+
page = await target.bases.searchRecords(token, table.tableId, {
|
|
10821
|
+
...query,
|
|
10822
|
+
keyword: args.keyword,
|
|
10823
|
+
searchFields
|
|
10824
|
+
});
|
|
10825
|
+
}
|
|
10826
|
+
return {
|
|
10827
|
+
...base,
|
|
10828
|
+
table_id: table.tableId,
|
|
10829
|
+
fields: fields.map(fieldView),
|
|
10830
|
+
records: page.records.map((record) => ({
|
|
10831
|
+
record_id: record.recordId,
|
|
10832
|
+
fields: record.fields
|
|
10833
|
+
})),
|
|
10834
|
+
has_more: page.hasMore,
|
|
10835
|
+
...page.hasMore ? { next_offset: offset + page.records.length } : {},
|
|
10836
|
+
...page.timezone === void 0 ? {} : { timezone: page.timezone },
|
|
10837
|
+
...page.ignoredFields.length === 0 ? {} : { ignored_fields: page.ignoredFields }
|
|
10838
|
+
};
|
|
10839
|
+
});
|
|
10840
|
+
}
|
|
10841
|
+
function json(value) {
|
|
10842
|
+
return preview(JSON.stringify(value) ?? "null", 120);
|
|
10843
|
+
}
|
|
10844
|
+
function cardLines$1(operations, tableName) {
|
|
10845
|
+
const lines = operations.map((operation, index) => {
|
|
10846
|
+
const n = `${index + 1}.`;
|
|
10847
|
+
switch (operation.command) {
|
|
10848
|
+
case "create": {
|
|
10849
|
+
const sample = operation.records.slice(0, CARD_PREVIEW_RECORDS).map((record) => ` ${json(record)}`);
|
|
10850
|
+
const more = operation.records.length > CARD_PREVIEW_RECORDS ? [` …(共 ${operation.records.length} 条)`] : [];
|
|
10851
|
+
return [
|
|
10852
|
+
`${n} 新增 ${operation.records.length} 条记录`,
|
|
10853
|
+
...sample,
|
|
10854
|
+
...more
|
|
10855
|
+
].join("\n");
|
|
10856
|
+
}
|
|
10857
|
+
case "update": {
|
|
10858
|
+
const sample = operation.records.slice(0, CARD_PREVIEW_RECORDS).map((record) => ` ${preview(record.record_id, 40)}:${json(record.fields)}`);
|
|
10859
|
+
const more = operation.records.length > CARD_PREVIEW_RECORDS ? [` …(共 ${operation.records.length} 条)`] : [];
|
|
10860
|
+
return [
|
|
10861
|
+
`${n} 修改 ${operation.records.length} 条记录`,
|
|
10862
|
+
...sample,
|
|
10863
|
+
...more
|
|
10864
|
+
].join("\n");
|
|
10865
|
+
}
|
|
10866
|
+
case "delete": return `${n} ⚠️ 删除 ${operation.record_ids.length} 条记录:${preview(operation.record_ids.join("、"), 160)}`;
|
|
10867
|
+
}
|
|
10868
|
+
});
|
|
10869
|
+
return [
|
|
10870
|
+
`**数据表**:${preview(tableName, 60)}`,
|
|
10871
|
+
`**将执行 ${operations.length} 个操作**`,
|
|
10872
|
+
...lines
|
|
10873
|
+
];
|
|
10874
|
+
}
|
|
10875
|
+
async function edit$1(kit, sessionId, rawArgs, signal) {
|
|
10876
|
+
const args = parseArgs(WIKI_BASE_EDIT_TOOL_NAME, wikiBaseEditArgsSchema, rawArgs);
|
|
10877
|
+
const gate = kit.gate(sessionId, WIKI_BASE_EDIT_TOOL_NAME);
|
|
10878
|
+
if (!gate.ok) return gate;
|
|
10879
|
+
return await guarded$1(kit, WIKI_BASE_EDIT_TOOL_NAME, async () => {
|
|
10880
|
+
const target = await baseTarget(kit, gate.context, args.doc);
|
|
10881
|
+
if ("ok" in target) return target;
|
|
10882
|
+
const { node, bases } = target;
|
|
10883
|
+
if (writableOf(kit, target) !== "all") {
|
|
10884
|
+
const changing = args.operations.find((operation) => operation.command !== "create");
|
|
10885
|
+
if (changing !== void 0) return failure("NOT_OWNED", `这个多维表格不是本应用创建的,只能新增记录,不能${changing.command === "update" ? "修改" : "删除"}已有记录(所有者:${node.owner ?? "未知"})`);
|
|
10886
|
+
}
|
|
10887
|
+
const table = pickTable(await bases.listTables(node.objToken), args.table);
|
|
10888
|
+
if (typeof table === "string") return failure("INVALID_OPERATION", table);
|
|
10889
|
+
const denied = await kit.consent(sessionId, target.context, "edit", {
|
|
10890
|
+
title: node.title,
|
|
10891
|
+
url: target.url,
|
|
10892
|
+
location: await kit.locationOf(target.context.api, node),
|
|
10893
|
+
details: cardLines$1(args.operations, table.name)
|
|
10894
|
+
}, signal);
|
|
10895
|
+
if (denied !== void 0) return denied;
|
|
10896
|
+
const created = [];
|
|
10897
|
+
const ignored = [];
|
|
10898
|
+
let updated = 0;
|
|
10899
|
+
let deleted = 0;
|
|
10900
|
+
for (const [index, operation] of args.operations.entries()) {
|
|
10901
|
+
if (signal.aborted) return {
|
|
10902
|
+
...failure("ABORTED", `已取消;前 ${index} 个操作已执行`),
|
|
10903
|
+
applied: index
|
|
10904
|
+
};
|
|
10905
|
+
try {
|
|
10906
|
+
switch (operation.command) {
|
|
10907
|
+
case "create":
|
|
10908
|
+
created.push(...await bases.createRecords(node.objToken, table.tableId, operation.records));
|
|
10909
|
+
break;
|
|
10910
|
+
case "update":
|
|
10911
|
+
ignored.push(...await bases.updateRecords(node.objToken, table.tableId, Object.fromEntries(operation.records.map((record) => [record.record_id, record.fields]))));
|
|
10912
|
+
updated += operation.records.length;
|
|
10913
|
+
break;
|
|
10914
|
+
case "delete":
|
|
10915
|
+
await bases.deleteRecords(node.objToken, table.tableId, operation.record_ids);
|
|
10916
|
+
deleted += operation.record_ids.length;
|
|
10917
|
+
break;
|
|
10918
|
+
}
|
|
10919
|
+
} catch (error) {
|
|
10920
|
+
const reason = failureOf(error).reason;
|
|
10921
|
+
return {
|
|
10922
|
+
...failure("EDIT_FAILED", `第 ${index + 1} 个操作失败:${reason};前 ${index} 个操作已执行`),
|
|
10923
|
+
applied: index
|
|
10924
|
+
};
|
|
10925
|
+
}
|
|
10926
|
+
}
|
|
10927
|
+
return {
|
|
10928
|
+
ok: true,
|
|
10929
|
+
title: node.title,
|
|
10930
|
+
node_token: node.nodeToken,
|
|
10931
|
+
url: target.url,
|
|
10932
|
+
table_id: table.tableId,
|
|
10933
|
+
applied: args.operations.length,
|
|
10934
|
+
created_record_ids: created,
|
|
10935
|
+
updated,
|
|
10936
|
+
deleted,
|
|
10937
|
+
ignored_fields: [...new Set(ignored)]
|
|
10938
|
+
};
|
|
10939
|
+
});
|
|
10940
|
+
}
|
|
10941
|
+
/** The Base tool family. */
|
|
10942
|
+
const BASE_TOOL_SPECS = [{
|
|
10943
|
+
name: WIKI_BASE_READ_TOOL_NAME,
|
|
10944
|
+
access: "read",
|
|
10945
|
+
description: READ_DESCRIPTION$2,
|
|
10946
|
+
parameters: READ_PARAMETERS$2,
|
|
10947
|
+
execute: read$1,
|
|
10948
|
+
presentCall: (args) => `读取知识库多维表格:${display(args.doc)}`
|
|
10949
|
+
}, {
|
|
10950
|
+
name: WIKI_BASE_EDIT_TOOL_NAME,
|
|
10951
|
+
access: "write",
|
|
10952
|
+
description: EDIT_DESCRIPTION$2,
|
|
10953
|
+
parameters: EDIT_PARAMETERS$2,
|
|
10954
|
+
execute: edit$1,
|
|
10955
|
+
presentCall: (args) => `修改知识库多维表格:${display(args.doc)}`
|
|
10956
|
+
}];
|
|
10957
|
+
//#endregion
|
|
10958
|
+
//#region src/feishu/wiki/a1.ts
|
|
10959
|
+
/**
|
|
10960
|
+
* A1 notation helpers for the wiki sheet tools: column letters, single cells,
|
|
10961
|
+
* and rectangle end rows. Ranges never carry a sheet prefix (`Sheet1!`); the
|
|
10962
|
+
* sub-sheet is selected separately.
|
|
10963
|
+
*
|
|
10964
|
+
* @module dsh-channels/feishu/wiki/a1
|
|
10965
|
+
*/
|
|
10966
|
+
/** Largest column the helpers accept (`ZZZ`). */
|
|
10967
|
+
const MAX_COLUMN = 18278;
|
|
10968
|
+
/** Largest row the helpers accept. */
|
|
10969
|
+
const MAX_ROW = 1e7;
|
|
10970
|
+
/**
|
|
10971
|
+
* Column letters to a 1-based index (`A` → 1, `AA` → 27).
|
|
10972
|
+
*
|
|
10973
|
+
* @param letters Column letters, any case.
|
|
10974
|
+
* @returns The index, or undefined when invalid or beyond `ZZZ`.
|
|
10975
|
+
*/
|
|
10976
|
+
function columnIndex(letters) {
|
|
10977
|
+
if (!/^[A-Za-z]{1,3}$/u.test(letters)) return void 0;
|
|
10978
|
+
let index = 0;
|
|
10979
|
+
for (const char of letters.toUpperCase()) index = index * 26 + (char.charCodeAt(0) - 64);
|
|
10980
|
+
return index <= MAX_COLUMN ? index : void 0;
|
|
10981
|
+
}
|
|
10982
|
+
/**
|
|
10983
|
+
* A 1-based column index to letters (1 → `A`, 27 → `AA`).
|
|
10984
|
+
*
|
|
10985
|
+
* @param index Column index, at least 1.
|
|
10986
|
+
* @returns The letters.
|
|
10987
|
+
*/
|
|
10988
|
+
function columnLetters(index) {
|
|
10989
|
+
let letters = "";
|
|
10990
|
+
let rest = index;
|
|
10991
|
+
while (rest > 0) {
|
|
10992
|
+
const digit = (rest - 1) % 26;
|
|
10993
|
+
letters = String.fromCharCode(65 + digit) + letters;
|
|
10994
|
+
rest = Math.floor((rest - 1) / 26);
|
|
10995
|
+
}
|
|
10996
|
+
return letters;
|
|
10997
|
+
}
|
|
10998
|
+
/**
|
|
10999
|
+
* Parse one cell (`B3`).
|
|
11000
|
+
*
|
|
11001
|
+
* @param value Cell reference.
|
|
11002
|
+
* @returns The position, or undefined when it is not a single valid cell.
|
|
11003
|
+
*/
|
|
11004
|
+
function parseCell(value) {
|
|
11005
|
+
const match = /^([A-Za-z]{1,3})([1-9]\d{0,7})$/u.exec(value.trim());
|
|
11006
|
+
if (match === null) return void 0;
|
|
11007
|
+
const column = columnIndex(match[1] ?? "");
|
|
11008
|
+
const row = Number(match[2]);
|
|
11009
|
+
return column === void 0 || row > MAX_ROW ? void 0 : {
|
|
11010
|
+
column,
|
|
11011
|
+
row
|
|
11012
|
+
};
|
|
11013
|
+
}
|
|
11014
|
+
/**
|
|
11015
|
+
* The rectangle that starts at `start` and spans `rows` × `columns` cells.
|
|
11016
|
+
*
|
|
11017
|
+
* @param start Top-left cell.
|
|
11018
|
+
* @param rows Row count, at least 1.
|
|
11019
|
+
* @param columns Column count, at least 1.
|
|
11020
|
+
* @returns The range (`B3:D10`).
|
|
11021
|
+
*/
|
|
11022
|
+
function rectangle(start, rows, columns) {
|
|
11023
|
+
return `${columnLetters(start.column)}${start.row}:${columnLetters(start.column + columns - 1)}${start.row + rows - 1}`;
|
|
11024
|
+
}
|
|
11025
|
+
/**
|
|
11026
|
+
* Last row of a range (`A1:B5` → 5, `C7` → 7); undefined for whole-column ranges (`A:C`).
|
|
11027
|
+
*
|
|
11028
|
+
* @param range A1 range.
|
|
11029
|
+
* @returns The row, or undefined.
|
|
11030
|
+
*/
|
|
11031
|
+
function rangeEndRow(range) {
|
|
11032
|
+
const end = range.split(":").at(-1) ?? "";
|
|
11033
|
+
const match = /(\d+)$/u.exec(end.trim());
|
|
11034
|
+
return match === null ? void 0 : Number(match[1]);
|
|
11035
|
+
}
|
|
11036
|
+
/**
|
|
11037
|
+
* Whether a range is plain A1 (`A1`, `A1:F30`, `A:C`, `3:5`) without a sheet prefix.
|
|
11038
|
+
*
|
|
11039
|
+
* @param range Candidate range.
|
|
11040
|
+
* @returns True when acceptable.
|
|
11041
|
+
*/
|
|
11042
|
+
function isA1Range(range) {
|
|
11043
|
+
return /^(?:[A-Za-z]{1,3}\d{0,8}|\d{1,8})(?::(?:[A-Za-z]{1,3}\d{0,8}|\d{1,8}))?$/u.test(range);
|
|
11044
|
+
}
|
|
11045
|
+
//#endregion
|
|
11046
|
+
//#region src/feishu/wiki/sheet-tools.ts
|
|
11047
|
+
/**
|
|
11048
|
+
* Wiki spreadsheet (电子表格) tools: `feishu_wiki_sheet_read` (switch
|
|
11049
|
+
* `feishuTools.wikiRead`) and `feishu_wiki_sheet_edit` (switch
|
|
11050
|
+
* `feishuTools.wikiWrite`).
|
|
11051
|
+
*
|
|
11052
|
+
* Both run through the same gates as the docx tools (whitelist, `wikiRoots`
|
|
11053
|
+
* containment, consent cards). Reading shares the docx read grant per Session
|
|
11054
|
+
* and document. Editing writes cell values and formulas only (no styles,
|
|
11055
|
+
* charts, or row/column structure), only on spreadsheets the app owns, and
|
|
11056
|
+
* only when the workbook revision still equals the one the model read, checked
|
|
11057
|
+
* before and after the consent card; Feishu offers no revision precondition on
|
|
11058
|
+
* the write itself.
|
|
11059
|
+
*
|
|
11060
|
+
* @module dsh-channels/feishu/wiki/sheet-tools
|
|
11061
|
+
*/
|
|
11062
|
+
/** Model-facing tool names; stable across releases (session history references them). */
|
|
11063
|
+
const WIKI_SHEET_READ_TOOL_NAME = "feishu_wiki_sheet_read";
|
|
11064
|
+
const WIKI_SHEET_EDIT_TOOL_NAME = "feishu_wiki_sheet_edit";
|
|
11065
|
+
/** Most cells one `feishu_wiki_sheet_edit` call may write in total. */
|
|
11066
|
+
const WIKI_SHEET_EDIT_MAX_CELLS = 5e4;
|
|
11067
|
+
/** Read size bounds (characters of CSV returned to the model). */
|
|
11068
|
+
const READ_DEFAULT_CHARS = 2e4;
|
|
11069
|
+
const READ_MAX_CHARS = 1e5;
|
|
11070
|
+
/** CSV characters requested when only the data extent of a sheet is needed. */
|
|
11071
|
+
const EXTENT_PROBE_CHARS = 2e3;
|
|
11072
|
+
/** CSV characters per page while scanning below the first data region for later rows. */
|
|
11073
|
+
const EXTENT_SCAN_CHARS = 2e5;
|
|
11074
|
+
/** Pages the append row scan reads before giving up. */
|
|
11075
|
+
const EXTENT_SCAN_PAGES = 20;
|
|
11076
|
+
/** Scan bounds when the workbook does not report the grid size. */
|
|
11077
|
+
const MAX_SCAN_COLUMN = 18278;
|
|
11078
|
+
const MAX_SCAN_ROW = 1e7;
|
|
11079
|
+
/** Rows of each written block shown on a consent card. */
|
|
11080
|
+
const CARD_PREVIEW_ROWS = 3;
|
|
11081
|
+
const refSchema$1 = z.string().trim().min(1).max(2048);
|
|
11082
|
+
const sheetSchema = z.string().trim().min(1).max(200);
|
|
11083
|
+
const cellValueSchema = z.union([
|
|
11084
|
+
z.string().max(5e4),
|
|
11085
|
+
z.number().finite(),
|
|
11086
|
+
z.boolean(),
|
|
11087
|
+
z.null()
|
|
11088
|
+
]);
|
|
11089
|
+
const valuesSchema = z.array(z.array(cellValueSchema).min(1).max(1e3)).min(1).max(1e4);
|
|
11090
|
+
const wikiSheetReadArgsSchema = z.object({
|
|
11091
|
+
doc: refSchema$1,
|
|
11092
|
+
sheet: sheetSchema.optional(),
|
|
11093
|
+
range: z.string().trim().min(1).max(40).refine(isA1Range, "需要不带子表前缀的 A1 范围,例如 A1:F30").optional(),
|
|
11094
|
+
max_chars: z.number().int().min(1e3).max(READ_MAX_CHARS).optional()
|
|
11095
|
+
}).strict();
|
|
11096
|
+
const sheetOperationSchema = z.discriminatedUnion("command", [z.object({
|
|
11097
|
+
command: z.literal("write"),
|
|
11098
|
+
sheet: sheetSchema.optional(),
|
|
11099
|
+
start_cell: z.string().trim().min(2).max(12),
|
|
11100
|
+
values: valuesSchema
|
|
11101
|
+
}).strict(), z.object({
|
|
11102
|
+
command: z.literal("append"),
|
|
11103
|
+
sheet: sheetSchema.optional(),
|
|
11104
|
+
start_column: z.string().trim().min(1).max(3).optional(),
|
|
11105
|
+
values: valuesSchema
|
|
11106
|
+
}).strict()]);
|
|
11107
|
+
const wikiSheetEditArgsSchema = z.object({
|
|
11108
|
+
doc: refSchema$1,
|
|
11109
|
+
revision_id: z.number().int().min(0),
|
|
11110
|
+
operations: z.array(sheetOperationSchema).min(1).max(20)
|
|
11111
|
+
}).strict();
|
|
11112
|
+
const READ_DESCRIPTION$1 = `读取知识库中的飞书电子表格(sheet)。doc 传知识库节点链接或 node_token。第一次读取某个表格会在话题里弹出授权卡,由话题发起人确认,确认后本话题内再次读取不再询问;等待期间不要重复调用。返回 sheets(子表列表);sheet 传子表 sheet_id 或标题选择子表,只有一个可见子表时可省略,有多个而未指定时只返回子表列表。range 为不带子表前缀的 A1 范围(如 A1:F30、A:C),省略时读整张子表。内容为 CSV(显示值),每行前缀 [row=N] 是真实行号,引用行号时以它为准,不要自己数行。max_chars 默认 ${READ_DEFAULT_CHARS};has_more 为 true 时表示被截断,从 actual_range 末行之后接着读。返回的 revision_id 用于 feishu_wiki_sheet_edit;editable 表示能否修改(只有本应用创建的表格可以修改)。`;
|
|
11113
|
+
const EDIT_DESCRIPTION$1 = `修改知识库中由本应用创建的飞书电子表格(sheet)的单元格值。先用 feishu_wiki_sheet_read 读取,把读到的 revision_id 原样传入;表格在此之后被改过会被拒绝,需要重新读取。operations 按顺序执行(最多 20 个,合计不超过 ${WIKI_SHEET_EDIT_MAX_CELLS} 个单元格):write(从 start_cell 如 B3 开始写入二维数组 values)、append(在子表现有数据的最后一行之后追加 values,start_column 默认 A;目标单元格非空时失败,不会覆盖)。返回 formula_errors 时,这些公式没有编译成功、已按文本写入,需要修正后重写。values 每个元素是一行;单元格可以是字符串、数字、布尔或 null(null 表示保持原值不变);以 = 开头的字符串作为公式写入。数字、金额、百分比(0.4 表示 40%)写成数字;编号、前导零、身份证号写成字符串。不会修改样式、图表、行列结构。sheet 传子表 sheet_id 或标题,只有一个可见子表时可省略。每次修改都会在话题里弹出授权卡,由话题发起人确认后才执行;等待期间不要重复调用。`;
|
|
11114
|
+
const REF_DESCRIPTION$1 = "知识库节点链接 https://<租户>.feishu.cn/wiki/<node_token> 或 node_token";
|
|
11115
|
+
const SHEET_PROPERTY = {
|
|
11116
|
+
type: "string",
|
|
11117
|
+
description: "子表 sheet_id 或标题;只有一个可见子表时可省略"
|
|
11118
|
+
};
|
|
11119
|
+
const VALUES_PROPERTY = {
|
|
11120
|
+
type: "array",
|
|
11121
|
+
description: "二维数组,每个元素是一行;单元格为字符串、数字、布尔或 null(保持原值),以 = 开头的字符串是公式",
|
|
11122
|
+
items: {
|
|
11123
|
+
type: "array",
|
|
11124
|
+
items: { type: [
|
|
11125
|
+
"string",
|
|
11126
|
+
"number",
|
|
11127
|
+
"boolean",
|
|
11128
|
+
"null"
|
|
11129
|
+
] }
|
|
11130
|
+
}
|
|
11131
|
+
};
|
|
11132
|
+
const READ_PARAMETERS$1 = {
|
|
11133
|
+
type: "object",
|
|
11134
|
+
properties: {
|
|
11135
|
+
doc: {
|
|
11136
|
+
type: "string",
|
|
11137
|
+
description: REF_DESCRIPTION$1
|
|
11138
|
+
},
|
|
11139
|
+
sheet: SHEET_PROPERTY,
|
|
11140
|
+
range: {
|
|
11141
|
+
type: "string",
|
|
11142
|
+
description: "不带子表前缀的 A1 范围,如 A1:F30;省略读整张子表"
|
|
11143
|
+
},
|
|
11144
|
+
max_chars: {
|
|
11145
|
+
type: "integer",
|
|
11146
|
+
minimum: 1e3,
|
|
11147
|
+
maximum: READ_MAX_CHARS,
|
|
11148
|
+
description: `返回 CSV 的字符上限,默认 ${READ_DEFAULT_CHARS}`
|
|
11149
|
+
}
|
|
11150
|
+
},
|
|
11151
|
+
required: ["doc"],
|
|
11152
|
+
additionalProperties: false
|
|
11153
|
+
};
|
|
11154
|
+
const EDIT_PARAMETERS$1 = {
|
|
11155
|
+
type: "object",
|
|
11156
|
+
properties: {
|
|
11157
|
+
doc: {
|
|
11158
|
+
type: "string",
|
|
11159
|
+
description: REF_DESCRIPTION$1
|
|
11160
|
+
},
|
|
11161
|
+
revision_id: {
|
|
11162
|
+
type: "integer",
|
|
11163
|
+
description: "feishu_wiki_sheet_read 返回的 revision_id"
|
|
11164
|
+
},
|
|
11165
|
+
operations: {
|
|
11166
|
+
type: "array",
|
|
11167
|
+
minItems: 1,
|
|
11168
|
+
maxItems: 20,
|
|
11169
|
+
items: {
|
|
11170
|
+
type: "object",
|
|
11171
|
+
properties: {
|
|
11172
|
+
command: {
|
|
11173
|
+
type: "string",
|
|
11174
|
+
enum: ["write", "append"]
|
|
11175
|
+
},
|
|
11176
|
+
sheet: SHEET_PROPERTY,
|
|
11177
|
+
start_cell: {
|
|
11178
|
+
type: "string",
|
|
11179
|
+
description: "write 的起始单元格,如 B3"
|
|
11180
|
+
},
|
|
11181
|
+
start_column: {
|
|
11182
|
+
type: "string",
|
|
11183
|
+
description: "append 的起始列,默认 A"
|
|
11184
|
+
},
|
|
11185
|
+
values: VALUES_PROPERTY
|
|
11186
|
+
},
|
|
11187
|
+
required: ["command", "values"],
|
|
11188
|
+
additionalProperties: false
|
|
11189
|
+
}
|
|
11190
|
+
}
|
|
11191
|
+
},
|
|
11192
|
+
required: [
|
|
11193
|
+
"doc",
|
|
11194
|
+
"revision_id",
|
|
11195
|
+
"operations"
|
|
11196
|
+
],
|
|
11197
|
+
additionalProperties: false
|
|
11198
|
+
};
|
|
11199
|
+
async function sheetTarget(kit, context, doc) {
|
|
11200
|
+
const sheets = context.sheets;
|
|
11201
|
+
if (sheets === void 0) return failure("CHANNEL_UNAVAILABLE");
|
|
11202
|
+
const ref = parseWikiRef(doc);
|
|
11203
|
+
if (ref === void 0) return failure("INVALID_REFERENCE");
|
|
11204
|
+
const node = await kit.containedNode(context, ref);
|
|
11205
|
+
if ("ok" in node) return node;
|
|
11206
|
+
if (node.objType !== "sheet") return failure("UNSUPPORTED_TYPE", `${failure("UNSUPPORTED_TYPE").reason}(该节点类型:${node.objType})`);
|
|
11207
|
+
return {
|
|
11208
|
+
context,
|
|
11209
|
+
sheets,
|
|
11210
|
+
node,
|
|
11211
|
+
url: nodeUrl(context.roots, node)
|
|
11212
|
+
};
|
|
11213
|
+
}
|
|
11214
|
+
function sheetView(sheet) {
|
|
11215
|
+
return {
|
|
11216
|
+
sheet_id: sheet.sheetId,
|
|
11217
|
+
title: sheet.title,
|
|
11218
|
+
index: sheet.index,
|
|
11219
|
+
resource_type: sheet.resourceType,
|
|
11220
|
+
hidden: sheet.hidden,
|
|
11221
|
+
...sheet.rowCount === void 0 ? {} : { row_count: sheet.rowCount },
|
|
11222
|
+
...sheet.columnCount === void 0 ? {} : { column_count: sheet.columnCount }
|
|
11223
|
+
};
|
|
11224
|
+
}
|
|
11225
|
+
/** The named grid sheet, the only visible grid sheet, or why neither applies. */
|
|
11226
|
+
function pickSheet(workbook, wanted) {
|
|
11227
|
+
const grids = workbook.sheets.filter((sheet) => sheet.resourceType === "sheet");
|
|
11228
|
+
if (wanted !== void 0) {
|
|
11229
|
+
const match = workbook.sheets.find((sheet) => sheet.sheetId === wanted) ?? workbook.sheets.find((sheet) => sheet.title === wanted);
|
|
11230
|
+
if (match === void 0) return `找不到子表「${wanted}」;可用子表:${sheetList(workbook)}`;
|
|
11231
|
+
if (match.resourceType !== "sheet") return `子表「${match.title}」是 ${match.resourceType},不是单元格表格,不能按单元格读写`;
|
|
11232
|
+
return match;
|
|
11233
|
+
}
|
|
11234
|
+
const visible = grids.filter((sheet) => !sheet.hidden);
|
|
11235
|
+
if (visible.length === 1 && visible[0] !== void 0) return visible[0];
|
|
11236
|
+
return `表格有多个子表,请用 sheet 指定:${sheetList(workbook)}`;
|
|
11237
|
+
}
|
|
11238
|
+
function sheetList(workbook) {
|
|
11239
|
+
return workbook.sheets.map((sheet) => `${sheet.title}(${sheet.sheetId})`).join("、") || "无";
|
|
11240
|
+
}
|
|
11241
|
+
function wholeSheetRange(sheet) {
|
|
11242
|
+
return sheet.rowCount !== void 0 && sheet.columnCount !== void 0 && sheet.rowCount > 0 && sheet.columnCount > 0 ? `A1:${columnLetters(sheet.columnCount)}${sheet.rowCount}` : "A:ZZZ";
|
|
11243
|
+
}
|
|
11244
|
+
async function read(kit, sessionId, rawArgs, signal) {
|
|
11245
|
+
const args = parseArgs(WIKI_SHEET_READ_TOOL_NAME, wikiSheetReadArgsSchema, rawArgs);
|
|
11246
|
+
const gate = kit.gate(sessionId, WIKI_SHEET_READ_TOOL_NAME);
|
|
11247
|
+
if (!gate.ok) return gate;
|
|
11248
|
+
return await guarded$1(kit, WIKI_SHEET_READ_TOOL_NAME, async () => {
|
|
11249
|
+
const target = await sheetTarget(kit, gate.context, args.doc);
|
|
11250
|
+
if ("ok" in target) return target;
|
|
11251
|
+
const denied = await kit.readConsent(sessionId, target.context, target.node, signal);
|
|
11252
|
+
if (denied !== void 0) return denied;
|
|
11253
|
+
if (signal.aborted) return failure("ABORTED");
|
|
11254
|
+
const workbook = await target.sheets.workbook(target.node.objToken);
|
|
11255
|
+
const base = {
|
|
11256
|
+
ok: true,
|
|
11257
|
+
title: target.node.title,
|
|
11258
|
+
node_token: target.node.nodeToken,
|
|
11259
|
+
url: target.url,
|
|
11260
|
+
...workbook.revision === void 0 ? {} : { revision_id: workbook.revision },
|
|
11261
|
+
sheets: workbook.sheets.map(sheetView),
|
|
11262
|
+
editable: kit.writeEnabled() && ownedByApp(target.node, target.context.botOpenId)
|
|
11263
|
+
};
|
|
11264
|
+
const sheet = pickSheet(workbook, args.sheet);
|
|
11265
|
+
if (typeof sheet === "string") {
|
|
11266
|
+
if (args.sheet !== void 0) return failure("INVALID_OPERATION", sheet);
|
|
11267
|
+
return {
|
|
11268
|
+
...base,
|
|
11269
|
+
warning: sheet,
|
|
11270
|
+
content: ""
|
|
11271
|
+
};
|
|
11272
|
+
}
|
|
11273
|
+
const csv = await target.sheets.readCsv(target.node.objToken, {
|
|
11274
|
+
sheetId: sheet.sheetId,
|
|
11275
|
+
range: args.range ?? wholeSheetRange(sheet),
|
|
11276
|
+
maxChars: args.max_chars ?? READ_DEFAULT_CHARS
|
|
11277
|
+
});
|
|
11278
|
+
return {
|
|
11279
|
+
...base,
|
|
11280
|
+
sheet_id: sheet.sheetId,
|
|
11281
|
+
...csv.actualRange === void 0 ? {} : { actual_range: csv.actualRange },
|
|
11282
|
+
...csv.currentRegion === void 0 ? {} : { current_region: csv.currentRegion },
|
|
11283
|
+
has_more: csv.hasMore,
|
|
11284
|
+
...csv.warning === void 0 ? {} : { warning: csv.warning },
|
|
11285
|
+
content: csv.csv
|
|
11286
|
+
};
|
|
11287
|
+
});
|
|
11288
|
+
}
|
|
11289
|
+
/** Rows padded with `null` (unchanged) to one rectangle. */
|
|
11290
|
+
function rectangular(values) {
|
|
11291
|
+
const columns = Math.max(...values.map((row) => row.length));
|
|
11292
|
+
return {
|
|
11293
|
+
rows: values.map((row) => [...row, ...Array(columns - row.length).fill(null)]),
|
|
11294
|
+
columns
|
|
11295
|
+
};
|
|
11296
|
+
}
|
|
11297
|
+
function plan(workbook, operations) {
|
|
11298
|
+
const planned = [];
|
|
11299
|
+
let cells = 0;
|
|
11300
|
+
for (const [index, operation] of operations.entries()) {
|
|
11301
|
+
const where = `第 ${index + 1} 个操作(${operation.command})`;
|
|
11302
|
+
const sheet = pickSheet(workbook, operation.sheet);
|
|
11303
|
+
if (typeof sheet === "string") return failure("INVALID_OPERATION", `${where}:${sheet}`);
|
|
11304
|
+
let start;
|
|
11305
|
+
if (operation.command === "write") {
|
|
11306
|
+
start = parseCell(operation.start_cell);
|
|
11307
|
+
if (start === void 0) return failure("INVALID_OPERATION", `${where}:start_cell 需要单个单元格,例如 B3`);
|
|
11308
|
+
} else {
|
|
11309
|
+
const column = parseCell(`${operation.start_column ?? "A"}1`);
|
|
11310
|
+
if (column === void 0) return failure("INVALID_OPERATION", `${where}:start_column 需要列字母,例如 A`);
|
|
11311
|
+
start = column;
|
|
11312
|
+
}
|
|
11313
|
+
const { rows, columns } = rectangular(operation.values);
|
|
11314
|
+
cells += rows.length * columns;
|
|
11315
|
+
if (cells > 5e4) return failure("INVALID_OPERATION", `一次最多写入 ${WIKI_SHEET_EDIT_MAX_CELLS} 个单元格,请拆成多次调用`);
|
|
11316
|
+
planned.push({
|
|
11317
|
+
operation,
|
|
11318
|
+
sheet,
|
|
11319
|
+
start,
|
|
11320
|
+
rows,
|
|
11321
|
+
columns
|
|
11322
|
+
});
|
|
11323
|
+
}
|
|
11324
|
+
return planned;
|
|
11325
|
+
}
|
|
11326
|
+
function cardLines(planned) {
|
|
11327
|
+
const lines = planned.map((write, index) => {
|
|
11328
|
+
const size = `${write.rows.length} 行 × ${write.columns} 列`;
|
|
11329
|
+
const where = write.operation.command === "write" ? `写入子表「${preview(write.sheet.title, 40)}」的 ${rectangle(write.start, write.rows.length, write.columns)}(${size})` : `在子表「${preview(write.sheet.title, 40)}」末尾追加 ${size}(从 ${columnLetters(write.start.column)} 列开始)`;
|
|
11330
|
+
const sample = write.rows.slice(0, CARD_PREVIEW_ROWS).map((row) => ` ${preview(row.map((cell) => cell === null ? "" : String(cell)).join(" | "), 80)}`);
|
|
11331
|
+
const more = write.rows.length > CARD_PREVIEW_ROWS ? [` …(共 ${write.rows.length} 行)`] : [];
|
|
11332
|
+
return [
|
|
11333
|
+
`${index + 1}. ${where}`,
|
|
11334
|
+
...sample,
|
|
11335
|
+
...more
|
|
11336
|
+
].join("\n");
|
|
11337
|
+
});
|
|
11338
|
+
return [`**将执行 ${planned.length} 个操作**`, ...lines];
|
|
11339
|
+
}
|
|
11340
|
+
/** First empty row after the sheet's data (1 for an empty sheet), or undefined when it cannot be determined. */
|
|
11341
|
+
async function appendRow(sheets, token, sheet) {
|
|
11342
|
+
const probe = await sheets.readCsv(token, {
|
|
11343
|
+
sheetId: sheet.sheetId,
|
|
11344
|
+
range: wholeSheetRange(sheet),
|
|
11345
|
+
maxChars: EXTENT_PROBE_CHARS
|
|
11346
|
+
});
|
|
11347
|
+
const region = probe.currentRegion ?? (probe.hasMore ? void 0 : probe.actualRange);
|
|
11348
|
+
let last = region === void 0 ? probe.csv.trim() === "" && !probe.hasMore ? 0 : void 0 : rangeEndRow(region);
|
|
11349
|
+
if (last === void 0) return void 0;
|
|
11350
|
+
const lastColumn = columnLetters(sheet.columnCount !== void 0 && sheet.columnCount > 0 ? sheet.columnCount : MAX_SCAN_COLUMN);
|
|
11351
|
+
const lastRow = sheet.rowCount !== void 0 && sheet.rowCount > 0 ? sheet.rowCount : MAX_SCAN_ROW;
|
|
11352
|
+
let from = last + 1;
|
|
11353
|
+
for (let page = 0; from <= lastRow; page++) {
|
|
11354
|
+
if (page === EXTENT_SCAN_PAGES) return void 0;
|
|
11355
|
+
const scan = await sheets.readCsv(token, {
|
|
11356
|
+
sheetId: sheet.sheetId,
|
|
11357
|
+
range: `A${from}:${lastColumn}${lastRow}`,
|
|
11358
|
+
maxChars: EXTENT_SCAN_CHARS
|
|
11359
|
+
});
|
|
11360
|
+
last = Math.max(last, lastFilledRow(scan.csv));
|
|
11361
|
+
if (!scan.hasMore) break;
|
|
11362
|
+
const end = scan.actualRange === void 0 ? void 0 : rangeEndRow(scan.actualRange);
|
|
11363
|
+
if (end === void 0 || end < from) return void 0;
|
|
11364
|
+
from = end + 1;
|
|
11365
|
+
}
|
|
11366
|
+
return last + 1;
|
|
11367
|
+
}
|
|
11368
|
+
/** Largest `[row=N]` whose record has a non-empty cell, or 0. */
|
|
11369
|
+
function lastFilledRow(csv) {
|
|
11370
|
+
let last = 0;
|
|
11371
|
+
for (const match of csv.matchAll(/^\[row=(\d+)\] ?(.*)$/gmu)) if (/[^,\s"]/u.test(match[2] ?? "")) last = Math.max(last, Number(match[1]));
|
|
11372
|
+
return last;
|
|
11373
|
+
}
|
|
11374
|
+
async function edit(kit, sessionId, rawArgs, signal) {
|
|
11375
|
+
const args = parseArgs(WIKI_SHEET_EDIT_TOOL_NAME, wikiSheetEditArgsSchema, rawArgs);
|
|
11376
|
+
const gate = kit.gate(sessionId, WIKI_SHEET_EDIT_TOOL_NAME);
|
|
11377
|
+
if (!gate.ok) return gate;
|
|
11378
|
+
return await guarded$1(kit, WIKI_SHEET_EDIT_TOOL_NAME, async () => {
|
|
11379
|
+
const target = await sheetTarget(kit, gate.context, args.doc);
|
|
11380
|
+
if ("ok" in target) return target;
|
|
11381
|
+
const { node, sheets } = target;
|
|
11382
|
+
if (!ownedByApp(node, target.context.botOpenId)) return failure("NOT_OWNED", `${failure("NOT_OWNED").reason}(该表格所有者:${node.owner ?? "未知"})`);
|
|
11383
|
+
const workbook = await sheets.workbook(node.objToken);
|
|
11384
|
+
if (workbook.revision !== void 0 && workbook.revision !== args.revision_id) return {
|
|
11385
|
+
...failure("REVISION_CONFLICT", `${failure("REVISION_CONFLICT").reason}(读取时 ${args.revision_id},当前 ${workbook.revision})`),
|
|
11386
|
+
revision_id: workbook.revision
|
|
11387
|
+
};
|
|
11388
|
+
const planned = plan(workbook, args.operations);
|
|
11389
|
+
if (!Array.isArray(planned)) return planned;
|
|
11390
|
+
const denied = await kit.consent(sessionId, target.context, "edit", {
|
|
11391
|
+
title: node.title,
|
|
11392
|
+
url: target.url,
|
|
11393
|
+
location: await kit.locationOf(target.context.api, node),
|
|
11394
|
+
details: cardLines(planned)
|
|
11395
|
+
}, signal);
|
|
11396
|
+
if (denied !== void 0) return denied;
|
|
11397
|
+
const after = await sheets.workbook(node.objToken);
|
|
11398
|
+
if (after.revision !== void 0 && after.revision !== args.revision_id) return {
|
|
11399
|
+
...failure("REVISION_CONFLICT", `${failure("REVISION_CONFLICT").reason}(等待授权期间表格被修改)`),
|
|
11400
|
+
revision_id: after.revision
|
|
11401
|
+
};
|
|
11402
|
+
const written = [];
|
|
11403
|
+
const formulaErrors = [];
|
|
11404
|
+
for (const [index, write] of planned.entries()) {
|
|
11405
|
+
if (signal.aborted) return {
|
|
11406
|
+
...failure("ABORTED", `已取消;前 ${index} 个操作已执行`),
|
|
11407
|
+
applied: index
|
|
11408
|
+
};
|
|
11409
|
+
try {
|
|
11410
|
+
let start = write.start;
|
|
11411
|
+
if (write.operation.command === "append") {
|
|
11412
|
+
const row = await appendRow(sheets, node.objToken, write.sheet);
|
|
11413
|
+
if (row === void 0) return {
|
|
11414
|
+
...failure("EDIT_FAILED", `第 ${index + 1} 个操作失败:无法确定子表「${write.sheet.title}」的数据末行,请改用 write 指定 start_cell;前 ${index} 个操作已执行`),
|
|
11415
|
+
applied: index
|
|
11416
|
+
};
|
|
11417
|
+
start = {
|
|
11418
|
+
column: write.start.column,
|
|
11419
|
+
row
|
|
11420
|
+
};
|
|
11421
|
+
}
|
|
11422
|
+
const range = rectangle(start, write.rows.length, write.columns);
|
|
11423
|
+
const result = await sheets.writeCells(node.objToken, {
|
|
11424
|
+
sheetId: write.sheet.sheetId,
|
|
11425
|
+
range,
|
|
11426
|
+
rows: write.rows,
|
|
11427
|
+
...write.operation.command === "append" ? { allowOverwrite: false } : {}
|
|
11428
|
+
});
|
|
11429
|
+
written.push({
|
|
11430
|
+
sheet_id: write.sheet.sheetId,
|
|
11431
|
+
range
|
|
11432
|
+
});
|
|
11433
|
+
for (const error of result.formulaErrors) formulaErrors.push({
|
|
11434
|
+
sheet_id: write.sheet.sheetId,
|
|
11435
|
+
cell: error.cell,
|
|
11436
|
+
...error.formula === void 0 ? {} : { formula: error.formula },
|
|
11437
|
+
...error.errorType === void 0 ? {} : { error_type: error.errorType },
|
|
11438
|
+
...error.detail === void 0 ? {} : { detail: error.detail }
|
|
11439
|
+
});
|
|
11440
|
+
} catch (error) {
|
|
11441
|
+
const reason = failureOf(error).reason;
|
|
11442
|
+
return {
|
|
11443
|
+
...failure("EDIT_FAILED", `第 ${index + 1} 个操作失败:${reason};前 ${index} 个操作已执行`),
|
|
11444
|
+
applied: index
|
|
11445
|
+
};
|
|
11446
|
+
}
|
|
11447
|
+
}
|
|
11448
|
+
const revision = await sheets.workbook(node.objToken).then((final) => final.revision, (error) => {
|
|
11449
|
+
kit.warn("dsh-channels: reading the revision after %s failed: %s", WIKI_SHEET_EDIT_TOOL_NAME, describeError(error));
|
|
11450
|
+
});
|
|
11451
|
+
return {
|
|
11452
|
+
ok: true,
|
|
11453
|
+
title: node.title,
|
|
11454
|
+
node_token: node.nodeToken,
|
|
11455
|
+
url: target.url,
|
|
11456
|
+
applied: planned.length,
|
|
11457
|
+
written,
|
|
11458
|
+
...revision === void 0 ? {} : { revision_id: revision },
|
|
11459
|
+
...formulaErrors.length === 0 ? {} : { formula_errors: formulaErrors }
|
|
11460
|
+
};
|
|
11461
|
+
});
|
|
11462
|
+
}
|
|
11463
|
+
/** The spreadsheet tool family. */
|
|
11464
|
+
const SHEET_TOOL_SPECS = [{
|
|
11465
|
+
name: WIKI_SHEET_READ_TOOL_NAME,
|
|
11466
|
+
access: "read",
|
|
11467
|
+
description: READ_DESCRIPTION$1,
|
|
11468
|
+
parameters: READ_PARAMETERS$1,
|
|
11469
|
+
execute: read,
|
|
11470
|
+
presentCall: (args) => `读取知识库表格:${display(args.doc)}`
|
|
11471
|
+
}, {
|
|
11472
|
+
name: WIKI_SHEET_EDIT_TOOL_NAME,
|
|
11473
|
+
access: "write",
|
|
11474
|
+
description: EDIT_DESCRIPTION$1,
|
|
11475
|
+
parameters: EDIT_PARAMETERS$1,
|
|
11476
|
+
execute: edit,
|
|
11477
|
+
presentCall: (args) => `修改知识库表格:${display(args.doc)}`
|
|
11478
|
+
}];
|
|
11479
|
+
//#endregion
|
|
11480
|
+
//#region src/feishu/wiki/tools.ts
|
|
11481
|
+
/**
|
|
11482
|
+
* Model-facing wiki (知识库) tools: `feishu_wiki_list`, `feishu_wiki_read`
|
|
11483
|
+
* (switch `feishuTools.wikiRead`), `feishu_wiki_create`, and
|
|
11484
|
+
* `feishu_wiki_edit` (switch `feishuTools.wikiWrite`).
|
|
11485
|
+
*
|
|
11486
|
+
* Every call acts with the app identity and is gated, in order, by:
|
|
11487
|
+
* - the switch and a non-empty `wikiRoots` (otherwise nothing is mounted);
|
|
11488
|
+
* - the topic owner being the administrator or on the wiki whitelist
|
|
11489
|
+
* (`/auth wiki add`), checked per call so revocation applies at once;
|
|
11490
|
+
* - `wikiRoots` containment of the node (see `scope.ts`);
|
|
11491
|
+
* - docx only for reading and editing;
|
|
11492
|
+
* - edits only on documents the app owns (wiki node `owner` equals the bot
|
|
11493
|
+
* open_id) whose current revision equals the `revision_id` the model read;
|
|
11494
|
+
* - the topic owner's consent card for opening a document (remembered per
|
|
11495
|
+
* Session and document), for every create, and for every edit.
|
|
11496
|
+
*
|
|
11497
|
+
* Browsing (`feishu_wiki_list`) needs no card. Every failure is a structured
|
|
11498
|
+
* value with a stable code and a readable reason.
|
|
11499
|
+
*
|
|
11500
|
+
* @module dsh-channels/feishu/wiki/tools
|
|
11501
|
+
*/
|
|
11502
|
+
/** Model-facing tool names; stable across releases (session history references them). */
|
|
11503
|
+
const WIKI_LIST_TOOL_NAME = "feishu_wiki_list";
|
|
11504
|
+
const WIKI_READ_TOOL_NAME = "feishu_wiki_read";
|
|
11505
|
+
const WIKI_CREATE_TOOL_NAME = "feishu_wiki_create";
|
|
11506
|
+
const WIKI_EDIT_TOOL_NAME = "feishu_wiki_edit";
|
|
11507
|
+
/** Longest accepted document title (characters). */
|
|
11508
|
+
const MAX_TITLE_CHARS = 200;
|
|
11509
|
+
/** Longest content one create/edit operation may carry (characters). */
|
|
11510
|
+
const MAX_CONTENT_CHARS = 2e5;
|
|
11511
|
+
const refSchema = z.string().trim().min(1).max(2048);
|
|
11512
|
+
const blockIdSchema = z.string().trim().min(1).max(128);
|
|
11513
|
+
const contentSchema = z.string().max(MAX_CONTENT_CHARS);
|
|
11514
|
+
const formatSchema = z.enum(["markdown", "xml"]);
|
|
11515
|
+
const wikiListArgsSchema = z.object({
|
|
11516
|
+
parent: refSchema.optional(),
|
|
11517
|
+
page_token: z.string().trim().min(1).max(512).optional()
|
|
11518
|
+
}).strict();
|
|
11519
|
+
const wikiReadArgsSchema = z.object({
|
|
11520
|
+
doc: refSchema,
|
|
11521
|
+
format: formatSchema.optional(),
|
|
11522
|
+
scope: z.enum([
|
|
11523
|
+
"full",
|
|
11524
|
+
"outline",
|
|
11525
|
+
"section",
|
|
11526
|
+
"range",
|
|
11527
|
+
"keyword"
|
|
11528
|
+
]).optional(),
|
|
11529
|
+
start_block_id: blockIdSchema.optional(),
|
|
11530
|
+
end_block_id: blockIdSchema.optional(),
|
|
11531
|
+
keyword: z.string().trim().min(1).max(500).optional(),
|
|
11532
|
+
max_depth: z.number().int().min(-1).max(20).optional()
|
|
11533
|
+
}).strict();
|
|
11534
|
+
const wikiCreateArgsSchema = z.object({
|
|
11535
|
+
parent: refSchema,
|
|
11536
|
+
title: z.string().trim().min(1).max(MAX_TITLE_CHARS),
|
|
11537
|
+
type: z.enum([
|
|
11538
|
+
"docx",
|
|
11539
|
+
"sheet",
|
|
11540
|
+
"bitable"
|
|
11541
|
+
]).optional(),
|
|
11542
|
+
content: contentSchema.optional(),
|
|
11543
|
+
format: formatSchema.optional()
|
|
11544
|
+
}).strict();
|
|
11545
|
+
const rangeFields = {
|
|
11546
|
+
block_id: blockIdSchema.optional(),
|
|
11547
|
+
start_block_id: blockIdSchema.optional(),
|
|
11548
|
+
end_block_id: blockIdSchema.optional()
|
|
11549
|
+
};
|
|
11550
|
+
const wikiEditOperationSchema = z.discriminatedUnion("command", [
|
|
11551
|
+
z.object({
|
|
11552
|
+
command: z.literal("append"),
|
|
11553
|
+
content: contentSchema.min(1)
|
|
11554
|
+
}).strict(),
|
|
11555
|
+
z.object({
|
|
11556
|
+
command: z.literal("insert_after"),
|
|
11557
|
+
block_id: blockIdSchema,
|
|
11558
|
+
content: contentSchema.min(1)
|
|
11559
|
+
}).strict(),
|
|
11560
|
+
z.object({
|
|
11561
|
+
command: z.literal("replace"),
|
|
11562
|
+
...rangeFields,
|
|
11563
|
+
content: contentSchema.min(1)
|
|
11564
|
+
}).strict(),
|
|
11565
|
+
z.object({
|
|
11566
|
+
command: z.literal("delete"),
|
|
11567
|
+
...rangeFields
|
|
11568
|
+
}).strict(),
|
|
11569
|
+
z.object({
|
|
11570
|
+
command: z.literal("str_replace"),
|
|
11571
|
+
pattern: z.string().min(1).max(1e4),
|
|
11572
|
+
content: contentSchema
|
|
11573
|
+
}).strict(),
|
|
11574
|
+
z.object({
|
|
11575
|
+
command: z.literal("overwrite"),
|
|
11576
|
+
content: contentSchema.min(1)
|
|
11577
|
+
}).strict()
|
|
11578
|
+
]);
|
|
11579
|
+
const wikiEditArgsSchema = z.object({
|
|
11580
|
+
doc: refSchema,
|
|
11581
|
+
revision_id: z.number().int().min(0),
|
|
11582
|
+
format: formatSchema.optional(),
|
|
11583
|
+
operations: z.array(wikiEditOperationSchema).min(1).max(20)
|
|
11584
|
+
}).strict();
|
|
11585
|
+
const OUTPUT_SCHEMA = {
|
|
11586
|
+
type: "object",
|
|
11587
|
+
properties: {
|
|
11588
|
+
ok: { type: "boolean" },
|
|
11589
|
+
code: { type: "string" },
|
|
11590
|
+
reason: { type: "string" }
|
|
11591
|
+
},
|
|
11592
|
+
required: ["ok"]
|
|
11593
|
+
};
|
|
11594
|
+
const LIST_DESCRIPTION = "浏览飞书知识库(只能访问插件配置的知识库范围 wikiRoots)。不传 parent 时列出可访问的根(知识空间或节点);传 parent(知识库节点链接、node_token,或知识空间链接)时列出它的直接子节点,has_child 为 true 的节点可以继续往下浏览。结果分页,带 page_token 时用它取下一页。返回的 node_token 可直接传给其他知识库工具。";
|
|
11595
|
+
const READ_DESCRIPTION = "读取知识库中的一篇飞书文档(docx;电子表格用 feishu_wiki_sheet_read,多维表格用 feishu_wiki_base_read)。doc 传知识库节点链接或 node_token。第一次读取某篇文档会在话题里弹出授权卡,由话题发起人确认,确认后本话题内再次读取同一篇不再询问;等待期间不要重复调用。scope 默认 full(整篇);大文档先用 outline 看目录,再用 section(start_block_id 为标题块 id)、range(start/end_block_id)或 keyword(关键词,支持 a|b)读取局部;max_depth 在 outline 中表示标题层级上限。format 默认 markdown;xml 为飞书文档 XML(类 HTML 标签:<p> <h1>-<h6> <ul>/<ol><li> <pre><code> <table> <callout> 等),只有 xml 读取会给每个块带上 id 属性(block id),需要按块修改时用 xml 读取。返回的 revision_id 用于 feishu_wiki_edit;editable 表示这篇文档能否被修改(只有本应用创建的文档可以修改)。";
|
|
11596
|
+
const CREATE_DESCRIPTION = "在知识库中新建一篇飞书文档(docx,默认)、电子表格(type=sheet)或多维表格(type=bitable)。parent 传父节点(知识库节点链接或 node_token)或知识空间链接(建在空间顶层),必须位于插件配置的知识库范围内;新节点本身也可以作为目录,之后把它的 node_token 作为 parent 在下面继续新建子节点。title 为标题;content 为文档正文(仅 docx,可选,不要在正文里重复标题),电子表格和多维表格建好后是空白的,再用 feishu_wiki_sheet_edit / feishu_wiki_base_edit 写入。format 默认 markdown;xml 为飞书文档 XML(类 HTML 标签:<p> <h1>-<h6> <ul>/<ol><li> <pre><code> <table> <callout> 等),只有 xml 读取会给每个块带上 id 属性(block id),需要按块修改时用 xml 读取。每次新建都会在话题里弹出授权卡,由话题发起人确认后才执行;等待期间不要重复调用。";
|
|
11597
|
+
const EDIT_DESCRIPTION = `修改知识库中由本应用创建的飞书文档(docx)。先用 feishu_wiki_read 读取(按块修改时用 format=xml 拿到 block id),把读到的 revision_id 原样传入;文档在此之后被他人改过会被拒绝,需要重新读取。operations 按顺序执行(最多 20 个):append(文末追加 content)、insert_after(在 block_id 之后插入 content)、replace(用 content 替换 block_id,或同一父块下 start_block_id 到 end_block_id 的连续块)、delete(删除 block_id,或 start_block_id 到 end_block_id)、str_replace(把文本 pattern 替换为 content,content 为空表示删除)、overwrite(清空后用 content 重写整篇,会丢失评论与不支持的内容,尽量少用)。替换和删除会使涉及的旧 block id 失效,同一个块的多处修改请合并成一次 replace。format 默认 markdown;xml 为飞书文档 XML(类 HTML 标签:<p> <h1>-<h6> <ul>/<ol><li> <pre><code> <table> <callout> 等),只有 xml 读取会给每个块带上 id 属性(block id),需要按块修改时用 xml 读取。每次修改都会在话题里弹出授权卡,由话题发起人确认后才执行;等待期间不要重复调用。`;
|
|
11598
|
+
const CREATE_TYPE_LABELS = {
|
|
11599
|
+
docx: "飞书文档",
|
|
11600
|
+
sheet: "电子表格",
|
|
11601
|
+
bitable: "多维表格"
|
|
11602
|
+
};
|
|
11603
|
+
/** The spreadsheet and Base tool families, mounted with the docx tools under the same switches. */
|
|
11604
|
+
const FAMILY_SPECS = [...SHEET_TOOL_SPECS, ...BASE_TOOL_SPECS];
|
|
11605
|
+
/**
|
|
11606
|
+
* The wiki tool runtime: mounts the enabled tools into Agent scopes and runs
|
|
11607
|
+
* each call through the gates in the module contract.
|
|
11608
|
+
*/
|
|
11609
|
+
var WikiToolRuntime = class {
|
|
11610
|
+
deps;
|
|
11611
|
+
mountAttempts = /* @__PURE__ */ new WeakMap();
|
|
11612
|
+
spaceNames = /* @__PURE__ */ new Map();
|
|
11613
|
+
warnedUnavailable = false;
|
|
11614
|
+
warnedMountFailure = false;
|
|
11615
|
+
constructor(deps) {
|
|
11616
|
+
this.deps = deps;
|
|
11617
|
+
}
|
|
11618
|
+
/** Tool names the current configuration mounts. */
|
|
11619
|
+
enabledTools() {
|
|
11620
|
+
const config = this.deps.config();
|
|
11621
|
+
if (config.wikiRoots.length === 0) return [];
|
|
11622
|
+
const family = (access) => FAMILY_SPECS.filter((spec) => spec.access === access).map((spec) => spec.name);
|
|
11623
|
+
return [...config.feishuTools.wikiRead ? [
|
|
11624
|
+
WIKI_LIST_TOOL_NAME,
|
|
11625
|
+
WIKI_READ_TOOL_NAME,
|
|
11626
|
+
...family("read")
|
|
11627
|
+
] : [], ...config.feishuTools.wikiWrite ? [
|
|
11628
|
+
WIKI_CREATE_TOOL_NAME,
|
|
11629
|
+
WIKI_EDIT_TOOL_NAME,
|
|
11630
|
+
...family("write")
|
|
11631
|
+
] : []];
|
|
11632
|
+
}
|
|
11633
|
+
/**
|
|
11634
|
+
* Register the enabled tools inside one Agent scope; same mount policy as
|
|
11635
|
+
* the other channel tools (a missing or slow `tools` service never blocks
|
|
11636
|
+
* session creation).
|
|
11637
|
+
*
|
|
11638
|
+
* @param agentCtx Agent scope to register in.
|
|
11639
|
+
* @param sessionId Session the tools act for.
|
|
11640
|
+
*/
|
|
11641
|
+
async mount(agentCtx, sessionId) {
|
|
11642
|
+
if (this.enabledTools().length === 0) return;
|
|
11643
|
+
const existing = this.mountAttempts.get(agentCtx);
|
|
11644
|
+
if (existing !== void 0) return await existing;
|
|
11645
|
+
const attempt = this.mountAttempt(agentCtx, sessionId);
|
|
11646
|
+
this.mountAttempts.set(agentCtx, attempt);
|
|
11647
|
+
await attempt;
|
|
11648
|
+
}
|
|
11649
|
+
async mountAttempt(agentCtx, sessionId) {
|
|
11650
|
+
let injected = false;
|
|
11651
|
+
if (await waitForAvailability(Promise.resolve(agentCtx.inject(["tools"], (ctx) => {
|
|
11652
|
+
injected = true;
|
|
11653
|
+
this.registerInScope(ctx, sessionId);
|
|
11654
|
+
})), this.warnedUnavailable ? 250 : 2e3) === "failed") {
|
|
11655
|
+
this.warnMountFailure("dsh-channels: mounting the wiki tools failed");
|
|
11656
|
+
return;
|
|
11657
|
+
}
|
|
11658
|
+
if (!injected && !this.warnedUnavailable) {
|
|
11659
|
+
this.warnedUnavailable = true;
|
|
11660
|
+
this.deps.warn("dsh-channels: 知识库工具未挂载:宿主 tools 服务不可用(会话继续)");
|
|
11661
|
+
}
|
|
11662
|
+
}
|
|
11663
|
+
registerInScope(agentCtx, sessionId) {
|
|
11664
|
+
const tools = agentCtx.tools;
|
|
11665
|
+
if (tools === void 0 || typeof tools.register !== "function") {
|
|
11666
|
+
this.warnMountFailure("dsh-channels: 知识库工具未注册:宿主 tools 服务不可用");
|
|
11667
|
+
return;
|
|
11668
|
+
}
|
|
11669
|
+
for (const name of this.enabledTools()) try {
|
|
11670
|
+
tools.register(this.definitionFor(name, sessionId));
|
|
11671
|
+
} catch (error) {
|
|
11672
|
+
this.warnMountFailure(`dsh-channels: registering ${name} failed: ${describeError(error)}`);
|
|
11673
|
+
}
|
|
11674
|
+
}
|
|
11675
|
+
warnMountFailure(message) {
|
|
11676
|
+
if (this.warnedMountFailure) return;
|
|
11677
|
+
this.warnedMountFailure = true;
|
|
11678
|
+
this.deps.warn(message);
|
|
11679
|
+
}
|
|
11680
|
+
definitionFor(name, sessionId) {
|
|
11681
|
+
const base = {
|
|
11682
|
+
output: {
|
|
11683
|
+
schema: OUTPUT_SCHEMA,
|
|
11684
|
+
render: (_args, value) => renderWikiResult(value)
|
|
11685
|
+
},
|
|
11686
|
+
presentResult: (_args, value) => presentWikiResult(value)
|
|
11687
|
+
};
|
|
11688
|
+
const spec = FAMILY_SPECS.find((candidate) => candidate.name === name);
|
|
11689
|
+
if (spec !== void 0) return {
|
|
11690
|
+
...base,
|
|
11691
|
+
name,
|
|
11692
|
+
description: spec.description,
|
|
11693
|
+
parameters: spec.parameters,
|
|
11694
|
+
execute: async (rawArgs, exec) => await spec.execute(this, sessionId, rawArgs, exec.signal),
|
|
11695
|
+
presentCall: (args) => spec.presentCall(args),
|
|
11696
|
+
isConcurrencySafe: () => false
|
|
11697
|
+
};
|
|
11698
|
+
switch (name) {
|
|
11699
|
+
case WIKI_LIST_TOOL_NAME: return {
|
|
11700
|
+
...base,
|
|
11701
|
+
name,
|
|
11702
|
+
description: LIST_DESCRIPTION,
|
|
11703
|
+
parameters: LIST_PARAMETERS,
|
|
11704
|
+
execute: async (rawArgs) => await this.list(sessionId, rawArgs),
|
|
11705
|
+
presentCall: () => "浏览知识库",
|
|
11706
|
+
isConcurrencySafe: () => true
|
|
11707
|
+
};
|
|
11708
|
+
case WIKI_READ_TOOL_NAME: return {
|
|
11709
|
+
...base,
|
|
11710
|
+
name,
|
|
11711
|
+
description: READ_DESCRIPTION,
|
|
11712
|
+
parameters: READ_PARAMETERS,
|
|
11713
|
+
execute: async (rawArgs, exec) => await this.read(sessionId, rawArgs, exec),
|
|
11714
|
+
presentCall: (args) => `读取知识库文档:${display(args.doc)}`,
|
|
11715
|
+
isConcurrencySafe: () => false
|
|
11716
|
+
};
|
|
11717
|
+
case WIKI_CREATE_TOOL_NAME: return {
|
|
11718
|
+
...base,
|
|
11719
|
+
name,
|
|
11720
|
+
description: CREATE_DESCRIPTION,
|
|
11721
|
+
parameters: CREATE_PARAMETERS,
|
|
11722
|
+
execute: async (rawArgs, exec) => await this.create(sessionId, rawArgs, exec),
|
|
11723
|
+
presentCall: (args) => `新建知识库文档:${display(args.title)}`,
|
|
11724
|
+
isConcurrencySafe: () => false
|
|
11725
|
+
};
|
|
11726
|
+
default: return {
|
|
11727
|
+
...base,
|
|
11728
|
+
name,
|
|
11729
|
+
description: EDIT_DESCRIPTION,
|
|
11730
|
+
parameters: EDIT_PARAMETERS,
|
|
11731
|
+
execute: async (rawArgs, exec) => await this.edit(sessionId, rawArgs, exec),
|
|
11732
|
+
presentCall: (args) => `修改知识库文档:${display(args.doc)}`,
|
|
11733
|
+
isConcurrencySafe: () => false
|
|
11734
|
+
};
|
|
11735
|
+
}
|
|
11736
|
+
}
|
|
11737
|
+
gate(sessionId, tool) {
|
|
11738
|
+
if (!this.enabledTools().includes(tool)) return failure("TOOL_DISABLED");
|
|
11739
|
+
const route = this.deps.routeOfSession(sessionId);
|
|
11740
|
+
if (route === void 0) return failure("ROUTE_NOT_FOUND");
|
|
11741
|
+
if (!this.deps.mayUseWiki(route.senderUserId)) return failure("NOT_AUTHORIZED");
|
|
11742
|
+
const channel = this.deps.channel();
|
|
11743
|
+
const api = channel?.wiki;
|
|
11744
|
+
if (channel === void 0 || api === void 0) return failure("CHANNEL_UNAVAILABLE");
|
|
11745
|
+
return {
|
|
11746
|
+
ok: true,
|
|
11747
|
+
context: {
|
|
11748
|
+
route,
|
|
11749
|
+
api,
|
|
11750
|
+
...channel.sheets === void 0 ? {} : { sheets: channel.sheets },
|
|
11751
|
+
...channel.bases === void 0 ? {} : { bases: channel.bases },
|
|
11752
|
+
roots: parseWikiRoots(this.deps.config().wikiRoots),
|
|
11753
|
+
botOpenId: channel.botOpenId()
|
|
11754
|
+
}
|
|
11755
|
+
};
|
|
11756
|
+
}
|
|
11757
|
+
/** Resolve a node/docx reference and check containment. */
|
|
11758
|
+
async containedNode(context, ref) {
|
|
11759
|
+
if (ref.kind === "space") return failure("INVALID_REFERENCE", "这里需要一个文档或节点,而不是整个知识空间");
|
|
11760
|
+
const node = await context.api.getNode(ref.token, ref.kind === "docx" ? "docx" : "wiki");
|
|
11761
|
+
return await containingRoot(context.api, context.roots, node) === void 0 ? failure("OUTSIDE_ROOTS") : node;
|
|
11762
|
+
}
|
|
11763
|
+
async consent(sessionId, context, action, fields, signal) {
|
|
11764
|
+
return consentFailure(await this.deps.consent.request({
|
|
11765
|
+
sessionId,
|
|
11766
|
+
route: context.route,
|
|
11767
|
+
action,
|
|
11768
|
+
...fields,
|
|
11769
|
+
signal
|
|
11770
|
+
}));
|
|
11771
|
+
}
|
|
11772
|
+
async spaceName(api, spaceId) {
|
|
11773
|
+
const cached = this.spaceNames.get(spaceId);
|
|
11774
|
+
if (cached !== void 0) return cached;
|
|
11775
|
+
const name = await api.spaceName(spaceId).catch(() => "");
|
|
11776
|
+
const label = name === "" ? `知识空间 ${spaceId}` : name;
|
|
11777
|
+
this.spaceNames.set(spaceId, label);
|
|
11778
|
+
return label;
|
|
11779
|
+
}
|
|
11780
|
+
/** `空间 / 父节点` for a node (one parent lookup). */
|
|
11781
|
+
async readConsent(sessionId, context, node, signal) {
|
|
11782
|
+
if (this.deps.consent.hasReadGrant(sessionId, node.objToken)) return void 0;
|
|
11783
|
+
const denied = await this.consent(sessionId, context, "read", {
|
|
11784
|
+
title: node.title,
|
|
11785
|
+
url: nodeUrl(context.roots, node),
|
|
11786
|
+
location: await this.locationOf(context.api, node),
|
|
11787
|
+
details: []
|
|
11788
|
+
}, signal);
|
|
11789
|
+
if (denied === void 0) this.deps.consent.grantRead(sessionId, node.objToken);
|
|
11790
|
+
return denied;
|
|
11791
|
+
}
|
|
11792
|
+
grantRead(sessionId, documentId) {
|
|
11793
|
+
this.deps.consent.grantRead(sessionId, documentId);
|
|
11794
|
+
}
|
|
11795
|
+
writeEnabled() {
|
|
11796
|
+
return this.deps.config().feishuTools.wikiWrite;
|
|
11797
|
+
}
|
|
11798
|
+
warn(message, ...args) {
|
|
11799
|
+
this.deps.warn(message, ...args);
|
|
11800
|
+
}
|
|
11801
|
+
async locationOf(api, node) {
|
|
11802
|
+
const space = await this.spaceName(api, node.spaceId);
|
|
11803
|
+
if (node.parentNodeToken === void 0) return space;
|
|
11804
|
+
const parent = await api.getNode(node.parentNodeToken, "wiki").catch(() => void 0);
|
|
11805
|
+
return parent === void 0 || parent.title === "" ? space : `${space} / ${parent.title}`;
|
|
11806
|
+
}
|
|
11807
|
+
nodeView(context, node) {
|
|
11808
|
+
return {
|
|
11809
|
+
title: node.title,
|
|
11810
|
+
node_token: node.nodeToken,
|
|
11811
|
+
obj_type: node.objType,
|
|
11812
|
+
node_type: node.nodeType,
|
|
11813
|
+
has_child: node.hasChild,
|
|
11814
|
+
url: nodeUrl(context.roots, node),
|
|
11815
|
+
owned_by_app: ownedByApp(node, context.botOpenId)
|
|
11816
|
+
};
|
|
11817
|
+
}
|
|
11818
|
+
async list(sessionId, rawArgs) {
|
|
11819
|
+
const args = parseArgs(WIKI_LIST_TOOL_NAME, wikiListArgsSchema, rawArgs);
|
|
11820
|
+
const gate = this.gate(sessionId, WIKI_LIST_TOOL_NAME);
|
|
11821
|
+
if (!gate.ok) return gate;
|
|
11822
|
+
const { context } = gate;
|
|
11823
|
+
return await guarded$1(this.deps, WIKI_LIST_TOOL_NAME, async () => {
|
|
11824
|
+
if (args.parent === void 0) return {
|
|
11825
|
+
ok: true,
|
|
11826
|
+
kind: "roots",
|
|
11827
|
+
items: await Promise.all(context.roots.map((root) => this.rootView(context, root)))
|
|
11828
|
+
};
|
|
11829
|
+
const ref = parseWikiRef(args.parent);
|
|
11830
|
+
if (ref === void 0) return failure("INVALID_REFERENCE");
|
|
11831
|
+
let spaceId;
|
|
11832
|
+
let parentToken;
|
|
11833
|
+
if (ref.kind === "space") {
|
|
11834
|
+
if (!spaceIsRoot(context.roots, ref.spaceId)) return failure("OUTSIDE_ROOTS");
|
|
11835
|
+
spaceId = ref.spaceId;
|
|
11836
|
+
} else {
|
|
11837
|
+
const node = await this.containedNode(context, ref);
|
|
11838
|
+
if ("ok" in node) return node;
|
|
11839
|
+
spaceId = node.spaceId;
|
|
11840
|
+
parentToken = node.nodeToken;
|
|
11841
|
+
}
|
|
11842
|
+
const page = await context.api.listChildren(spaceId, parentToken, args.page_token);
|
|
11843
|
+
return {
|
|
11844
|
+
ok: true,
|
|
11845
|
+
kind: "children",
|
|
11846
|
+
parent: args.parent,
|
|
11847
|
+
items: page.items.map((node) => this.nodeView(context, node)),
|
|
11848
|
+
...page.pageToken === void 0 ? {} : { page_token: page.pageToken }
|
|
11849
|
+
};
|
|
11850
|
+
});
|
|
11851
|
+
}
|
|
11852
|
+
async rootView(context, root) {
|
|
11853
|
+
try {
|
|
11854
|
+
if (root.kind === "space") return {
|
|
11855
|
+
root: "space",
|
|
11856
|
+
title: await this.spaceName(context.api, root.spaceId),
|
|
11857
|
+
link: root.link,
|
|
11858
|
+
space_id: root.spaceId
|
|
11859
|
+
};
|
|
11860
|
+
const node = await context.api.getNode(root.nodeToken, "wiki");
|
|
11861
|
+
return {
|
|
11862
|
+
root: "node",
|
|
11863
|
+
title: node.title,
|
|
11864
|
+
link: root.link,
|
|
11865
|
+
node_token: node.nodeToken,
|
|
11866
|
+
has_child: node.hasChild
|
|
11867
|
+
};
|
|
11868
|
+
} catch (error) {
|
|
11869
|
+
return {
|
|
11870
|
+
...root.kind === "space" ? {
|
|
11871
|
+
root: "space",
|
|
11872
|
+
space_id: root.spaceId
|
|
11873
|
+
} : {
|
|
11874
|
+
root: "node",
|
|
11875
|
+
node_token: root.nodeToken
|
|
11876
|
+
},
|
|
11877
|
+
title: "",
|
|
11878
|
+
link: root.link,
|
|
11879
|
+
error: failureOf(error).reason
|
|
11880
|
+
};
|
|
11881
|
+
}
|
|
11882
|
+
}
|
|
11883
|
+
async read(sessionId, rawArgs, exec) {
|
|
11884
|
+
const args = parseArgs(WIKI_READ_TOOL_NAME, wikiReadArgsSchema, rawArgs);
|
|
11885
|
+
const gate = this.gate(sessionId, WIKI_READ_TOOL_NAME);
|
|
11886
|
+
if (!gate.ok) return gate;
|
|
11887
|
+
const { context } = gate;
|
|
11888
|
+
return await guarded$1(this.deps, WIKI_READ_TOOL_NAME, async () => {
|
|
11889
|
+
const ref = parseWikiRef(args.doc);
|
|
11890
|
+
if (ref === void 0) return failure("INVALID_REFERENCE");
|
|
11891
|
+
const node = await this.containedNode(context, ref);
|
|
11892
|
+
if ("ok" in node) return node;
|
|
11893
|
+
if (node.objType !== "docx") return failure("UNSUPPORTED_TYPE", `${FAILURE_REASONS.UNSUPPORTED_TYPE}(该节点类型:${node.objType})`);
|
|
11894
|
+
const url = nodeUrl(context.roots, node);
|
|
11895
|
+
const denied = await this.readConsent(sessionId, context, node, exec.signal);
|
|
11896
|
+
if (denied !== void 0) return denied;
|
|
11897
|
+
if (exec.signal.aborted) return failure("ABORTED");
|
|
11898
|
+
const format = args.format ?? "markdown";
|
|
11899
|
+
const document = await context.api.fetchDocument(node.objToken, {
|
|
11900
|
+
format,
|
|
11901
|
+
withBlockIds: true,
|
|
11902
|
+
scope: args.scope ?? "full",
|
|
11903
|
+
...args.start_block_id === void 0 ? {} : { startBlockId: args.start_block_id },
|
|
11904
|
+
...args.end_block_id === void 0 ? {} : { endBlockId: args.end_block_id },
|
|
11905
|
+
...args.keyword === void 0 ? {} : { keyword: args.keyword },
|
|
11906
|
+
...args.max_depth === void 0 ? {} : { maxDepth: args.max_depth }
|
|
11907
|
+
});
|
|
11908
|
+
return {
|
|
11909
|
+
ok: true,
|
|
11910
|
+
title: node.title,
|
|
11911
|
+
node_token: node.nodeToken,
|
|
11912
|
+
url,
|
|
11913
|
+
revision_id: document.revisionId,
|
|
11914
|
+
format,
|
|
11915
|
+
editable: this.deps.config().feishuTools.wikiWrite && ownedByApp(node, context.botOpenId),
|
|
11916
|
+
content: document.content
|
|
11917
|
+
};
|
|
11918
|
+
});
|
|
11919
|
+
}
|
|
11920
|
+
async create(sessionId, rawArgs, exec) {
|
|
11921
|
+
const args = parseArgs(WIKI_CREATE_TOOL_NAME, wikiCreateArgsSchema, rawArgs);
|
|
11922
|
+
const gate = this.gate(sessionId, WIKI_CREATE_TOOL_NAME);
|
|
11923
|
+
if (!gate.ok) return gate;
|
|
11924
|
+
const { context } = gate;
|
|
11925
|
+
return await guarded$1(this.deps, WIKI_CREATE_TOOL_NAME, async () => {
|
|
11926
|
+
const ref = parseWikiRef(args.parent);
|
|
11927
|
+
if (ref === void 0) return failure("INVALID_REFERENCE");
|
|
11928
|
+
let spaceId;
|
|
11929
|
+
let parentToken;
|
|
11930
|
+
let location;
|
|
11931
|
+
let parentUrl;
|
|
11932
|
+
if (ref.kind === "space") {
|
|
11933
|
+
if (!spaceIsRoot(context.roots, ref.spaceId)) return failure("OUTSIDE_ROOTS");
|
|
11934
|
+
spaceId = ref.spaceId;
|
|
11935
|
+
location = `${await this.spaceName(context.api, spaceId)}(顶层)`;
|
|
11936
|
+
} else {
|
|
11937
|
+
const parent = await this.containedNode(context, ref);
|
|
11938
|
+
if ("ok" in parent) return parent;
|
|
11939
|
+
spaceId = parent.spaceId;
|
|
11940
|
+
parentToken = parent.nodeToken;
|
|
11941
|
+
parentUrl = nodeUrl(context.roots, parent);
|
|
11942
|
+
location = `${await this.spaceName(context.api, spaceId)} / ${parent.title === "" ? parent.nodeToken : parent.title}`;
|
|
11943
|
+
}
|
|
11944
|
+
const type = args.type ?? "docx";
|
|
11945
|
+
const format = args.format ?? "markdown";
|
|
11946
|
+
const content = args.content === void 0 || args.content.trim() === "" ? void 0 : args.content;
|
|
11947
|
+
if (type !== "docx" && content !== void 0) return failure("INVALID_OPERATION", "content 只用于飞书文档;新建电子表格或多维表格后,用 feishu_wiki_sheet_edit / feishu_wiki_base_edit 写入数据");
|
|
11948
|
+
const details = type !== "docx" ? [`**类型**:${CREATE_TYPE_LABELS[type]}(空白)`] : content === void 0 ? ["**内容**:空文档"] : [`**内容预览**(${format === "xml" ? "XML" : "Markdown"},共 ${content.length} 字)\n${previewBlock(content)}`];
|
|
11949
|
+
const denied = await this.consent(sessionId, context, "create", {
|
|
11950
|
+
title: args.title,
|
|
11951
|
+
...parentUrl === void 0 ? {} : { url: parentUrl },
|
|
11952
|
+
location,
|
|
11953
|
+
details
|
|
11954
|
+
}, exec.signal);
|
|
11955
|
+
if (denied !== void 0) return denied;
|
|
11956
|
+
if (exec.signal.aborted) return failure("ABORTED");
|
|
11957
|
+
const node = await context.api.createNode(spaceId, parentToken, args.title, type);
|
|
11958
|
+
const url = nodeUrl(context.roots, node);
|
|
11959
|
+
this.grantRead(sessionId, node.objToken);
|
|
11960
|
+
if (content === void 0) return {
|
|
11961
|
+
ok: true,
|
|
11962
|
+
title: node.title || args.title,
|
|
11963
|
+
node_token: node.nodeToken,
|
|
11964
|
+
url,
|
|
11965
|
+
warnings: []
|
|
11966
|
+
};
|
|
11967
|
+
try {
|
|
11968
|
+
const written = await context.api.updateDocument(node.objToken, {
|
|
11969
|
+
command: "block_insert_after",
|
|
11970
|
+
format,
|
|
11971
|
+
content,
|
|
11972
|
+
blockId: "-1"
|
|
11973
|
+
});
|
|
11974
|
+
if (written.result === "failed") return {
|
|
11975
|
+
...failure("CONTENT_FAILED", `${FAILURE_REASONS.CONTENT_FAILED}:${written.warnings.join(";") || "飞书返回 failed"};可用 feishu_wiki_edit 重新写入`),
|
|
11976
|
+
node_token: node.nodeToken,
|
|
11977
|
+
url
|
|
11978
|
+
};
|
|
11979
|
+
if (written.result === "partial_success") return {
|
|
11980
|
+
...failure("CONTENT_FAILED", `文档已创建,但内容只部分写入:${written.warnings.join(";") || "飞书返回 partial_success"};请先用 feishu_wiki_read 核验,再用 feishu_wiki_edit 补齐`),
|
|
11981
|
+
node_token: node.nodeToken,
|
|
11982
|
+
url
|
|
11983
|
+
};
|
|
11984
|
+
return {
|
|
11985
|
+
ok: true,
|
|
11986
|
+
title: node.title || args.title,
|
|
11987
|
+
node_token: node.nodeToken,
|
|
11988
|
+
url,
|
|
11989
|
+
...written.revisionId === void 0 ? {} : { revision_id: written.revisionId },
|
|
11990
|
+
warnings: written.warnings
|
|
11991
|
+
};
|
|
11992
|
+
} catch (error) {
|
|
11993
|
+
const reason = failureOf(error).reason;
|
|
11994
|
+
return {
|
|
11995
|
+
...failure("CONTENT_FAILED", `${FAILURE_REASONS.CONTENT_FAILED}:${reason};可用 feishu_wiki_edit 重新写入`),
|
|
11996
|
+
node_token: node.nodeToken,
|
|
11997
|
+
url
|
|
11998
|
+
};
|
|
11999
|
+
}
|
|
12000
|
+
});
|
|
12001
|
+
}
|
|
12002
|
+
async edit(sessionId, rawArgs, exec) {
|
|
12003
|
+
const args = parseArgs(WIKI_EDIT_TOOL_NAME, wikiEditArgsSchema, rawArgs);
|
|
12004
|
+
const gate = this.gate(sessionId, WIKI_EDIT_TOOL_NAME);
|
|
12005
|
+
if (!gate.ok) return gate;
|
|
12006
|
+
const { context } = gate;
|
|
12007
|
+
const format = args.format ?? "markdown";
|
|
12008
|
+
const requests = [];
|
|
12009
|
+
for (const [index, operation] of args.operations.entries()) {
|
|
12010
|
+
const request = updateRequestOf(operation, format);
|
|
12011
|
+
if (typeof request === "string") return failure("INVALID_OPERATION", `第 ${index + 1} 个操作(${operation.command}):${request}`);
|
|
12012
|
+
requests.push(request);
|
|
12013
|
+
}
|
|
12014
|
+
return await guarded$1(this.deps, WIKI_EDIT_TOOL_NAME, async () => {
|
|
12015
|
+
const ref = parseWikiRef(args.doc);
|
|
12016
|
+
if (ref === void 0) return failure("INVALID_REFERENCE");
|
|
12017
|
+
const node = await this.containedNode(context, ref);
|
|
12018
|
+
if ("ok" in node) return node;
|
|
12019
|
+
if (node.objType !== "docx") return failure("UNSUPPORTED_TYPE", `${FAILURE_REASONS.UNSUPPORTED_TYPE}(该节点类型:${node.objType})`);
|
|
12020
|
+
if (!ownedByApp(node, context.botOpenId)) return failure("NOT_OWNED", `${FAILURE_REASONS.NOT_OWNED}(该文档所有者:${node.owner ?? "未知"})`);
|
|
12021
|
+
const current = await context.api.documentRevision(node.objToken);
|
|
12022
|
+
if (current !== args.revision_id) return {
|
|
12023
|
+
...failure("REVISION_CONFLICT", `${FAILURE_REASONS.REVISION_CONFLICT}(读取时 ${args.revision_id},当前 ${current})`),
|
|
12024
|
+
revision_id: current
|
|
12025
|
+
};
|
|
12026
|
+
const url = nodeUrl(context.roots, node);
|
|
12027
|
+
const denied = await this.consent(sessionId, context, "edit", {
|
|
12028
|
+
title: node.title,
|
|
12029
|
+
url,
|
|
12030
|
+
location: await this.locationOf(context.api, node),
|
|
12031
|
+
details: operationLines(args.operations)
|
|
12032
|
+
}, exec.signal);
|
|
12033
|
+
if (denied !== void 0) return denied;
|
|
12034
|
+
if (await context.api.documentRevision(node.objToken) !== args.revision_id) return failure("REVISION_CONFLICT", `${FAILURE_REASONS.REVISION_CONFLICT}(等待授权期间文档被修改)`);
|
|
12035
|
+
const warnings = [];
|
|
12036
|
+
let revision = args.revision_id;
|
|
12037
|
+
for (const [index, request] of requests.entries()) {
|
|
12038
|
+
if (exec.signal.aborted) return {
|
|
12039
|
+
...failure("ABORTED", `已取消;前 ${index} 个操作已执行`),
|
|
12040
|
+
applied: index
|
|
12041
|
+
};
|
|
12042
|
+
let result;
|
|
12043
|
+
try {
|
|
12044
|
+
result = await context.api.updateDocument(node.objToken, {
|
|
12045
|
+
...request,
|
|
12046
|
+
revisionId: revision
|
|
12047
|
+
});
|
|
12048
|
+
} catch (error) {
|
|
12049
|
+
const reason = failureOf(error).reason;
|
|
12050
|
+
return {
|
|
12051
|
+
...failure("EDIT_FAILED", `第 ${index + 1} 个操作失败:${reason};前 ${index} 个操作已执行`),
|
|
12052
|
+
applied: index
|
|
12053
|
+
};
|
|
12054
|
+
}
|
|
12055
|
+
warnings.push(...result.warnings);
|
|
12056
|
+
if (result.revisionId !== void 0) revision = result.revisionId;
|
|
12057
|
+
if (result.result === "failed") return {
|
|
12058
|
+
...failure("EDIT_FAILED", `第 ${index + 1} 个操作失败:${result.warnings.join(";") || "飞书返回 failed"};前 ${index} 个操作已执行`),
|
|
12059
|
+
applied: index
|
|
12060
|
+
};
|
|
12061
|
+
if (result.result === "partial_success") return {
|
|
12062
|
+
...failure("EDIT_PARTIAL", `第 ${index + 1} 个操作只部分生效(${result.warnings.join(";") || "飞书返回 partial_success"});前 ${index} 个操作已执行,已停止后续操作;请用 feishu_wiki_read 读取核验后再继续`),
|
|
12063
|
+
applied: index,
|
|
12064
|
+
revision_id: revision
|
|
12065
|
+
};
|
|
12066
|
+
}
|
|
12067
|
+
return {
|
|
12068
|
+
ok: true,
|
|
12069
|
+
title: node.title,
|
|
12070
|
+
node_token: node.nodeToken,
|
|
12071
|
+
url,
|
|
12072
|
+
applied: requests.length,
|
|
12073
|
+
revision_id: revision,
|
|
12074
|
+
warnings
|
|
12075
|
+
};
|
|
12076
|
+
});
|
|
12077
|
+
}
|
|
12078
|
+
};
|
|
12079
|
+
/** Map one validated operation to a `docs_ai` update, or the reason it is incomplete. */
|
|
12080
|
+
function updateRequestOf(operation, format) {
|
|
12081
|
+
switch (operation.command) {
|
|
12082
|
+
case "append": return {
|
|
12083
|
+
command: "block_insert_after",
|
|
12084
|
+
format,
|
|
12085
|
+
content: operation.content,
|
|
12086
|
+
blockId: "-1"
|
|
12087
|
+
};
|
|
12088
|
+
case "insert_after": return {
|
|
12089
|
+
command: "block_insert_after",
|
|
12090
|
+
format,
|
|
12091
|
+
content: operation.content,
|
|
12092
|
+
blockId: operation.block_id
|
|
12093
|
+
};
|
|
12094
|
+
case "replace": {
|
|
12095
|
+
const range = rangeOf(operation);
|
|
12096
|
+
return typeof range === "string" ? range : {
|
|
12097
|
+
command: "block_replace",
|
|
12098
|
+
format,
|
|
12099
|
+
content: operation.content,
|
|
12100
|
+
...range
|
|
12101
|
+
};
|
|
12102
|
+
}
|
|
12103
|
+
case "delete": {
|
|
12104
|
+
const range = rangeOf(operation);
|
|
12105
|
+
return typeof range === "string" ? range : {
|
|
12106
|
+
command: "block_delete",
|
|
12107
|
+
format,
|
|
12108
|
+
...range
|
|
12109
|
+
};
|
|
12110
|
+
}
|
|
12111
|
+
case "str_replace": return {
|
|
12112
|
+
command: "str_replace",
|
|
12113
|
+
format,
|
|
12114
|
+
pattern: operation.pattern,
|
|
12115
|
+
content: operation.content
|
|
12116
|
+
};
|
|
12117
|
+
case "overwrite": return {
|
|
12118
|
+
command: "overwrite",
|
|
12119
|
+
format,
|
|
12120
|
+
content: operation.content
|
|
12121
|
+
};
|
|
12122
|
+
}
|
|
12123
|
+
}
|
|
12124
|
+
/** `block_id` alone, or `start_block_id` + `end_block_id` together. */
|
|
12125
|
+
function rangeOf(operation) {
|
|
12126
|
+
const hasRange = operation.start_block_id !== void 0 || operation.end_block_id !== void 0;
|
|
12127
|
+
if (operation.block_id !== void 0 && !hasRange) return { blockId: operation.block_id };
|
|
12128
|
+
if (operation.block_id === void 0 && operation.start_block_id !== void 0 && operation.end_block_id !== void 0) return {
|
|
12129
|
+
startBlockId: operation.start_block_id,
|
|
12130
|
+
endBlockId: operation.end_block_id
|
|
12131
|
+
};
|
|
12132
|
+
return "需要 block_id,或成对的 start_block_id 与 end_block_id(二者不能同时使用)";
|
|
12133
|
+
}
|
|
12134
|
+
/** The consent card's operation list. */
|
|
12135
|
+
/** Consent-card details: a header, then one entry per operation so every operation stays visible. */
|
|
12136
|
+
function operationLines(operations) {
|
|
12137
|
+
const lines = operations.map((operation, index) => {
|
|
12138
|
+
const n = `${index + 1}.`;
|
|
12139
|
+
switch (operation.command) {
|
|
12140
|
+
case "append": return `${n} 在文末追加 ${operation.content.length} 字:${preview(operation.content)}`;
|
|
12141
|
+
case "insert_after": return `${n} 在块 \`${operation.block_id}\` 后插入 ${operation.content.length} 字:${preview(operation.content)}`;
|
|
12142
|
+
case "replace": return `${n} 替换${blockRangeLabel(operation)}为 ${operation.content.length} 字:${preview(operation.content)}`;
|
|
12143
|
+
case "delete": return `${n} 删除${blockRangeLabel(operation)}`;
|
|
12144
|
+
case "str_replace": return `${n} 把文本「${preview(operation.pattern)}」替换为「${preview(operation.content)}」`;
|
|
12145
|
+
case "overwrite": return `${n} ⚠️ 清空整篇并重写为 ${operation.content.length} 字:${preview(operation.content)}`;
|
|
12146
|
+
}
|
|
12147
|
+
});
|
|
12148
|
+
return [`**将执行 ${operations.length} 个操作**`, ...lines];
|
|
12149
|
+
}
|
|
12150
|
+
function blockRangeLabel(operation) {
|
|
12151
|
+
return operation.block_id !== void 0 ? `块 \`${operation.block_id}\`` : `块 \`${operation.start_block_id ?? "?"}\` 到 \`${operation.end_block_id ?? "?"}\``;
|
|
12152
|
+
}
|
|
12153
|
+
function isValue(value) {
|
|
12154
|
+
return typeof value === "object" && value !== null && typeof value.ok === "boolean";
|
|
12155
|
+
}
|
|
12156
|
+
/**
|
|
12157
|
+
* Model-visible content of one call: a read returns its document content as
|
|
12158
|
+
* plain text after a short header; everything else is JSON.
|
|
12159
|
+
*
|
|
12160
|
+
* @param value The tool value.
|
|
12161
|
+
* @returns One text block.
|
|
12162
|
+
*/
|
|
12163
|
+
function renderWikiResult(value) {
|
|
12164
|
+
if (!isValue(value)) return [{
|
|
12165
|
+
type: "text",
|
|
12166
|
+
text: "知识库工具结果不可用"
|
|
12167
|
+
}];
|
|
12168
|
+
if (value.ok && "content" in value) {
|
|
12169
|
+
const { content, ...meta } = value;
|
|
12170
|
+
return [{
|
|
12171
|
+
type: "text",
|
|
12172
|
+
text: `${JSON.stringify(meta)}\n\n${content}`
|
|
12173
|
+
}];
|
|
12174
|
+
}
|
|
12175
|
+
return [{
|
|
12176
|
+
type: "text",
|
|
12177
|
+
text: JSON.stringify(value)
|
|
12178
|
+
}];
|
|
12179
|
+
}
|
|
12180
|
+
/**
|
|
12181
|
+
* One-line result presentation.
|
|
12182
|
+
*
|
|
12183
|
+
* @param value The tool value.
|
|
12184
|
+
* @returns Display text.
|
|
12185
|
+
*/
|
|
12186
|
+
function presentWikiResult(value) {
|
|
12187
|
+
if (!isValue(value)) return "知识库工具结果不可用";
|
|
12188
|
+
if (!value.ok) return `失败(${value.code}):${value.reason}`;
|
|
12189
|
+
if ("items" in value) return `列出 ${value.items.length} 项`;
|
|
12190
|
+
if ("records" in value && value.records !== void 0) return `已读取《${value.title}》(${value.records.length} 条记录)`;
|
|
12191
|
+
if ("tables" in value) return `已读取《${value.title}》(${value.tables.length} 个数据表)`;
|
|
12192
|
+
if ("content" in value) return `已读取《${value.title}》(${value.content.length} 字)`;
|
|
12193
|
+
if ("applied" in value) return `已修改《${value.title}》(${value.applied} 个操作)`;
|
|
12194
|
+
return `已新建《${value.title}》`;
|
|
12195
|
+
}
|
|
12196
|
+
const REF_DESCRIPTION = "知识库节点链接 https://<租户>.feishu.cn/wiki/<node_token> 或 node_token";
|
|
12197
|
+
const FORMAT_PROPERTY = {
|
|
12198
|
+
type: "string",
|
|
12199
|
+
enum: ["markdown", "xml"],
|
|
12200
|
+
description: "内容格式,默认 markdown;xml 读取时带 block id"
|
|
12201
|
+
};
|
|
12202
|
+
const BLOCK_ID_PROPERTY = {
|
|
12203
|
+
type: "string",
|
|
12204
|
+
description: "块 id(xml 读取结果中的 id 属性)"
|
|
12205
|
+
};
|
|
12206
|
+
const LIST_PARAMETERS = {
|
|
12207
|
+
type: "object",
|
|
12208
|
+
properties: {
|
|
12209
|
+
parent: {
|
|
12210
|
+
type: "string",
|
|
12211
|
+
description: `要列出子节点的位置:${REF_DESCRIPTION},或知识空间链接 https://<租户>.feishu.cn/wiki/space/<space_id>;不传则列出可访问的根`
|
|
12212
|
+
},
|
|
12213
|
+
page_token: {
|
|
12214
|
+
type: "string",
|
|
12215
|
+
description: "上一页返回的 page_token"
|
|
12216
|
+
}
|
|
12217
|
+
},
|
|
12218
|
+
additionalProperties: false
|
|
12219
|
+
};
|
|
12220
|
+
const READ_PARAMETERS = {
|
|
12221
|
+
type: "object",
|
|
12222
|
+
properties: {
|
|
12223
|
+
doc: {
|
|
12224
|
+
type: "string",
|
|
12225
|
+
description: REF_DESCRIPTION
|
|
12226
|
+
},
|
|
12227
|
+
format: FORMAT_PROPERTY,
|
|
12228
|
+
scope: {
|
|
12229
|
+
type: "string",
|
|
12230
|
+
enum: [
|
|
12231
|
+
"full",
|
|
12232
|
+
"outline",
|
|
12233
|
+
"section",
|
|
12234
|
+
"range",
|
|
12235
|
+
"keyword"
|
|
12236
|
+
],
|
|
12237
|
+
description: "读取范围,默认 full"
|
|
12238
|
+
},
|
|
12239
|
+
start_block_id: {
|
|
12240
|
+
...BLOCK_ID_PROPERTY,
|
|
12241
|
+
description: "section 的标题块 id,或 range 的起点"
|
|
12242
|
+
},
|
|
12243
|
+
end_block_id: {
|
|
12244
|
+
...BLOCK_ID_PROPERTY,
|
|
12245
|
+
description: "range 的终点,-1 表示到文末"
|
|
12246
|
+
},
|
|
12247
|
+
keyword: {
|
|
12248
|
+
type: "string",
|
|
12249
|
+
description: "keyword 模式的关键词,多个用 | 分隔"
|
|
12250
|
+
},
|
|
12251
|
+
max_depth: {
|
|
12252
|
+
type: "integer",
|
|
12253
|
+
description: "outline:标题层级上限;其他:子树深度,-1 不限"
|
|
12254
|
+
}
|
|
12255
|
+
},
|
|
12256
|
+
required: ["doc"],
|
|
12257
|
+
additionalProperties: false
|
|
12258
|
+
};
|
|
12259
|
+
const CREATE_PARAMETERS = {
|
|
12260
|
+
type: "object",
|
|
12261
|
+
properties: {
|
|
12262
|
+
parent: {
|
|
12263
|
+
type: "string",
|
|
12264
|
+
description: `父位置:${REF_DESCRIPTION},或知识空间链接(建在空间顶层)`
|
|
12265
|
+
},
|
|
12266
|
+
title: {
|
|
12267
|
+
type: "string",
|
|
12268
|
+
description: "标题"
|
|
12269
|
+
},
|
|
12270
|
+
type: {
|
|
12271
|
+
type: "string",
|
|
12272
|
+
enum: [
|
|
12273
|
+
"docx",
|
|
12274
|
+
"sheet",
|
|
12275
|
+
"bitable"
|
|
12276
|
+
],
|
|
12277
|
+
description: "节点类型:docx 飞书文档(默认)、sheet 电子表格、bitable 多维表格"
|
|
12278
|
+
},
|
|
12279
|
+
content: {
|
|
12280
|
+
type: "string",
|
|
12281
|
+
description: "文档正文(仅 docx,可选)"
|
|
12282
|
+
},
|
|
12283
|
+
format: FORMAT_PROPERTY
|
|
12284
|
+
},
|
|
12285
|
+
required: ["parent", "title"],
|
|
12286
|
+
additionalProperties: false
|
|
12287
|
+
};
|
|
12288
|
+
const EDIT_PARAMETERS = {
|
|
12289
|
+
type: "object",
|
|
12290
|
+
properties: {
|
|
12291
|
+
doc: {
|
|
12292
|
+
type: "string",
|
|
12293
|
+
description: REF_DESCRIPTION
|
|
12294
|
+
},
|
|
12295
|
+
revision_id: {
|
|
12296
|
+
type: "integer",
|
|
12297
|
+
description: "feishu_wiki_read 返回的 revision_id"
|
|
12298
|
+
},
|
|
12299
|
+
format: FORMAT_PROPERTY,
|
|
12300
|
+
operations: {
|
|
12301
|
+
type: "array",
|
|
12302
|
+
minItems: 1,
|
|
12303
|
+
maxItems: 20,
|
|
12304
|
+
items: {
|
|
12305
|
+
type: "object",
|
|
12306
|
+
properties: {
|
|
12307
|
+
command: {
|
|
12308
|
+
type: "string",
|
|
12309
|
+
enum: [
|
|
12310
|
+
"append",
|
|
12311
|
+
"insert_after",
|
|
12312
|
+
"replace",
|
|
12313
|
+
"delete",
|
|
12314
|
+
"str_replace",
|
|
12315
|
+
"overwrite"
|
|
12316
|
+
]
|
|
12317
|
+
},
|
|
12318
|
+
block_id: BLOCK_ID_PROPERTY,
|
|
12319
|
+
start_block_id: BLOCK_ID_PROPERTY,
|
|
12320
|
+
end_block_id: BLOCK_ID_PROPERTY,
|
|
12321
|
+
pattern: {
|
|
12322
|
+
type: "string",
|
|
12323
|
+
description: "str_replace 要查找的文本"
|
|
12324
|
+
},
|
|
12325
|
+
content: {
|
|
12326
|
+
type: "string",
|
|
12327
|
+
description: "写入的内容"
|
|
12328
|
+
}
|
|
12329
|
+
},
|
|
12330
|
+
required: ["command"],
|
|
12331
|
+
additionalProperties: false
|
|
12332
|
+
}
|
|
12333
|
+
}
|
|
12334
|
+
},
|
|
12335
|
+
required: [
|
|
12336
|
+
"doc",
|
|
12337
|
+
"revision_id",
|
|
12338
|
+
"operations"
|
|
12339
|
+
],
|
|
12340
|
+
additionalProperties: false
|
|
12341
|
+
};
|
|
12342
|
+
//#endregion
|
|
12343
|
+
//#region src/routing/inbound.ts
|
|
12344
|
+
/**
|
|
12345
|
+
* Inbound routing: dedup, admission, the route decision tree, and the
|
|
12346
|
+
* session-establishment / continuation flow.
|
|
12347
|
+
*
|
|
12348
|
+
* Everything here is channel-plane policy; the command plane (slash commands)
|
|
12349
|
+
* and the answerers stay in the service and are reached through narrow ports.
|
|
12350
|
+
* Dependency reads go through accessors so the moved logic stays readable.
|
|
12351
|
+
*
|
|
12352
|
+
* @module dsh-channels/routing/inbound
|
|
12353
|
+
*/
|
|
12354
|
+
/** Confirmation line for each keyword approval reply. */
|
|
12355
|
+
const APPROVAL_TEXT_REPLIES = {
|
|
12356
|
+
allowed: "已允许本次操作。",
|
|
12357
|
+
remembered: "已允许,本话题内同类操作不再询问。",
|
|
12358
|
+
denied: "已拒绝本次操作。"
|
|
12359
|
+
};
|
|
12360
|
+
/**
|
|
12361
|
+
* User-facing text for attachments that could not be archived. Delivered as a
|
|
12362
|
+
* plain reply (never a card) so the user learns why the Agent cannot read a
|
|
12363
|
+
* file instead of silently getting an answer about older attachments.
|
|
12364
|
+
*/
|
|
12365
|
+
function buildAttachmentFailureNotice(failures) {
|
|
12366
|
+
return [
|
|
12367
|
+
"⚠️ 附件保存失败,Agent 无法读取:",
|
|
12368
|
+
...failures.map((failure) => `• ${failure.name}:${failure.reason}`),
|
|
12369
|
+
"可尝试压缩/拆分后重发,或把文件直接放到工作区目录。"
|
|
9400
12370
|
].join("\n");
|
|
9401
12371
|
}
|
|
9402
12372
|
/** Build one durable user message from inbound Feishu text. */
|
|
@@ -11892,6 +14862,8 @@ const ATTACHMENT_STORE_MODES = ["native", "archive"];
|
|
|
11892
14862
|
const DEFAULT_ATTACHMENT_FETCH_MAX_BYTES = 100 * 1024 * 1024;
|
|
11893
14863
|
/** Default whole-call timeout of `feishu_attachment_fetch` (120 s). */
|
|
11894
14864
|
const DEFAULT_ATTACHMENT_FETCH_TIMEOUT_MS = 12e4;
|
|
14865
|
+
/** Largest delay Node's `setTimeout` honors (2^31 − 1 ms, about 24.8 days); longer delays fire at once. */
|
|
14866
|
+
const MAX_TIMER_DELAY_MS = 2147483647;
|
|
11895
14867
|
/** Default per-image byte cap for trigger-message prefetch (20 MiB). */
|
|
11896
14868
|
const DEFAULT_PREFETCH_IMAGE_MAX_BYTES = 20 * 1024 * 1024;
|
|
11897
14869
|
/** Default total byte budget for one trigger-message image prefetch (50 MiB). */
|
|
@@ -12090,10 +15062,12 @@ const configSchema = z.object({
|
|
|
12090
15062
|
* fetch (1–50). A message with more attachments is refused with a readable
|
|
12091
15063
|
* reason so the model can retry per `file_key`.
|
|
12092
15064
|
* - `attachmentFetchMaxBytes`: total download budget of one tool call
|
|
12093
|
-
* (default 100 MiB). Files stream to the native attachment
|
|
12094
|
-
* limit is enforced while streaming rather than buffered
|
|
12095
|
-
* -
|
|
12096
|
-
*
|
|
15065
|
+
* (default 100 MiB, no upper bound). Files stream to the native attachment
|
|
15066
|
+
* library, so the limit is enforced while streaming rather than buffered
|
|
15067
|
+
* first, and multi-GiB files need only disk space.
|
|
15068
|
+
* - `attachmentFetchTimeoutMs`: whole-call timeout (default 120 s, at most
|
|
15069
|
+
* {@link MAX_TIMER_DELAY_MS}). On expiry the in-flight download is
|
|
15070
|
+
* cancelled and reported per attachment.
|
|
12097
15071
|
* - `topicMessages` (default on): register `feishu_topic_messages` in
|
|
12098
15072
|
* channel sessions so the model can read the quoted message / earlier
|
|
12099
15073
|
* topic messages on demand and authorize their attachments for fetch.
|
|
@@ -12113,16 +15087,40 @@ const configSchema = z.object({
|
|
|
12113
15087
|
* `im:resource` (or `im:resource:upload`) scope; without it a call returns
|
|
12114
15088
|
* a readable failure and the turn continues.
|
|
12115
15089
|
*
|
|
15090
|
+
* - `wikiRead` (default off): register `feishu_wiki_list`,
|
|
15091
|
+
* `feishu_wiki_read`, `feishu_wiki_sheet_read`, and
|
|
15092
|
+
* `feishu_wiki_base_read` so the model can browse the wiki nodes under
|
|
15093
|
+
* `wikiRoots` and read docx documents, spreadsheets, and Bases there.
|
|
15094
|
+
* Needs `wiki:wiki` (or `wiki:wiki:readonly`), `docx:document` (or
|
|
15095
|
+
* `docx:document:readonly`), `sheets:spreadsheet` (or a read-only
|
|
15096
|
+
* spreadsheet scope), and `bitable:app` (or a read-only Base scope).
|
|
15097
|
+
* - `wikiWrite` (default off): register `feishu_wiki_create`,
|
|
15098
|
+
* `feishu_wiki_edit`, `feishu_wiki_sheet_edit`, and
|
|
15099
|
+
* `feishu_wiki_base_edit` so the model can create docx documents,
|
|
15100
|
+
* spreadsheets, and Bases under `wikiRoots`, edit documents and
|
|
15101
|
+
* spreadsheets this app owns, and change records of Bases this app owns
|
|
15102
|
+
* (other Bases accept new records only). Needs `wiki:wiki`,
|
|
15103
|
+
* `docx:document`, `sheets:spreadsheet`, and `bitable:app` (or the
|
|
15104
|
+
* matching write-only scopes).
|
|
15105
|
+
*
|
|
15106
|
+
* The wiki tools act with the app identity and are mounted only while
|
|
15107
|
+
* `wikiRoots` is non-empty. Each call is limited to topics whose owner is
|
|
15108
|
+
* the administrator or on the wiki whitelist (`/auth wiki add`); opening a
|
|
15109
|
+
* document, creating one, and editing one each need the topic owner's
|
|
15110
|
+
* consent on an authorization card (a read consent lasts for the topic).
|
|
15111
|
+
*
|
|
12116
15112
|
* The Web panel/metrics ship with `panel_docs_release`.
|
|
12117
15113
|
*/
|
|
12118
15114
|
feishuTools: z.object({
|
|
12119
15115
|
attachmentFetch: z.boolean().default(true),
|
|
12120
15116
|
attachmentFetchMaxResources: z.number().int().min(1).max(50).default(10),
|
|
12121
|
-
attachmentFetchMaxBytes: z.number().int().min(1).
|
|
12122
|
-
attachmentFetchTimeoutMs: z.number().int().min(1e3).max(
|
|
15117
|
+
attachmentFetchMaxBytes: z.number().int().min(1).default(DEFAULT_ATTACHMENT_FETCH_MAX_BYTES),
|
|
15118
|
+
attachmentFetchTimeoutMs: z.number().int().min(1e3).max(MAX_TIMER_DELAY_MS).default(DEFAULT_ATTACHMENT_FETCH_TIMEOUT_MS),
|
|
12123
15119
|
topicMessages: z.boolean().default(true),
|
|
12124
15120
|
topicMessagesTimeoutMs: z.number().int().min(1e3).max(12e4).default(DEFAULT_TOPIC_MESSAGES_TIMEOUT_MS),
|
|
12125
|
-
sendFile: z.boolean().default(true)
|
|
15121
|
+
sendFile: z.boolean().default(true),
|
|
15122
|
+
wikiRead: z.boolean().default(false),
|
|
15123
|
+
wikiWrite: z.boolean().default(false)
|
|
12126
15124
|
}).default({
|
|
12127
15125
|
attachmentFetch: true,
|
|
12128
15126
|
attachmentFetchMaxResources: 10,
|
|
@@ -12130,9 +15128,26 @@ const configSchema = z.object({
|
|
|
12130
15128
|
attachmentFetchTimeoutMs: DEFAULT_ATTACHMENT_FETCH_TIMEOUT_MS,
|
|
12131
15129
|
topicMessages: true,
|
|
12132
15130
|
topicMessagesTimeoutMs: DEFAULT_TOPIC_MESSAGES_TIMEOUT_MS,
|
|
12133
|
-
sendFile: true
|
|
15131
|
+
sendFile: true,
|
|
15132
|
+
wikiRead: false,
|
|
15133
|
+
wikiWrite: false
|
|
12134
15134
|
}),
|
|
12135
15135
|
/**
|
|
15136
|
+
* Wiki (知识库) locations the wiki tools may touch: whole spaces
|
|
15137
|
+
* (`https://<tenant>.feishu.cn/wiki/space/<space_id>`) or single nodes with
|
|
15138
|
+
* everything below them (`https://<tenant>.feishu.cn/wiki/<node_token>`).
|
|
15139
|
+
* Browsing, reading, creating, and editing outside these roots fail. Empty
|
|
15140
|
+
* (default) mounts no wiki tool even when `feishuTools.wikiRead`/`wikiWrite`
|
|
15141
|
+
* is on.
|
|
15142
|
+
*/
|
|
15143
|
+
wikiRoots: z.array(z.string().trim().superRefine((link, issue) => {
|
|
15144
|
+
const parsed = parseWikiRootLink(link);
|
|
15145
|
+
if (!parsed.ok) issue.addIssue({
|
|
15146
|
+
code: "custom",
|
|
15147
|
+
message: WIKI_ROOT_LINK_ERRORS[parsed.error]
|
|
15148
|
+
});
|
|
15149
|
+
})).max(20).default([]),
|
|
15150
|
+
/**
|
|
12136
15151
|
* Send a one-time onboarding card on the first private message of a chat
|
|
12137
15152
|
* (facts + command cheat sheet). Persisted per chat; never repeats.
|
|
12138
15153
|
*/
|
|
@@ -12285,6 +15300,9 @@ const EDITABLE_KEYS = [
|
|
|
12285
15300
|
"feishuTools.topicMessages",
|
|
12286
15301
|
"feishuTools.topicMessagesTimeoutMs",
|
|
12287
15302
|
"feishuTools.sendFile",
|
|
15303
|
+
"feishuTools.wikiRead",
|
|
15304
|
+
"feishuTools.wikiWrite",
|
|
15305
|
+
"wikiRoots",
|
|
12288
15306
|
"promptContextTimeoutMs",
|
|
12289
15307
|
"provider",
|
|
12290
15308
|
"model",
|
|
@@ -12395,6 +15413,12 @@ const PANEL_COPY = {
|
|
|
12395
15413
|
discardTab: "放弃本页更改",
|
|
12396
15414
|
saveHintClean: "没有未保存的更改。",
|
|
12397
15415
|
dirtyCount: "未保存项",
|
|
15416
|
+
listAdd: "添加",
|
|
15417
|
+
listRemove: "移除",
|
|
15418
|
+
listEntry: "第 {n} 项",
|
|
15419
|
+
listTooMany: "最多 {max} 项(空输入框不计)",
|
|
15420
|
+
listNotArray: "必须是字符串列表",
|
|
15421
|
+
listEntryNotString: "必须是字符串",
|
|
12398
15422
|
browseDirectory: "选择目录…",
|
|
12399
15423
|
browsingDirectory: "选择中…",
|
|
12400
15424
|
directoryPicked: "已选择目录",
|
|
@@ -12750,6 +15774,7 @@ async function openFeishuDomain(ctx) {
|
|
|
12750
15774
|
const accessGroupsRaw = domain.table("access_groups");
|
|
12751
15775
|
const accessUsersRaw = domain.table("access_users");
|
|
12752
15776
|
const accessRequestsRaw = domain.table("access_requests");
|
|
15777
|
+
const accessWikiUsersRaw = domain.table("access_wiki_users");
|
|
12753
15778
|
return {
|
|
12754
15779
|
routes: {
|
|
12755
15780
|
get: (key) => table.get(key),
|
|
@@ -12793,11 +15818,19 @@ async function openFeishuDomain(ctx) {
|
|
|
12793
15818
|
delete: (key) => accessRequestsRaw.delete(key),
|
|
12794
15819
|
entries: () => accessRequestsRaw.entries()
|
|
12795
15820
|
},
|
|
15821
|
+
accessWikiUsers: {
|
|
15822
|
+
get: (key) => accessWikiUsersRaw.get(key),
|
|
15823
|
+
put: (key, value) => accessWikiUsersRaw.put(key, value),
|
|
15824
|
+
delete: (key) => accessWikiUsersRaw.delete(key),
|
|
15825
|
+
entries: () => accessWikiUsersRaw.entries()
|
|
15826
|
+
},
|
|
12796
15827
|
getGlobal: () => domain.global.get() ?? {},
|
|
12797
15828
|
setGlobal: (value) => domain.global.set(value),
|
|
12798
15829
|
close: () => domain.close()
|
|
12799
15830
|
};
|
|
12800
15831
|
}
|
|
15832
|
+
/** `/auth wiki` usage line (input-error card). */
|
|
15833
|
+
const AUTH_WIKI_USAGE = "用法:/auth wiki add <@用户|user_id> 或 /auth wiki remove <@用户|user_id>。知识库白名单里的用户(以及管理员)发起的话题可以使用知识库工具。";
|
|
12801
15834
|
/**
|
|
12802
15835
|
* Resolve one credential reference through the host credentials seam.
|
|
12803
15836
|
*
|
|
@@ -12866,6 +15899,8 @@ var FeishuService = class {
|
|
|
12866
15899
|
attachmentFetchTools;
|
|
12867
15900
|
topicMessagesTools;
|
|
12868
15901
|
sendFileTools;
|
|
15902
|
+
wikiConsent;
|
|
15903
|
+
wikiTools;
|
|
12869
15904
|
/** FIFO admission for ordinary turns and commands targeting one route. */
|
|
12870
15905
|
routeOperations = new RouteOperationQueue();
|
|
12871
15906
|
/** Active Host command executions, aborted before shutdown tears peers down. */
|
|
@@ -12956,6 +15991,7 @@ var FeishuService = class {
|
|
|
12956
15991
|
if (this.effectiveAttachmentBackend() === "native") mounts.push(this.attachmentFetchTools.mount(agentCtx, sessionId));
|
|
12957
15992
|
mounts.push(this.topicMessagesTools.mount(agentCtx, sessionId));
|
|
12958
15993
|
mounts.push(this.sendFileTools.mount(agentCtx, sessionId));
|
|
15994
|
+
mounts.push(this.wikiTools.mount(agentCtx, sessionId));
|
|
12959
15995
|
return Promise.all(mounts).then(() => void 0);
|
|
12960
15996
|
},
|
|
12961
15997
|
warn: (message, ...args) => this.ctx.logger?.warn?.(message, ...args)
|
|
@@ -13010,6 +16046,21 @@ var FeishuService = class {
|
|
|
13010
16046
|
fetchAvailable: () => this.effectiveAttachmentBackend() === "native",
|
|
13011
16047
|
warn: (message, ...args) => this.ctx.logger?.warn?.(message, ...args)
|
|
13012
16048
|
});
|
|
16049
|
+
this.wikiConsent = new WikiConsent({
|
|
16050
|
+
channel: () => this.started ? this.channel : void 0,
|
|
16051
|
+
timeoutMs: () => this.config.approvalTimeoutMs,
|
|
16052
|
+
onError: (sessionId, error) => {
|
|
16053
|
+
this.ctx.logger?.warn?.("dsh-channels wiki consent error (session %s): %s", sessionId, String(error));
|
|
16054
|
+
}
|
|
16055
|
+
});
|
|
16056
|
+
this.wikiTools = new WikiToolRuntime({
|
|
16057
|
+
config: () => this.config,
|
|
16058
|
+
channel: () => this.started ? this.channel : void 0,
|
|
16059
|
+
routeOfSession: (sessionId) => this.approvalRouteFor(sessionId),
|
|
16060
|
+
mayUseWiki: (userId) => this.accessController.isAdmin(userId) || this.accessStore.hasWikiUser(userId),
|
|
16061
|
+
consent: this.wikiConsent,
|
|
16062
|
+
warn: (message, ...args) => this.ctx.logger?.warn?.(message, ...args)
|
|
16063
|
+
});
|
|
13013
16064
|
this.sendFileTools = new SendFileToolRuntime({
|
|
13014
16065
|
config: () => this.config,
|
|
13015
16066
|
channel: () => this.started ? this.channel : void 0,
|
|
@@ -13696,7 +16747,9 @@ var FeishuService = class {
|
|
|
13696
16747
|
requireMention: this.config.requireMention,
|
|
13697
16748
|
ownerLookupFailed: this.config.accessMode === "guarded" && this.configuredAdminUserId() === void 0 && this.appOwner !== void 0 && "error" in this.appOwner && this.appOwner.apiRejected,
|
|
13698
16749
|
slashCommandPanel: this.config.slashCommandPanel,
|
|
13699
|
-
sendFile: this.config.feishuTools.sendFile
|
|
16750
|
+
sendFile: this.config.feishuTools.sendFile,
|
|
16751
|
+
wikiRead: this.config.feishuTools.wikiRead && this.config.wikiRoots.length > 0,
|
|
16752
|
+
wikiWrite: this.config.feishuTools.wikiWrite && this.config.wikiRoots.length > 0
|
|
13700
16753
|
});
|
|
13701
16754
|
state = result.consoleUrl === void 0 ? {
|
|
13702
16755
|
state: "ok",
|
|
@@ -13890,6 +16943,7 @@ var FeishuService = class {
|
|
|
13890
16943
|
accessRequests.onCardAction(event);
|
|
13891
16944
|
answerer.onCardAction(event);
|
|
13892
16945
|
questions.onCardAction(event);
|
|
16946
|
+
this.wikiConsent.onCardAction(event);
|
|
13893
16947
|
this.onStopAction(event);
|
|
13894
16948
|
this.onPickAction(event).catch((error) => {
|
|
13895
16949
|
this.ctx.logger?.warn?.("dsh-channels: picker card action failed: %s", String(error));
|
|
@@ -13968,6 +17022,7 @@ var FeishuService = class {
|
|
|
13968
17022
|
this.approvalAnswerer?.dispose();
|
|
13969
17023
|
this.approvalAnswerer = void 0;
|
|
13970
17024
|
this.questionAnswerer?.dispose();
|
|
17025
|
+
this.wikiConsent.dispose();
|
|
13971
17026
|
this.questionAnswerer = void 0;
|
|
13972
17027
|
if (this.gcTimer !== void 0) {
|
|
13973
17028
|
clearInterval(this.gcTimer);
|
|
@@ -14097,8 +17152,8 @@ var FeishuService = class {
|
|
|
14097
17152
|
`**准入模式**:\`${this.config.accessMode}\``,
|
|
14098
17153
|
`**管理员**:${this.adminStatusLine()}`,
|
|
14099
17154
|
...msg.chatType === "group" ? [`**本群**:${this.accessStore.hasGroup(msg.chatId) ? "已授权" : "未授权"}(\`${msg.chatId}\`)`] : [],
|
|
14100
|
-
`**白名单**:群 ${groups.length} 个 · 用户 ${users.length} 个 · 待处理申请 ${pending.length} 个`,
|
|
14101
|
-
"群授权:`/auth allow` 或 `/auth revoke`(在目标群内发);用户授权:`/auth user add @用户` 或 `/auth user add <user_id
|
|
17155
|
+
`**白名单**:群 ${groups.length} 个 · 用户 ${users.length} 个 · 知识库 ${this.accessStore.wikiUserRecords().length} 个 · 待处理申请 ${pending.length} 个`,
|
|
17156
|
+
"群授权:`/auth allow` 或 `/auth revoke`(在目标群内发);用户授权:`/auth user add @用户` 或 `/auth user add <user_id>`;知识库授权:`/auth wiki add @用户`。"
|
|
14102
17157
|
]), direct);
|
|
14103
17158
|
return;
|
|
14104
17159
|
}
|
|
@@ -14195,17 +17250,68 @@ var FeishuService = class {
|
|
|
14195
17250
|
}, direct);
|
|
14196
17251
|
return;
|
|
14197
17252
|
}
|
|
17253
|
+
if (action === "wiki") {
|
|
17254
|
+
const sub = words[1];
|
|
17255
|
+
const target = sub === "add" || sub === "remove" ? resolveAuthUserTarget(words.slice(2).join(" "), msg.mentions, this.channel?.botOpenId()) : {
|
|
17256
|
+
ok: false,
|
|
17257
|
+
reason: "usage"
|
|
17258
|
+
};
|
|
17259
|
+
if (!target.ok) {
|
|
17260
|
+
await this.delivery.commandCard(msg, {
|
|
17261
|
+
kind: "input-error",
|
|
17262
|
+
command: "auth",
|
|
17263
|
+
text: target.reason === "usage" ? AUTH_WIKI_USAGE : AUTH_USER_TARGET_ERRORS[target.reason]
|
|
17264
|
+
}, direct);
|
|
17265
|
+
return;
|
|
17266
|
+
}
|
|
17267
|
+
const { userId } = target;
|
|
17268
|
+
const label = target.name === void 0 ? `\`${userId}\`` : `${target.name}(\`${userId}\`)`;
|
|
17269
|
+
if (sub === "add") {
|
|
17270
|
+
if (this.accessStore.hasWikiUser(userId)) {
|
|
17271
|
+
await this.delivery.commandCard(msg, {
|
|
17272
|
+
kind: "success",
|
|
17273
|
+
command: "auth",
|
|
17274
|
+
text: `该用户已在知识库白名单中:${label}。`
|
|
17275
|
+
}, direct);
|
|
17276
|
+
return;
|
|
17277
|
+
}
|
|
17278
|
+
await this.accessStore.addWikiUser({
|
|
17279
|
+
userId,
|
|
17280
|
+
...target.name === void 0 ? {} : { name: target.name },
|
|
17281
|
+
source: "command",
|
|
17282
|
+
addedBy: msg.senderId,
|
|
17283
|
+
addedAt: now
|
|
17284
|
+
});
|
|
17285
|
+
await this.delivery.commandCard(msg, {
|
|
17286
|
+
kind: "success",
|
|
17287
|
+
command: "auth",
|
|
17288
|
+
text: `✅ 已把 ${label} 加入知识库白名单:他发起的话题里可以使用知识库工具(每次读取、新建、修改仍需他在授权卡上确认)。`
|
|
17289
|
+
}, direct);
|
|
17290
|
+
return;
|
|
17291
|
+
}
|
|
17292
|
+
const removed = await this.accessStore.removeWikiUser(userId);
|
|
17293
|
+
await this.delivery.commandCard(msg, {
|
|
17294
|
+
kind: removed ? "success" : "unavailable",
|
|
17295
|
+
command: "auth",
|
|
17296
|
+
text: removed ? `已把 ${label} 移出知识库白名单,立即生效。` : `用户 ${label} 不在知识库白名单中。`
|
|
17297
|
+
}, direct);
|
|
17298
|
+
return;
|
|
17299
|
+
}
|
|
14198
17300
|
if (action === "list") {
|
|
14199
17301
|
const groups = this.accessStore.groupRecords();
|
|
14200
17302
|
const users = this.accessStore.userRecords();
|
|
14201
17303
|
const pending = this.accessStore.pendingRequests();
|
|
14202
17304
|
const groupLines = groups.length === 0 ? ["(暂无)"] : groups.slice(0, 30).map((group) => `- ${group.name ?? group.chatId} \`${group.chatId}\``);
|
|
14203
17305
|
const userLines = users.length === 0 ? ["(暂无)"] : users.slice(0, 30).map((user) => `- ${user.name ?? user.userId} \`${user.userId}\``);
|
|
17306
|
+
const wikiUsers = this.accessStore.wikiUserRecords();
|
|
17307
|
+
const wikiLines = wikiUsers.length === 0 ? ["(暂无;管理员始终可用)"] : wikiUsers.slice(0, 30).map((user) => `- ${user.name ?? user.userId} \`${user.userId}\``);
|
|
14204
17308
|
await this.delivery.standaloneReply(msg, standaloneInfoCard("**🔐 访问授权清单**", [
|
|
14205
17309
|
`**已授权群(${groups.length})**`,
|
|
14206
17310
|
...groupLines,
|
|
14207
17311
|
`**已授权用户(${users.length})**`,
|
|
14208
17312
|
...userLines,
|
|
17313
|
+
`**知识库白名单(${wikiUsers.length})**`,
|
|
17314
|
+
...wikiLines,
|
|
14209
17315
|
`**待处理申请**:${pending.length} 个(可在 Web 设置面板处理)`
|
|
14210
17316
|
]), direct);
|
|
14211
17317
|
return;
|
|
@@ -14213,7 +17319,7 @@ var FeishuService = class {
|
|
|
14213
17319
|
await this.delivery.commandCard(msg, {
|
|
14214
17320
|
kind: "input-error",
|
|
14215
17321
|
command: "auth",
|
|
14216
|
-
text: "用法:/auth [status|allow|revoke|list|user add <@用户|user_id>|user remove <@用户|user_id>]"
|
|
17322
|
+
text: "用法:/auth [status|allow|revoke|list|user add <@用户|user_id>|user remove <@用户|user_id>|wiki add <@用户|user_id>|wiki remove <@用户|user_id>]"
|
|
14217
17323
|
}, direct);
|
|
14218
17324
|
} catch (error) {
|
|
14219
17325
|
this.ctx.logger?.warn?.("dsh-channels: /auth failed: %s", String(error));
|
|
@@ -14359,7 +17465,8 @@ var FeishuService = class {
|
|
|
14359
17465
|
/**
|
|
14360
17466
|
* Picker-card click: run the chosen `/<command> <arg>` exactly as if the
|
|
14361
17467
|
* operator had typed it under the picker card, so access admission, route
|
|
14362
|
-
* ownership and validation are the typed command's.
|
|
17468
|
+
* ownership and validation are the typed command's. The result replaces
|
|
17469
|
+
* the picker card in place instead of arriving as a new card. Stale pickers (the
|
|
14363
17470
|
* topic was rebound, or the card was forwarded elsewhere) and non-owner
|
|
14364
17471
|
* clicks are ignored.
|
|
14365
17472
|
*/
|
|
@@ -14371,7 +17478,7 @@ var FeishuService = class {
|
|
|
14371
17478
|
const userId = event.operator.userId;
|
|
14372
17479
|
if (route === void 0 || route.chatId !== event.chatId || userId !== route.senderUserId) return;
|
|
14373
17480
|
const line = `/${action.command} ${action.arg}`;
|
|
14374
|
-
await this.handleSlashMessage({
|
|
17481
|
+
await this.delivery.replacingCard(event.messageId, () => this.handleSlashMessage({
|
|
14375
17482
|
messageId: event.messageId,
|
|
14376
17483
|
chatId: route.chatId,
|
|
14377
17484
|
chatType: route.chatType,
|
|
@@ -14384,7 +17491,7 @@ var FeishuService = class {
|
|
|
14384
17491
|
mentionedBot: true,
|
|
14385
17492
|
...route.kind === "thread" ? { threadId: route.key } : {},
|
|
14386
17493
|
createTime: Date.now()
|
|
14387
|
-
}, line);
|
|
17494
|
+
}, line));
|
|
14388
17495
|
}
|
|
14389
17496
|
/**
|
|
14390
17497
|
* `/permissions`: show this Session's effective sandbox+approval preset and
|
|
@@ -14565,7 +17672,7 @@ var FeishuService = class {
|
|
|
14565
17672
|
return;
|
|
14566
17673
|
}
|
|
14567
17674
|
if (spec.name === "health" && result.kind === "success") {
|
|
14568
|
-
await this.delivery.standaloneReply(msg, buildHealthCard(this.healthSnapshot()));
|
|
17675
|
+
await this.delivery.standaloneReply(msg, buildHealthCard(this.healthSnapshot()), { createThread: false });
|
|
14569
17676
|
return;
|
|
14570
17677
|
}
|
|
14571
17678
|
if (spec.name === "status" && result.kind === "success") {
|
|
@@ -15488,8 +18595,81 @@ const BOOLEAN_KEYS = /* @__PURE__ */ new Set([
|
|
|
15488
18595
|
"prefetchImages",
|
|
15489
18596
|
"feishuTools.attachmentFetch",
|
|
15490
18597
|
"feishuTools.topicMessages",
|
|
15491
|
-
"feishuTools.sendFile"
|
|
18598
|
+
"feishuTools.sendFile",
|
|
18599
|
+
"feishuTools.wikiRead",
|
|
18600
|
+
"feishuTools.wikiWrite"
|
|
15492
18601
|
]);
|
|
18602
|
+
/** Editable keys rendered as `string-list` (one input per entry). */
|
|
18603
|
+
const STRING_LIST_FIELDS = { wikiRoots: {
|
|
18604
|
+
maxItems: 20,
|
|
18605
|
+
itemFormat: "wiki-root-link"
|
|
18606
|
+
} };
|
|
18607
|
+
/** Entry checks per item format: the readable reason, or `undefined` when valid. */
|
|
18608
|
+
const ITEM_FORMAT_CHECKS = { "wiki-root-link": (entry) => {
|
|
18609
|
+
const parsed = parseWikiRootLink(entry);
|
|
18610
|
+
return parsed.ok ? void 0 : WIKI_ROOT_LINK_ERRORS[parsed.error];
|
|
18611
|
+
} };
|
|
18612
|
+
/** `第 N 项「entry」:reason`, shared wording with the Client check. */
|
|
18613
|
+
function listEntryMessage(index, entry, reason) {
|
|
18614
|
+
const position = PANEL_COPY.listEntry.replace("{n}", String(index + 1));
|
|
18615
|
+
return entry === void 0 ? `${position}${reason}` : `${position}「${entry}」:${reason}`;
|
|
18616
|
+
}
|
|
18617
|
+
/**
|
|
18618
|
+
* Normalize one submitted `string-list` value: entries are trimmed, empty
|
|
18619
|
+
* entries dropped, the count bounded by `maxItems`, and every remaining entry
|
|
18620
|
+
* checked against `itemFormat`. Issue indexes refer to the submitted array
|
|
18621
|
+
* (empty inputs included), so the panel can mark the exact input.
|
|
18622
|
+
*
|
|
18623
|
+
* @param key - The editable key the issues are reported under.
|
|
18624
|
+
* @param raw - The untrusted submitted value.
|
|
18625
|
+
* @param spec - The field's bounds and entry format.
|
|
18626
|
+
* @returns The cleaned entries, or every per-entry / whole-field issue.
|
|
18627
|
+
*/
|
|
18628
|
+
function normalizeStringList(key, raw, spec) {
|
|
18629
|
+
if (!Array.isArray(raw)) return {
|
|
18630
|
+
ok: false,
|
|
18631
|
+
issues: [{
|
|
18632
|
+
key,
|
|
18633
|
+
message: PANEL_COPY.listNotArray
|
|
18634
|
+
}]
|
|
18635
|
+
};
|
|
18636
|
+
const issues = [];
|
|
18637
|
+
const value = [];
|
|
18638
|
+
const check = spec.itemFormat === void 0 ? void 0 : ITEM_FORMAT_CHECKS[spec.itemFormat];
|
|
18639
|
+
raw.forEach((item, index) => {
|
|
18640
|
+
if (typeof item !== "string") {
|
|
18641
|
+
issues.push({
|
|
18642
|
+
key,
|
|
18643
|
+
index,
|
|
18644
|
+
message: listEntryMessage(index, void 0, PANEL_COPY.listEntryNotString)
|
|
18645
|
+
});
|
|
18646
|
+
return;
|
|
18647
|
+
}
|
|
18648
|
+
const entry = item.trim();
|
|
18649
|
+
if (entry === "") return;
|
|
18650
|
+
const reason = check?.(entry);
|
|
18651
|
+
if (reason !== void 0) {
|
|
18652
|
+
issues.push({
|
|
18653
|
+
key,
|
|
18654
|
+
index,
|
|
18655
|
+
message: listEntryMessage(index, entry, reason)
|
|
18656
|
+
});
|
|
18657
|
+
return;
|
|
18658
|
+
}
|
|
18659
|
+
value.push(entry);
|
|
18660
|
+
});
|
|
18661
|
+
if (raw.filter((item) => typeof item !== "string" || item.trim() !== "").length > spec.maxItems) issues.push({
|
|
18662
|
+
key,
|
|
18663
|
+
message: PANEL_COPY.listTooMany.replace("{max}", String(spec.maxItems))
|
|
18664
|
+
});
|
|
18665
|
+
return issues.length > 0 ? {
|
|
18666
|
+
ok: false,
|
|
18667
|
+
issues
|
|
18668
|
+
} : {
|
|
18669
|
+
ok: true,
|
|
18670
|
+
value
|
|
18671
|
+
};
|
|
18672
|
+
}
|
|
15493
18673
|
const SELECT_OPTIONS = {
|
|
15494
18674
|
attachmentStore: [{
|
|
15495
18675
|
value: "native",
|
|
@@ -15646,16 +18826,15 @@ const FIELD_META = {
|
|
|
15646
18826
|
},
|
|
15647
18827
|
"feishuTools.attachmentFetchMaxBytes": {
|
|
15648
18828
|
label: "单次取回字节预算(字节)",
|
|
15649
|
-
help: "一次工具调用的总下载字节预算(默认 104857600 = 100 MiB
|
|
18829
|
+
help: "一次工具调用的总下载字节预算(默认 104857600 = 100 MiB,不设上限);文件流式写入原生库,不整文件缓冲,几 GiB 的文件只占磁盘。",
|
|
15650
18830
|
min: 1,
|
|
15651
|
-
max: 1024 * 1024 * 1024,
|
|
15652
18831
|
step: 1
|
|
15653
18832
|
},
|
|
15654
18833
|
"feishuTools.attachmentFetchTimeoutMs": {
|
|
15655
18834
|
label: "单次取回超时(ms)",
|
|
15656
|
-
help: "一次 feishu_attachment_fetch 的整段超时(默认 120000 ms
|
|
18835
|
+
help: "一次 feishu_attachment_fetch 的整段超时(默认 120000 ms,最小 1000,最大 2147483647 ≈ 24.8 天);到期取消下载并逐项报告可读原因。大文件请按带宽同步调大。",
|
|
15657
18836
|
min: 1e3,
|
|
15658
|
-
max:
|
|
18837
|
+
max: MAX_TIMER_DELAY_MS,
|
|
15659
18838
|
step: 1e3
|
|
15660
18839
|
},
|
|
15661
18840
|
"feishuTools.topicMessages": {
|
|
@@ -15673,6 +18852,20 @@ const FIELD_META = {
|
|
|
15673
18852
|
label: "启用 feishu_send_file 工具",
|
|
15674
18853
|
help: "让模型把当前话题工作目录里生成的图片/文件发回当前会话(图片 ≤10 MB,文件 ≤30 MB)。需开通 im:resource 权限。对新会话生效。"
|
|
15675
18854
|
},
|
|
18855
|
+
"feishuTools.wikiRead": {
|
|
18856
|
+
label: "启用知识库读取工具",
|
|
18857
|
+
help: "让模型浏览「知识库范围」内的知识库节点,并读取 docx 文档、电子表格和多维表格(feishu_wiki_list / feishu_wiki_read / feishu_wiki_sheet_read / feishu_wiki_base_read),以应用身份访问。需开通 wiki:wiki、docx:document、sheets:spreadsheet、bitable:app 权限(或对应的只读权限)。只有话题所有者是管理员或在知识库白名单(/auth wiki add @成员)中才能使用;打开文档前会在授权卡上征得话题所有者同意,每个话题一次。知识库范围为空时不挂载。对新会话生效。"
|
|
18858
|
+
},
|
|
18859
|
+
"feishuTools.wikiWrite": {
|
|
18860
|
+
label: "启用知识库写入工具",
|
|
18861
|
+
help: "让模型在「知识库范围」内新建 docx 文档、电子表格和多维表格,编辑本应用拥有的文档和电子表格,增改删本应用拥有的多维表格记录、向其他多维表格新增记录(feishu_wiki_create / feishu_wiki_edit / feishu_wiki_sheet_edit / feishu_wiki_base_edit),以应用身份访问;每次新建或编辑都需话题所有者在审批卡上同意。需开通 wiki:wiki、docx:document、sheets:spreadsheet、bitable:app 权限;使用条件同读取工具(管理员或知识库白名单)。知识库范围为空时不挂载。对新会话生效。"
|
|
18862
|
+
},
|
|
18863
|
+
wikiRoots: {
|
|
18864
|
+
label: "知识库范围(wikiRoots)",
|
|
18865
|
+
help: "知识库工具只能访问这些位置,每个输入框填一个链接:整个知识空间 https://<租户>.feishu.cn/wiki/space/<space_id>,或节点 https://<租户>.feishu.cn/wiki/<node_token>(该节点及其下所有节点);最多 20 项,空输入框保存时忽略。留空 = 不挂载任何知识库工具(即使上面的开关已开启)。对新会话生效。",
|
|
18866
|
+
placeholder: "https://<租户>.feishu.cn/wiki/space/<space_id>",
|
|
18867
|
+
wide: true
|
|
18868
|
+
},
|
|
15676
18869
|
provider: {
|
|
15677
18870
|
label: "模型服务商",
|
|
15678
18871
|
help: "留空 = 使用部署默认;例如 deepseek-official。",
|
|
@@ -15738,6 +18931,9 @@ const GROUP_OF = {
|
|
|
15738
18931
|
"feishuTools.topicMessages": "tools",
|
|
15739
18932
|
"feishuTools.topicMessagesTimeoutMs": "tools",
|
|
15740
18933
|
"feishuTools.sendFile": "tools",
|
|
18934
|
+
"feishuTools.wikiRead": "tools",
|
|
18935
|
+
"feishuTools.wikiWrite": "tools",
|
|
18936
|
+
wikiRoots: "tools",
|
|
15741
18937
|
provider: "model",
|
|
15742
18938
|
model: "model",
|
|
15743
18939
|
reasoningEffort: "model",
|
|
@@ -15750,10 +18946,11 @@ const GROUP_OF = {
|
|
|
15750
18946
|
const FIELD_DESCRIPTORS = EDITABLE_KEYS.map((key) => {
|
|
15751
18947
|
const meta = FIELD_META[key];
|
|
15752
18948
|
const options = SELECT_OPTIONS[key];
|
|
18949
|
+
const list = STRING_LIST_FIELDS[key];
|
|
15753
18950
|
return {
|
|
15754
18951
|
key,
|
|
15755
18952
|
label: meta.label,
|
|
15756
|
-
kind: BOOLEAN_KEYS.has(key) ? "boolean" : options !== void 0 ? "select" : meta.min !== void 0 ? "number" : "text",
|
|
18953
|
+
kind: list !== void 0 ? "string-list" : BOOLEAN_KEYS.has(key) ? "boolean" : options !== void 0 ? "select" : meta.min !== void 0 ? "number" : "text",
|
|
15757
18954
|
group: GROUP_OF[key],
|
|
15758
18955
|
help: meta.help,
|
|
15759
18956
|
...options === void 0 ? {} : { options },
|
|
@@ -15761,6 +18958,8 @@ const FIELD_DESCRIPTORS = EDITABLE_KEYS.map((key) => {
|
|
|
15761
18958
|
...meta.max === void 0 ? {} : { max: meta.max },
|
|
15762
18959
|
...meta.step === void 0 ? {} : { step: meta.step },
|
|
15763
18960
|
...meta.placeholder === void 0 ? {} : { placeholder: meta.placeholder },
|
|
18961
|
+
...list === void 0 ? {} : { maxItems: list.maxItems },
|
|
18962
|
+
...list?.itemFormat === void 0 ? {} : { itemFormat: list.itemFormat },
|
|
15764
18963
|
...meta.wide === void 0 ? {} : { wide: meta.wide },
|
|
15765
18964
|
restartRequired: RESTART_SET.has(key)
|
|
15766
18965
|
};
|
|
@@ -16166,6 +19365,7 @@ var SettingsSurface = class {
|
|
|
16166
19365
|
};
|
|
16167
19366
|
const raw = {};
|
|
16168
19367
|
const clearedKeys = [];
|
|
19368
|
+
const listIssues = [];
|
|
16169
19369
|
for (const key of EDITABLE_KEYS) {
|
|
16170
19370
|
const value = values[key];
|
|
16171
19371
|
if (value === void 0) continue;
|
|
@@ -16173,16 +19373,23 @@ var SettingsSurface = class {
|
|
|
16173
19373
|
clearedKeys.push(key);
|
|
16174
19374
|
continue;
|
|
16175
19375
|
}
|
|
19376
|
+
const list = STRING_LIST_FIELDS[key];
|
|
19377
|
+
if (list !== void 0) {
|
|
19378
|
+
const normalized = normalizeStringList(key, value, list);
|
|
19379
|
+
if (normalized.ok) setOverlayValue(raw, key, normalized.value);
|
|
19380
|
+
else listIssues.push(...normalized.issues);
|
|
19381
|
+
continue;
|
|
19382
|
+
}
|
|
16176
19383
|
setOverlayValue(raw, key, value);
|
|
16177
19384
|
}
|
|
16178
19385
|
const parsed = parseOverlay(raw);
|
|
16179
|
-
if (!parsed.ok) return {
|
|
19386
|
+
if (!parsed.ok || listIssues.length > 0) return {
|
|
16180
19387
|
ok: false,
|
|
16181
19388
|
error: "配置校验失败",
|
|
16182
|
-
issues: parsed.issues.map((issue) => ({
|
|
19389
|
+
issues: [...listIssues, ...parsed.ok ? [] : parsed.issues.map((issue) => ({
|
|
16183
19390
|
key: issue.path,
|
|
16184
19391
|
message: issue.message
|
|
16185
|
-
}))
|
|
19392
|
+
}))]
|
|
16186
19393
|
};
|
|
16187
19394
|
if (parsed.patch.baseDir !== void 0) {
|
|
16188
19395
|
const problem = await prepareWorkspaceBaseDir(parsed.patch.baseDir);
|