@modesto_ma/dsh-channels 0.5.0-beta.1 → 0.5.0-beta.2
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/feishu-onboarding.md +3 -0
- package/lib/client.js +271 -1
- package/lib/client.js.map +1 -1
- package/lib/index.js +2061 -212
- 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 +32 -0
- 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 +23 -0
- package/src/feishu/wiki/api.ts +346 -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/tools.ts +984 -0
- package/src/feishu-channel.ts +10 -0
- package/src/service.ts +87 -7
- package/src/settings/contract.ts +42 -4
- package/src/settings/surface.ts +107 -6
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,26 @@ const SEND_FILE_REQUIREMENT = {
|
|
|
3384
3400
|
level: "optional",
|
|
3385
3401
|
purpose: "上传图片/文件,让模型用 feishu_send_file 把生成的文件发给用户"
|
|
3386
3402
|
};
|
|
3403
|
+
/** Wiki read needs (`feishu_wiki_list` / `feishu_wiki_read`). */
|
|
3404
|
+
const WIKI_READ_REQUIREMENTS = [{
|
|
3405
|
+
anyOf: ["wiki:wiki", "wiki:wiki:readonly"],
|
|
3406
|
+
level: "optional",
|
|
3407
|
+
purpose: "浏览知识库节点(feishu_wiki_list / feishu_wiki_read)"
|
|
3408
|
+
}, {
|
|
3409
|
+
anyOf: ["docx:document", "docx:document:readonly"],
|
|
3410
|
+
level: "optional",
|
|
3411
|
+
purpose: "读取知识库中的文档内容(feishu_wiki_read)"
|
|
3412
|
+
}];
|
|
3413
|
+
/** Wiki write needs (`feishu_wiki_create` / `feishu_wiki_edit`). */
|
|
3414
|
+
const WIKI_WRITE_REQUIREMENTS = [{
|
|
3415
|
+
anyOf: ["wiki:wiki"],
|
|
3416
|
+
level: "optional",
|
|
3417
|
+
purpose: "在知识库中新建节点(feishu_wiki_create)"
|
|
3418
|
+
}, {
|
|
3419
|
+
anyOf: ["docx:document"],
|
|
3420
|
+
level: "optional",
|
|
3421
|
+
purpose: "写入与修改知识库文档内容(feishu_wiki_create / feishu_wiki_edit)"
|
|
3422
|
+
}];
|
|
3387
3423
|
/** Group receive needs while ordinary group messages require an @ mention. */
|
|
3388
3424
|
const MENTION_GROUP_REQUIREMENTS = [{
|
|
3389
3425
|
anyOf: ["im:message.group_at_msg:readonly", "im:message.group_msg"],
|
|
@@ -3444,9 +3480,15 @@ function scopeRequirements(context) {
|
|
|
3444
3480
|
...context.requireMention ? MENTION_GROUP_REQUIREMENTS : [ALL_GROUP_REQUIREMENT],
|
|
3445
3481
|
...BASE_REQUIREMENTS,
|
|
3446
3482
|
...context.sendFile ? [SEND_FILE_REQUIREMENT] : [],
|
|
3483
|
+
...wikiRequirements(context),
|
|
3447
3484
|
...context.slashCommandPanel ? SLASH_COMMAND_REQUIREMENTS : []
|
|
3448
3485
|
];
|
|
3449
3486
|
}
|
|
3487
|
+
/** Wiki needs; the write set subsumes the read set's preferred scopes. */
|
|
3488
|
+
function wikiRequirements(context) {
|
|
3489
|
+
if (context.wikiWrite) return WIKI_WRITE_REQUIREMENTS;
|
|
3490
|
+
return context.wikiRead ? WIKI_READ_REQUIREMENTS : [];
|
|
3491
|
+
}
|
|
3450
3492
|
/**
|
|
3451
3493
|
* Compare `grants` with the applicable requirements. Only tenant (or untyped)
|
|
3452
3494
|
* grants count: the plugin always calls with the app identity.
|
|
@@ -3473,6 +3515,186 @@ function checkScopes(grants, appId, context) {
|
|
|
3473
3515
|
};
|
|
3474
3516
|
}
|
|
3475
3517
|
//#endregion
|
|
3518
|
+
//#region src/feishu/wiki/api.ts
|
|
3519
|
+
/**
|
|
3520
|
+
* Feishu wiki (知识库) and docx OpenAPI calls used by the wiki tools, made
|
|
3521
|
+
* with the app identity (tenant access token).
|
|
3522
|
+
*
|
|
3523
|
+
* Structure calls use `wiki/v2` (`spaces/get_node`, `spaces/:id`,
|
|
3524
|
+
* `spaces/:id/nodes`); document content uses the `docs_ai/v1` document
|
|
3525
|
+
* endpoints, which read and write Markdown or Feishu document XML directly
|
|
3526
|
+
* (block ids are exported only in XML); the revision check uses
|
|
3527
|
+
* `docx/v1/documents/:id`. Every call maps a thrown transport error or a
|
|
3528
|
+
* non-zero Feishu `code` to {@link FeishuApiError}.
|
|
3529
|
+
*
|
|
3530
|
+
* @module dsh-channels/feishu/wiki/api
|
|
3531
|
+
*/
|
|
3532
|
+
const OBJ_TYPES = /* @__PURE__ */ new Set([
|
|
3533
|
+
"doc",
|
|
3534
|
+
"docx",
|
|
3535
|
+
"sheet",
|
|
3536
|
+
"mindnote",
|
|
3537
|
+
"bitable",
|
|
3538
|
+
"file",
|
|
3539
|
+
"slides"
|
|
3540
|
+
]);
|
|
3541
|
+
/** Feishu's per-page maximum for `spaces/:id/nodes`. */
|
|
3542
|
+
const LIST_PAGE_SIZE = 50;
|
|
3543
|
+
function thrownError(error) {
|
|
3544
|
+
if (error instanceof FeishuApiError) return error;
|
|
3545
|
+
const data = error?.response?.data;
|
|
3546
|
+
const code = typeof data?.code === "number" ? data.code : void 0;
|
|
3547
|
+
const msg = typeof data?.msg === "string" ? data.msg : void 0;
|
|
3548
|
+
if (code === void 0 && msg === void 0) return new FeishuApiError(error instanceof Error ? error.message : String(error));
|
|
3549
|
+
return new FeishuApiError([code === void 0 ? void 0 : `code=${code}`, msg].filter(Boolean).join(" "), code);
|
|
3550
|
+
}
|
|
3551
|
+
async function call(request, input) {
|
|
3552
|
+
let response;
|
|
3553
|
+
try {
|
|
3554
|
+
response = await request(input);
|
|
3555
|
+
} catch (error) {
|
|
3556
|
+
throw thrownError(error);
|
|
3557
|
+
}
|
|
3558
|
+
if (response.code !== void 0 && response.code !== 0) throw new FeishuApiError(`code=${response.code}${response.msg === void 0 ? "" : ` ${response.msg}`}`, response.code);
|
|
3559
|
+
if (response.data === void 0) throw new FeishuApiError(`${input.url}: response has no data`);
|
|
3560
|
+
return response.data;
|
|
3561
|
+
}
|
|
3562
|
+
function wikiNode(raw, where) {
|
|
3563
|
+
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`);
|
|
3564
|
+
return {
|
|
3565
|
+
spaceId: raw.space_id,
|
|
3566
|
+
nodeToken: raw.node_token,
|
|
3567
|
+
objToken: raw.obj_token,
|
|
3568
|
+
objType: raw.obj_type,
|
|
3569
|
+
...raw.parent_node_token === void 0 || raw.parent_node_token === "" ? {} : { parentNodeToken: raw.parent_node_token },
|
|
3570
|
+
nodeType: raw.node_type === "shortcut" ? "shortcut" : "origin",
|
|
3571
|
+
title: raw.title ?? "",
|
|
3572
|
+
hasChild: raw.has_child === true,
|
|
3573
|
+
...raw.owner === void 0 || raw.owner === "" ? {} : { owner: raw.owner },
|
|
3574
|
+
...raw.url === void 0 || raw.url === "" ? {} : { url: raw.url }
|
|
3575
|
+
};
|
|
3576
|
+
}
|
|
3577
|
+
function segment(value) {
|
|
3578
|
+
return encodeURIComponent(value);
|
|
3579
|
+
}
|
|
3580
|
+
/**
|
|
3581
|
+
* {@link WikiApi} over the SDK's generic authenticated request.
|
|
3582
|
+
*
|
|
3583
|
+
* @param request `Client.request` of the connected SDK client.
|
|
3584
|
+
* @returns The API.
|
|
3585
|
+
*/
|
|
3586
|
+
function createWikiApi(request) {
|
|
3587
|
+
return {
|
|
3588
|
+
async getNode(token, objType) {
|
|
3589
|
+
const url = "/open-apis/wiki/v2/spaces/get_node";
|
|
3590
|
+
return wikiNode((await call(request, {
|
|
3591
|
+
method: "GET",
|
|
3592
|
+
url,
|
|
3593
|
+
params: {
|
|
3594
|
+
token,
|
|
3595
|
+
obj_type: objType
|
|
3596
|
+
}
|
|
3597
|
+
})).node, url);
|
|
3598
|
+
},
|
|
3599
|
+
async spaceName(spaceId) {
|
|
3600
|
+
return (await call(request, {
|
|
3601
|
+
method: "GET",
|
|
3602
|
+
url: `/open-apis/wiki/v2/spaces/${segment(spaceId)}`
|
|
3603
|
+
})).space?.name ?? "";
|
|
3604
|
+
},
|
|
3605
|
+
async listChildren(spaceId, parentNodeToken, pageToken) {
|
|
3606
|
+
const url = `/open-apis/wiki/v2/spaces/${segment(spaceId)}/nodes`;
|
|
3607
|
+
const data = await call(request, {
|
|
3608
|
+
method: "GET",
|
|
3609
|
+
url,
|
|
3610
|
+
params: {
|
|
3611
|
+
page_size: LIST_PAGE_SIZE,
|
|
3612
|
+
...parentNodeToken === void 0 ? {} : { parent_node_token: parentNodeToken },
|
|
3613
|
+
...pageToken === void 0 ? {} : { page_token: pageToken }
|
|
3614
|
+
}
|
|
3615
|
+
});
|
|
3616
|
+
return {
|
|
3617
|
+
items: (data.items ?? []).map((item) => wikiNode(item, url)),
|
|
3618
|
+
...data.has_more === true && data.page_token !== void 0 && data.page_token !== "" ? { pageToken: data.page_token } : {}
|
|
3619
|
+
};
|
|
3620
|
+
},
|
|
3621
|
+
async createDocxNode(spaceId, parentNodeToken, title) {
|
|
3622
|
+
const url = `/open-apis/wiki/v2/spaces/${segment(spaceId)}/nodes`;
|
|
3623
|
+
return wikiNode((await call(request, {
|
|
3624
|
+
method: "POST",
|
|
3625
|
+
url,
|
|
3626
|
+
data: {
|
|
3627
|
+
obj_type: "docx",
|
|
3628
|
+
node_type: "origin",
|
|
3629
|
+
title,
|
|
3630
|
+
...parentNodeToken === void 0 ? {} : { parent_node_token: parentNodeToken }
|
|
3631
|
+
}
|
|
3632
|
+
})).node, url);
|
|
3633
|
+
},
|
|
3634
|
+
async documentRevision(documentId) {
|
|
3635
|
+
const url = `/open-apis/docx/v1/documents/${segment(documentId)}`;
|
|
3636
|
+
const revision = (await call(request, {
|
|
3637
|
+
method: "GET",
|
|
3638
|
+
url
|
|
3639
|
+
})).document?.revision_id;
|
|
3640
|
+
if (typeof revision !== "number") throw new FeishuApiError(`${url}: response has no revision_id`);
|
|
3641
|
+
return revision;
|
|
3642
|
+
},
|
|
3643
|
+
async fetchDocument(documentId, fetch) {
|
|
3644
|
+
const url = `/open-apis/docs_ai/v1/documents/${segment(documentId)}/fetch`;
|
|
3645
|
+
const ids = fetch.format === "xml" && fetch.withBlockIds;
|
|
3646
|
+
const readOption = fetch.scope === "full" ? void 0 : {
|
|
3647
|
+
read_mode: fetch.scope,
|
|
3648
|
+
...fetch.startBlockId === void 0 ? {} : { start_block_id: fetch.startBlockId },
|
|
3649
|
+
...fetch.endBlockId === void 0 ? {} : { end_block_id: fetch.endBlockId },
|
|
3650
|
+
...fetch.keyword === void 0 ? {} : { keyword: fetch.keyword },
|
|
3651
|
+
...fetch.maxDepth === void 0 ? {} : { max_depth: String(fetch.maxDepth) }
|
|
3652
|
+
};
|
|
3653
|
+
const document = (await call(request, {
|
|
3654
|
+
method: "POST",
|
|
3655
|
+
url,
|
|
3656
|
+
data: {
|
|
3657
|
+
format: fetch.format,
|
|
3658
|
+
export_option: {
|
|
3659
|
+
export_block_id: ids,
|
|
3660
|
+
export_style_attrs: false,
|
|
3661
|
+
export_cite_extra_data: false
|
|
3662
|
+
},
|
|
3663
|
+
...readOption === void 0 ? {} : { read_option: readOption }
|
|
3664
|
+
}
|
|
3665
|
+
})).document;
|
|
3666
|
+
if (document === void 0 || typeof document.content !== "string") throw new FeishuApiError(`${url}: response has no document content`);
|
|
3667
|
+
return {
|
|
3668
|
+
revisionId: document.revision_id ?? 0,
|
|
3669
|
+
content: document.content
|
|
3670
|
+
};
|
|
3671
|
+
},
|
|
3672
|
+
async updateDocument(documentId, update) {
|
|
3673
|
+
const data = await call(request, {
|
|
3674
|
+
method: "PUT",
|
|
3675
|
+
url: `/open-apis/docs_ai/v1/documents/${segment(documentId)}`,
|
|
3676
|
+
data: {
|
|
3677
|
+
format: update.format,
|
|
3678
|
+
command: update.command,
|
|
3679
|
+
...update.content === void 0 ? {} : { content: update.content },
|
|
3680
|
+
...update.pattern === void 0 ? {} : { pattern: update.pattern },
|
|
3681
|
+
...update.blockId === void 0 ? {} : { block_id: update.blockId },
|
|
3682
|
+
...update.startBlockId === void 0 ? {} : { start_block_id: update.startBlockId },
|
|
3683
|
+
...update.endBlockId === void 0 ? {} : { end_block_id: update.endBlockId },
|
|
3684
|
+
...update.revisionId === void 0 ? {} : { revision_id: update.revisionId }
|
|
3685
|
+
}
|
|
3686
|
+
});
|
|
3687
|
+
const warnings = Array.isArray(data.warnings) ? data.warnings.filter((item) => typeof item === "string" && item !== "") : [];
|
|
3688
|
+
return {
|
|
3689
|
+
result: data.result ?? "success",
|
|
3690
|
+
...data.document?.revision_id === void 0 ? {} : { revisionId: data.document.revision_id },
|
|
3691
|
+
...data.updated_blocks_count === void 0 ? {} : { updatedBlocks: data.updated_blocks_count },
|
|
3692
|
+
warnings
|
|
3693
|
+
};
|
|
3694
|
+
}
|
|
3695
|
+
};
|
|
3696
|
+
}
|
|
3697
|
+
//#endregion
|
|
3476
3698
|
//#region src/feishu-channel.ts
|
|
3477
3699
|
/**
|
|
3478
3700
|
* Thin wrapper over the official SDK's `createLarkChannel` high-level module.
|
|
@@ -3665,6 +3887,7 @@ var LarkChannelAdapter = class {
|
|
|
3665
3887
|
requireMention;
|
|
3666
3888
|
dmMode;
|
|
3667
3889
|
slashCommands;
|
|
3890
|
+
wiki;
|
|
3668
3891
|
constructor(options) {
|
|
3669
3892
|
this.channel = createLarkChannel({
|
|
3670
3893
|
appId: options.appId,
|
|
@@ -3683,6 +3906,7 @@ var LarkChannelAdapter = class {
|
|
|
3683
3906
|
this.dmMode = options.dmMode;
|
|
3684
3907
|
const rawClient = this.channel.rawClient;
|
|
3685
3908
|
this.slashCommands = slashCommandApi(async (input) => await rawClient.request(input));
|
|
3909
|
+
this.wiki = createWikiApi(async (input) => await rawClient.request(input));
|
|
3686
3910
|
}
|
|
3687
3911
|
onMessage(handler) {
|
|
3688
3912
|
return this.channel.on("message", (message) => {
|
|
@@ -4830,7 +5054,7 @@ function resolveAttachmentService(ctx) {
|
|
|
4830
5054
|
if (isAttachmentStore(viaGet)) return viaGet;
|
|
4831
5055
|
} catch {}
|
|
4832
5056
|
}
|
|
4833
|
-
const FAILURE_REASONS$
|
|
5057
|
+
const FAILURE_REASONS$2 = {
|
|
4834
5058
|
TOO_MANY_IMAGES: "图片数量超过单条消息上限",
|
|
4835
5059
|
IMAGES_TOO_LARGE: "图片总字节超过单条消息上限",
|
|
4836
5060
|
UNSUPPORTED_IMAGE_TYPE: "不支持的图片格式(仅支持 PNG/JPEG/WebP/GIF)",
|
|
@@ -4878,7 +5102,7 @@ function isAttachmentErrorCode(code) {
|
|
|
4878
5102
|
}
|
|
4879
5103
|
/** User/model-facing wording for one stable failure code. */
|
|
4880
5104
|
function attachmentFailureReason(code) {
|
|
4881
|
-
return FAILURE_REASONS$
|
|
5105
|
+
return FAILURE_REASONS$2[code];
|
|
4882
5106
|
}
|
|
4883
5107
|
/**
|
|
4884
5108
|
* Extract a host attachment failure code from an arbitrary thrown value.
|
|
@@ -4993,7 +5217,7 @@ var NativeAttachmentStore = class {
|
|
|
4993
5217
|
if (mediaType === void 0) return {
|
|
4994
5218
|
ok: false,
|
|
4995
5219
|
code: "UNSUPPORTED_IMAGE_TYPE",
|
|
4996
|
-
reason: FAILURE_REASONS$
|
|
5220
|
+
reason: FAILURE_REASONS$2.UNSUPPORTED_IMAGE_TYPE
|
|
4997
5221
|
};
|
|
4998
5222
|
const limits = mounted.limits;
|
|
4999
5223
|
if (limits !== void 0) try {
|
|
@@ -5007,7 +5231,7 @@ var NativeAttachmentStore = class {
|
|
|
5007
5231
|
if (mediaTypes !== void 0 && mediaTypes.length > 0 && !mediaTypes.includes(mediaType)) return {
|
|
5008
5232
|
ok: false,
|
|
5009
5233
|
code: "UNSUPPORTED_IMAGE_TYPE",
|
|
5010
|
-
reason: FAILURE_REASONS$
|
|
5234
|
+
reason: FAILURE_REASONS$2.UNSUPPORTED_IMAGE_TYPE
|
|
5011
5235
|
};
|
|
5012
5236
|
} catch {}
|
|
5013
5237
|
const name = sanitizeFileName$1(input.name);
|
|
@@ -5048,7 +5272,7 @@ var NativeAttachmentStore = class {
|
|
|
5048
5272
|
if (input.signal?.aborted === true) return {
|
|
5049
5273
|
ok: false,
|
|
5050
5274
|
code: "ABORTED",
|
|
5051
|
-
reason: FAILURE_REASONS$
|
|
5275
|
+
reason: FAILURE_REASONS$2.ABORTED
|
|
5052
5276
|
};
|
|
5053
5277
|
const name = sanitizeFileName$1(input.name) ?? "file.bin";
|
|
5054
5278
|
try {
|
|
@@ -5126,18 +5350,18 @@ var NativeAttachmentStore = class {
|
|
|
5126
5350
|
if (signal?.aborted === true || isAbortError$2(error)) return {
|
|
5127
5351
|
ok: false,
|
|
5128
5352
|
code: "ABORTED",
|
|
5129
|
-
reason: FAILURE_REASONS$
|
|
5353
|
+
reason: FAILURE_REASONS$2.ABORTED
|
|
5130
5354
|
};
|
|
5131
5355
|
const code = attachmentErrorCodeOf(error, service);
|
|
5132
5356
|
if (code !== void 0) return {
|
|
5133
5357
|
ok: false,
|
|
5134
5358
|
code,
|
|
5135
|
-
reason: FAILURE_REASONS$
|
|
5359
|
+
reason: FAILURE_REASONS$2[code]
|
|
5136
5360
|
};
|
|
5137
5361
|
return {
|
|
5138
5362
|
ok: false,
|
|
5139
5363
|
code: "ATTACHMENT_STORE_ERROR",
|
|
5140
|
-
reason: `${FAILURE_REASONS$
|
|
5364
|
+
reason: `${FAILURE_REASONS$2.ATTACHMENT_STORE_ERROR}:${errorMessage(error)}`
|
|
5141
5365
|
};
|
|
5142
5366
|
}
|
|
5143
5367
|
};
|
|
@@ -5162,7 +5386,7 @@ function readPathOf(ref, kind, service) {
|
|
|
5162
5386
|
if (code !== void 0) return {
|
|
5163
5387
|
ok: false,
|
|
5164
5388
|
code,
|
|
5165
|
-
reason: FAILURE_REASONS$
|
|
5389
|
+
reason: FAILURE_REASONS$2[code]
|
|
5166
5390
|
};
|
|
5167
5391
|
return {
|
|
5168
5392
|
ok: false,
|
|
@@ -5361,7 +5585,7 @@ var InboundAttachmentStore = class {
|
|
|
5361
5585
|
if (resource.folder === true) {
|
|
5362
5586
|
failures.push({
|
|
5363
5587
|
name,
|
|
5364
|
-
reason: FAILURE_REASONS$
|
|
5588
|
+
reason: FAILURE_REASONS$2.FOLDER_UNSUPPORTED
|
|
5365
5589
|
});
|
|
5366
5590
|
continue;
|
|
5367
5591
|
}
|
|
@@ -6290,9 +6514,26 @@ var SessionManager = class {
|
|
|
6290
6514
|
const EXPIRED_ROUTE_TEXT = "该话题已超过路由保留时间,并已与原 Session 解绑。为避免误接续上下文,插件不会在此话题创建新 Session;请从私聊或群聊主会话发起新消息(群聊请 @ 机器人)创建新话题。";
|
|
6291
6515
|
var CardDelivery = class {
|
|
6292
6516
|
deps;
|
|
6517
|
+
/** Open `replacingCard` scopes per message id; overlapping clicks nest. */
|
|
6518
|
+
inPlace = /* @__PURE__ */ new Map();
|
|
6293
6519
|
constructor(deps) {
|
|
6294
6520
|
this.deps = deps;
|
|
6295
6521
|
}
|
|
6522
|
+
/**
|
|
6523
|
+
* Run `operation` with every `standaloneReply` anchored to `messageId`
|
|
6524
|
+
* patched into that (bot-sent card) message instead of replying to it.
|
|
6525
|
+
* Used for picker clicks, whose synthetic message id is the picker card.
|
|
6526
|
+
*/
|
|
6527
|
+
async replacingCard(messageId, operation) {
|
|
6528
|
+
this.inPlace.set(messageId, (this.inPlace.get(messageId) ?? 0) + 1);
|
|
6529
|
+
try {
|
|
6530
|
+
await operation();
|
|
6531
|
+
} finally {
|
|
6532
|
+
const remaining = (this.inPlace.get(messageId) ?? 1) - 1;
|
|
6533
|
+
if (remaining === 0) this.inPlace.delete(messageId);
|
|
6534
|
+
else this.inPlace.set(messageId, remaining);
|
|
6535
|
+
}
|
|
6536
|
+
}
|
|
6296
6537
|
/** The live channel; throws instead of silently dropping a delivery. */
|
|
6297
6538
|
requireChannel() {
|
|
6298
6539
|
const channel = this.deps.channel();
|
|
@@ -6326,13 +6567,22 @@ var CardDelivery = class {
|
|
|
6326
6567
|
/**
|
|
6327
6568
|
* Shared delivery policy for command-plane cards: reply in a new topic by
|
|
6328
6569
|
* default, then degrade to a plain reply when Feishu refuses the topic.
|
|
6570
|
+
* `createThread: false` replies in the chat timeline, or inside the topic
|
|
6571
|
+
* the message already belongs to. Inside `replacingCard` the card patches
|
|
6572
|
+
* the anchored card instead, and falls back to a reply if the patch fails.
|
|
6329
6573
|
*/
|
|
6330
6574
|
async standaloneReply(msg, card, options = {}) {
|
|
6331
6575
|
const channel = this.deps.channel();
|
|
6332
6576
|
if (!this.deps.started() || channel === void 0) return;
|
|
6577
|
+
if (this.inPlace.has(msg.messageId)) try {
|
|
6578
|
+
await channel.updateCard(msg.messageId, card);
|
|
6579
|
+
return;
|
|
6580
|
+
} catch (error) {
|
|
6581
|
+
this.deps.warn("dsh-channels: in-place update of card %s failed, replying instead: %s", msg.messageId, String(error));
|
|
6582
|
+
}
|
|
6333
6583
|
const createThread = options.createThread ?? true;
|
|
6334
6584
|
try {
|
|
6335
|
-
await channel.send(msg.chatId, { card }, createThread ? {
|
|
6585
|
+
await channel.send(msg.chatId, { card }, createThread || msg.threadId !== void 0 ? {
|
|
6336
6586
|
replyTo: msg.messageId,
|
|
6337
6587
|
replyInThread: true
|
|
6338
6588
|
} : { replyTo: msg.messageId });
|
|
@@ -6437,7 +6687,7 @@ function messageReadFailureReason(code) {
|
|
|
6437
6687
|
default: return "读取飞书消息失败";
|
|
6438
6688
|
}
|
|
6439
6689
|
}
|
|
6440
|
-
function failure$
|
|
6690
|
+
function failure$3(code, reason = messageReadFailureReason(code)) {
|
|
6441
6691
|
return {
|
|
6442
6692
|
ok: false,
|
|
6443
6693
|
code,
|
|
@@ -6770,7 +7020,10 @@ function messageTextOf(item) {
|
|
|
6770
7020
|
return name === void 0 ? "[文件夹]" : `[文件夹 ${name}]`;
|
|
6771
7021
|
}
|
|
6772
7022
|
case "merge_forward": return "[合并转发]";
|
|
6773
|
-
case "interactive":
|
|
7023
|
+
case "interactive": {
|
|
7024
|
+
const text = content === void 0 ? "" : cardText(content);
|
|
7025
|
+
return text === "" ? "[卡片消息]" : `[卡片] ${text}`;
|
|
7026
|
+
}
|
|
6774
7027
|
case "audio": return "[语音]";
|
|
6775
7028
|
case "video": return "[视频]";
|
|
6776
7029
|
case "sticker": return "[表情]";
|
|
@@ -6778,6 +7031,84 @@ function messageTextOf(item) {
|
|
|
6778
7031
|
default: return item.rawContent === void 0 || item.rawContent === "" ? `[${item.msgType} 消息]` : sanitizeExcerpt(item.rawContent);
|
|
6779
7032
|
}
|
|
6780
7033
|
}
|
|
7034
|
+
/** Deepest card node visited by {@link cardText}; real cards nest far less. */
|
|
7035
|
+
const MAX_CARD_DEPTH = 12;
|
|
7036
|
+
/**
|
|
7037
|
+
* Card keys whose values are never reader-visible text: callback payloads,
|
|
7038
|
+
* link targets, and layout/style settings.
|
|
7039
|
+
*/
|
|
7040
|
+
const CARD_SKIPPED_KEYS = /* @__PURE__ */ new Set([
|
|
7041
|
+
"value",
|
|
7042
|
+
"behaviors",
|
|
7043
|
+
"url",
|
|
7044
|
+
"href",
|
|
7045
|
+
"multi_url",
|
|
7046
|
+
"image_key",
|
|
7047
|
+
"img_key",
|
|
7048
|
+
"icon",
|
|
7049
|
+
"style",
|
|
7050
|
+
"config",
|
|
7051
|
+
"confirm",
|
|
7052
|
+
"i18n_elements",
|
|
7053
|
+
"i18n_header",
|
|
7054
|
+
"user_id",
|
|
7055
|
+
"open_id"
|
|
7056
|
+
]);
|
|
7057
|
+
/**
|
|
7058
|
+
* Flatten one card body into text. Handles both the simplified structure the
|
|
7059
|
+
* message read APIs return by default (`{ title, elements: [[...]] }`) and a
|
|
7060
|
+
* sent card's JSON 1.0/2.0 (`header.title`, `elements` or `body.elements`):
|
|
7061
|
+
* every `text`/`content` string and `plain_text`/`lark_md` object in reading
|
|
7062
|
+
* order, images as `[图片]`. A Card 1.0 localized only through
|
|
7063
|
+
* `i18n_header`/`i18n_elements` renders one locale. Returns `''` when no text
|
|
7064
|
+
* is found.
|
|
7065
|
+
*/
|
|
7066
|
+
function cardText(content) {
|
|
7067
|
+
const parts = [];
|
|
7068
|
+
const title = stringField$1(content, "title");
|
|
7069
|
+
if (title !== void 0) parts.push(title);
|
|
7070
|
+
collectCardText(asRecord$2(content.header) ?? localizedBranch(content.i18n_header), parts, 0);
|
|
7071
|
+
collectCardText(content.elements ?? asRecord$2(content.body)?.elements ?? localizedBranch(content.i18n_elements), parts, 0);
|
|
7072
|
+
return parts.map((part) => part.trim()).filter((part) => part !== "").join(" ");
|
|
7073
|
+
}
|
|
7074
|
+
/**
|
|
7075
|
+
* One locale branch of a Card 1.0 `i18n_*` map: `zh_cn` when present,
|
|
7076
|
+
* otherwise the first branch, so a card localized only through `i18n_*`
|
|
7077
|
+
* still renders once instead of once per locale.
|
|
7078
|
+
*/
|
|
7079
|
+
function localizedBranch(value) {
|
|
7080
|
+
const branches = asRecord$2(value);
|
|
7081
|
+
if (branches === void 0) return void 0;
|
|
7082
|
+
return branches.zh_cn ?? Object.values(branches)[0];
|
|
7083
|
+
}
|
|
7084
|
+
/** Append the reader-visible text of one card node to `parts`. */
|
|
7085
|
+
function collectCardText(node, parts, depth) {
|
|
7086
|
+
if (depth > MAX_CARD_DEPTH) return;
|
|
7087
|
+
if (Array.isArray(node)) {
|
|
7088
|
+
for (const child of node) collectCardText(child, parts, depth + 1);
|
|
7089
|
+
return;
|
|
7090
|
+
}
|
|
7091
|
+
const record = asRecord$2(node);
|
|
7092
|
+
if (record === void 0) return;
|
|
7093
|
+
const tag = stringField$1(record, "tag");
|
|
7094
|
+
if (tag === "img" || tag === "image") {
|
|
7095
|
+
parts.push("[图片]");
|
|
7096
|
+
return;
|
|
7097
|
+
}
|
|
7098
|
+
if (tag === "at") {
|
|
7099
|
+
const name = stringField$1(record, "user_name");
|
|
7100
|
+
parts.push(name === void 0 ? "@" : `@${name}`);
|
|
7101
|
+
return;
|
|
7102
|
+
}
|
|
7103
|
+
for (const [key, value] of Object.entries(record)) {
|
|
7104
|
+
if (CARD_SKIPPED_KEYS.has(key)) continue;
|
|
7105
|
+
if (typeof value === "string") {
|
|
7106
|
+
if (key === "text" || key === "content") parts.push(value);
|
|
7107
|
+
continue;
|
|
7108
|
+
}
|
|
7109
|
+
collectCardText(value, parts, depth + 1);
|
|
7110
|
+
}
|
|
7111
|
+
}
|
|
6781
7112
|
/** Flatten one post body into text; inline images/files become placeholders. */
|
|
6782
7113
|
function postText(content) {
|
|
6783
7114
|
const lines = [];
|
|
@@ -7030,7 +7361,7 @@ var MessageReader = class {
|
|
|
7030
7361
|
* flat container + children list the P0 probe documented.
|
|
7031
7362
|
*/
|
|
7032
7363
|
async readMessage(messageId, options = {}) {
|
|
7033
|
-
if (messageId === "") return failure$
|
|
7364
|
+
if (messageId === "") return failure$3("INVALID_ARGUMENT", "message_id 不能为空");
|
|
7034
7365
|
return await this.readWith((channel) => {
|
|
7035
7366
|
const get = channel.messageGet;
|
|
7036
7367
|
if (get === void 0) throw new Error("当前飞书通道不支持读取消息内容(message.get 不可用)");
|
|
@@ -7043,19 +7374,19 @@ var MessageReader = class {
|
|
|
7043
7374
|
* calls so the same public behavior is available everywhere.
|
|
7044
7375
|
*/
|
|
7045
7376
|
async readMessages(messageIds, options = {}) {
|
|
7046
|
-
if (messageIds.length === 0) return failure$
|
|
7047
|
-
if (messageIds.length > 50) return failure$
|
|
7377
|
+
if (messageIds.length === 0) return failure$3("INVALID_ARGUMENT", "message_ids 不能为空");
|
|
7378
|
+
if (messageIds.length > 50) return failure$3("INVALID_ARGUMENT", `一次最多批量读取 50 条消息(本次 ${messageIds.length} 条)`);
|
|
7048
7379
|
const deduped = [...new Set(messageIds)];
|
|
7049
|
-
if (options.signal?.aborted === true) return failure$
|
|
7380
|
+
if (options.signal?.aborted === true) return failure$3("ABORTED");
|
|
7050
7381
|
const scope = createReadScope(options);
|
|
7051
7382
|
try {
|
|
7052
7383
|
const channel = this.channelOrUndefined();
|
|
7053
|
-
if (channel === void 0) return failure$
|
|
7054
|
-
if (scope.signal.aborted) return failure$
|
|
7384
|
+
if (channel === void 0) return failure$3("CHANNEL_UNAVAILABLE");
|
|
7385
|
+
if (scope.signal.aborted) return failure$3("ABORTED");
|
|
7055
7386
|
if (channel.messageMget !== void 0) {
|
|
7056
7387
|
const response = await cancellable$1(Promise.resolve(channel.messageMget.call(channel, deduped)), scope.signal);
|
|
7057
7388
|
const items = parseMessageItems(response);
|
|
7058
|
-
if (items === void 0) return failureFromResponse(response) ?? failure$
|
|
7389
|
+
if (items === void 0) return failureFromResponse(response) ?? failure$3("MESSAGE_READ_FAILED", "飞书批量读取消息返回了无法识别的结果");
|
|
7059
7390
|
return {
|
|
7060
7391
|
ok: true,
|
|
7061
7392
|
value: items
|
|
@@ -7063,12 +7394,12 @@ var MessageReader = class {
|
|
|
7063
7394
|
}
|
|
7064
7395
|
const items = [];
|
|
7065
7396
|
for (const id of deduped) {
|
|
7066
|
-
if (scope.signal.aborted) return failure$
|
|
7397
|
+
if (scope.signal.aborted) return failure$3("ABORTED");
|
|
7067
7398
|
const get = channel.messageGet;
|
|
7068
|
-
if (get === void 0) return failure$
|
|
7399
|
+
if (get === void 0) return failure$3("MESSAGE_READ_FAILED", "当前飞书通道不支持读取消息内容(message.get 不可用)");
|
|
7069
7400
|
const response = await cancellable$1(Promise.resolve(get.call(channel, id)), scope.signal);
|
|
7070
7401
|
const parsed = parseMessageItems(response);
|
|
7071
|
-
if (parsed === void 0) return failureFromResponse(response) ?? failure$
|
|
7402
|
+
if (parsed === void 0) return failureFromResponse(response) ?? failure$3("MESSAGE_READ_FAILED", "飞书读取消息返回了无法识别的结果");
|
|
7072
7403
|
items.push(...parsed);
|
|
7073
7404
|
}
|
|
7074
7405
|
return {
|
|
@@ -7083,12 +7414,12 @@ var MessageReader = class {
|
|
|
7083
7414
|
}
|
|
7084
7415
|
/** List one topic's messages (`container_id_type=thread`). */
|
|
7085
7416
|
async listThread(threadId, options = {}) {
|
|
7086
|
-
if (threadId === "") return failure$
|
|
7417
|
+
if (threadId === "") return failure$3("INVALID_ARGUMENT", "thread id 不能为空");
|
|
7087
7418
|
return await this.listContainer("thread", threadId, options);
|
|
7088
7419
|
}
|
|
7089
7420
|
/** List one chat's main timeline (`container_id_type=chat`). */
|
|
7090
7421
|
async listChat(chatId, options = {}) {
|
|
7091
|
-
if (chatId === "") return failure$
|
|
7422
|
+
if (chatId === "") return failure$3("INVALID_ARGUMENT", "chat id 不能为空");
|
|
7092
7423
|
return await this.listContainer("chat", chatId, options);
|
|
7093
7424
|
}
|
|
7094
7425
|
/**
|
|
@@ -7129,14 +7460,14 @@ var MessageReader = class {
|
|
|
7129
7460
|
}
|
|
7130
7461
|
/** Shared call/parse/failure plumbing of every wrapper. */
|
|
7131
7462
|
async readWith(operation, parse, options) {
|
|
7132
|
-
if (options.signal?.aborted === true) return failure$
|
|
7463
|
+
if (options.signal?.aborted === true) return failure$3("ABORTED");
|
|
7133
7464
|
const channel = this.channelOrUndefined();
|
|
7134
|
-
if (channel === void 0) return failure$
|
|
7465
|
+
if (channel === void 0) return failure$3("CHANNEL_UNAVAILABLE");
|
|
7135
7466
|
const scope = createReadScope(options);
|
|
7136
7467
|
try {
|
|
7137
7468
|
const response = await cancellable$1(operation(channel), scope.signal);
|
|
7138
7469
|
const parsed = parse(response);
|
|
7139
|
-
if (parsed === void 0) return failureFromResponse(response) ?? failure$
|
|
7470
|
+
if (parsed === void 0) return failureFromResponse(response) ?? failure$3("MESSAGE_READ_FAILED", "飞书消息接口返回了无法识别的结果");
|
|
7140
7471
|
return {
|
|
7141
7472
|
ok: true,
|
|
7142
7473
|
value: parsed
|
|
@@ -7296,8 +7627,8 @@ function failureFromResponse(response) {
|
|
|
7296
7627
|
const msg = stringField$1(root, "msg") ?? stringField$1(body, "msg");
|
|
7297
7628
|
const permission = code === 99991672 || code === 99991679 || code === 99991663 || msg !== void 0 && /permission|scope|forbidden|not authorized|无权限/iu.test(msg);
|
|
7298
7629
|
const detail = sanitizePlainText(`code=${code}${msg === void 0 ? "" : ` ${msg}`}`);
|
|
7299
|
-
if (permission) return failure$
|
|
7300
|
-
return failure$
|
|
7630
|
+
if (permission) return failure$3("PERMISSION_DENIED", `${messageReadFailureReason("PERMISSION_DENIED")}(${detail})`);
|
|
7631
|
+
return failure$3("MESSAGE_READ_FAILED", `${messageReadFailureReason("MESSAGE_READ_FAILED")}:${detail}`);
|
|
7301
7632
|
}
|
|
7302
7633
|
/**
|
|
7303
7634
|
* Map one thrown Feishu/HTTP error into a structured failure. Permission
|
|
@@ -7305,8 +7636,8 @@ function failureFromResponse(response) {
|
|
|
7305
7636
|
* without treating the turn as failed (AC6).
|
|
7306
7637
|
*/
|
|
7307
7638
|
function classifyReadError(error, timedOut = false) {
|
|
7308
|
-
if (timedOut) return failure$
|
|
7309
|
-
if (isAbortError$1(error)) return failure$
|
|
7639
|
+
if (timedOut) return failure$3("TIMEOUT", messageReadFailureReason("TIMEOUT"));
|
|
7640
|
+
if (isAbortError$1(error)) return failure$3("ABORTED", messageReadFailureReason("ABORTED"));
|
|
7310
7641
|
const record = asRecord$2(error);
|
|
7311
7642
|
const response = asRecord$2(record?.response);
|
|
7312
7643
|
const responseData = asRecord$2(response?.data);
|
|
@@ -7316,8 +7647,8 @@ function classifyReadError(error, timedOut = false) {
|
|
|
7316
7647
|
const message = apiMessage ?? (error instanceof Error ? error.message : void 0);
|
|
7317
7648
|
const permission = status === 403 || status === 401 || code === 99991672 || code === 99991679 || code === 99991663 || message !== void 0 && /permission|scope|forbidden|not authorized|无权限/iu.test(message);
|
|
7318
7649
|
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$
|
|
7650
|
+
if (permission) return failure$3("PERMISSION_DENIED", `${messageReadFailureReason("PERMISSION_DENIED")}(${detail})`);
|
|
7651
|
+
return failure$3("MESSAGE_READ_FAILED", `${messageReadFailureReason("MESSAGE_READ_FAILED")}:${detail}`);
|
|
7321
7652
|
}
|
|
7322
7653
|
//#endregion
|
|
7323
7654
|
//#region src/feishu/tools.ts
|
|
@@ -7460,7 +7791,7 @@ function attachmentFetchFailureReason(code) {
|
|
|
7460
7791
|
const host = attachmentFailureReason(code);
|
|
7461
7792
|
return typeof host === "string" && host.length > 0 ? host : FETCH_FAILURE_REASONS.INTERNAL_ERROR ?? "附件获取失败";
|
|
7462
7793
|
}
|
|
7463
|
-
function failure$
|
|
7794
|
+
function failure$2(code, reason) {
|
|
7464
7795
|
return {
|
|
7465
7796
|
ok: false,
|
|
7466
7797
|
code,
|
|
@@ -7692,9 +8023,9 @@ var AttachmentFetchToolRuntime = class {
|
|
|
7692
8023
|
}
|
|
7693
8024
|
const args = parsed.data;
|
|
7694
8025
|
const messageId = args.message_id;
|
|
7695
|
-
if (!this.enabled()) return callFailure(messageId, failure$
|
|
8026
|
+
if (!this.enabled()) return callFailure(messageId, failure$2("TOOL_DISABLED"));
|
|
7696
8027
|
const route = this.deps.routeOfSession(sessionId);
|
|
7697
|
-
if (route === void 0) return callFailure(messageId, failure$
|
|
8028
|
+
if (route === void 0) return callFailure(messageId, failure$2("ROUTE_NOT_FOUND"));
|
|
7698
8029
|
const config = this.deps.config().feishuTools;
|
|
7699
8030
|
const scope = createAbortScope(exec.signal, config.attachmentFetchTimeoutMs);
|
|
7700
8031
|
const budget = {
|
|
@@ -7716,8 +8047,8 @@ var AttachmentFetchToolRuntime = class {
|
|
|
7716
8047
|
try {
|
|
7717
8048
|
return await this.fetch(call);
|
|
7718
8049
|
} catch (error) {
|
|
7719
|
-
if (budget.exceeded) return callFailure(messageId, failure$
|
|
7720
|
-
if (scope.signal.aborted) return callFailure(messageId, failure$
|
|
8050
|
+
if (budget.exceeded) return callFailure(messageId, failure$2("BYTES_LIMIT"));
|
|
8051
|
+
if (scope.signal.aborted) return callFailure(messageId, failure$2(scope.timeout.value ? "TIMEOUT" : "ABORTED"));
|
|
7721
8052
|
const reason = `${attachmentFetchFailureReason("INTERNAL_ERROR")}:${describeUnexpectedError(error)}`;
|
|
7722
8053
|
this.deps.warn("dsh-channels: %s failed unexpectedly: %s", ATTACHMENT_FETCH_TOOL_NAME, reason);
|
|
7723
8054
|
return callFailure(messageId, {
|
|
@@ -7731,11 +8062,11 @@ var AttachmentFetchToolRuntime = class {
|
|
|
7731
8062
|
}
|
|
7732
8063
|
async fetch(call) {
|
|
7733
8064
|
const messageId = call.args.message_id;
|
|
7734
|
-
if (call.signal.aborted) return callFailure(messageId, failure$
|
|
8065
|
+
if (call.signal.aborted) return callFailure(messageId, failure$2(call.timeout.value ? "TIMEOUT" : "ABORTED"));
|
|
7735
8066
|
const known = await this.resolveKnownMessage(call, messageId);
|
|
7736
8067
|
if (known === void 0) {
|
|
7737
|
-
if (call.signal.aborted) return callFailure(messageId, failure$
|
|
7738
|
-
return callFailure(messageId, failure$
|
|
8068
|
+
if (call.signal.aborted) return callFailure(messageId, failure$2(call.timeout.value ? "TIMEOUT" : "ABORTED"));
|
|
8069
|
+
return callFailure(messageId, failure$2("AUTHORIZATION_DENIED"));
|
|
7739
8070
|
}
|
|
7740
8071
|
let resources = [...known.resources];
|
|
7741
8072
|
if (resources.length === 0) {
|
|
@@ -7752,11 +8083,11 @@ var AttachmentFetchToolRuntime = class {
|
|
|
7752
8083
|
const shown = knownKeys.slice(0, 10);
|
|
7753
8084
|
const hidden = knownKeys.length - shown.length;
|
|
7754
8085
|
const available = knownKeys.length === 0 ? "" : `(可用:${shown.join("、")}${hidden > 0 ? ` 等 ${knownKeys.length} 个` : ""})`;
|
|
7755
|
-
return callFailure(messageId, failure$
|
|
8086
|
+
return callFailure(messageId, failure$2("UNKNOWN_FILE_KEY", `${attachmentFetchFailureReason("UNKNOWN_FILE_KEY")}:${displayText$1(key)}${available}`));
|
|
7756
8087
|
}
|
|
7757
8088
|
}
|
|
7758
|
-
if (resources.length === 0) return callFailure(messageId, failure$
|
|
7759
|
-
if (resources.length > call.limits.maxResources) return callFailure(messageId, failure$
|
|
8089
|
+
if (resources.length === 0) return callFailure(messageId, failure$2("NO_RESOURCES"));
|
|
8090
|
+
if (resources.length > call.limits.maxResources) return callFailure(messageId, failure$2("RESOURCE_LIMIT", `${attachmentFetchFailureReason("RESOURCE_LIMIT")}(本消息 ${resources.length} 个,单次上限 ${call.limits.maxResources} 个;请用 file_key 分批获取)`));
|
|
7760
8091
|
this.rememberKnownMessage(call, known, allResources);
|
|
7761
8092
|
const results = [];
|
|
7762
8093
|
for (let index = 0; index < resources.length; index += 1) {
|
|
@@ -7768,12 +8099,12 @@ var AttachmentFetchToolRuntime = class {
|
|
|
7768
8099
|
break;
|
|
7769
8100
|
}
|
|
7770
8101
|
if (call.budget.exceeded) {
|
|
7771
|
-
results.push(failedItem(resource, failure$
|
|
7772
|
-
for (const rest of resources.slice(index + 1)) results.push(failedItem(rest, failure$
|
|
8102
|
+
results.push(failedItem(resource, failure$2("BYTES_LIMIT")));
|
|
8103
|
+
for (const rest of resources.slice(index + 1)) results.push(failedItem(rest, failure$2("BYTES_LIMIT")));
|
|
7773
8104
|
break;
|
|
7774
8105
|
}
|
|
7775
8106
|
if (call.channel === void 0) {
|
|
7776
|
-
results.push(failedItem(resource, failure$
|
|
8107
|
+
results.push(failedItem(resource, failure$2("CHANNEL_UNAVAILABLE")));
|
|
7777
8108
|
continue;
|
|
7778
8109
|
}
|
|
7779
8110
|
results.push(await this.fetchResource(call, known, resource));
|
|
@@ -7825,18 +8156,18 @@ var AttachmentFetchToolRuntime = class {
|
|
|
7825
8156
|
*/
|
|
7826
8157
|
async expandMessageResources(call, message) {
|
|
7827
8158
|
const channel = call.channel;
|
|
7828
|
-
if (channel === void 0) return failure$
|
|
8159
|
+
if (channel === void 0) return failure$2("CHANNEL_UNAVAILABLE");
|
|
7829
8160
|
const messageGet = channel.messageGet;
|
|
7830
|
-
if (messageGet === void 0) return failure$
|
|
8161
|
+
if (messageGet === void 0) return failure$2("MESSAGE_READ_FAILED", `${attachmentFetchFailureReason("MESSAGE_READ_FAILED")}:当前飞书通道不支持读取消息内容,无法展开附件`);
|
|
7831
8162
|
let response;
|
|
7832
8163
|
try {
|
|
7833
8164
|
response = await cancellable(Promise.resolve(messageGet.call(channel, message.messageId)), call.signal);
|
|
7834
8165
|
} catch (error) {
|
|
7835
8166
|
if (isAbortError(error)) throw error;
|
|
7836
|
-
return failure$
|
|
8167
|
+
return failure$2("MESSAGE_READ_FAILED", `${attachmentFetchFailureReason("MESSAGE_READ_FAILED")}:${describeFetchError(error)}`);
|
|
7837
8168
|
}
|
|
7838
8169
|
const items = rawMessageItems(response);
|
|
7839
|
-
if (items === void 0) return failure$
|
|
8170
|
+
if (items === void 0) return failure$2("MESSAGE_READ_FAILED", `${attachmentFetchFailureReason("MESSAGE_READ_FAILED")}:返回内容无法识别`);
|
|
7840
8171
|
return {
|
|
7841
8172
|
ok: true,
|
|
7842
8173
|
resources: message.kind === "merge_forward" ? mergedForwardResources(items) : messageResourcesOfItems(items)
|
|
@@ -7844,17 +8175,17 @@ var AttachmentFetchToolRuntime = class {
|
|
|
7844
8175
|
}
|
|
7845
8176
|
async fetchResource(call, message, resource) {
|
|
7846
8177
|
const downloadId = resource.containerMessageId ?? message.messageId;
|
|
7847
|
-
if (resource.folder === true) return failedItem(resource, failure$
|
|
8178
|
+
if (resource.folder === true) return failedItem(resource, failure$2("FOLDER_UNSUPPORTED"));
|
|
7848
8179
|
if (resource.type === "image") return await this.fetchImage(call, downloadId, resource);
|
|
7849
8180
|
return await this.fetchFile(call, downloadId, resource);
|
|
7850
8181
|
}
|
|
7851
8182
|
async fetchImage(call, downloadId, resource) {
|
|
7852
8183
|
const channel = call.channel;
|
|
7853
|
-
if (channel === void 0) return failedItem(resource, failure$
|
|
8184
|
+
if (channel === void 0) return failedItem(resource, failure$2("CHANNEL_UNAVAILABLE"));
|
|
7854
8185
|
const remaining = call.budget.maxBytes - call.budget.usedBytes;
|
|
7855
8186
|
if (remaining <= 0) {
|
|
7856
8187
|
call.budget.exceeded = true;
|
|
7857
|
-
return failedItem(resource, failure$
|
|
8188
|
+
return failedItem(resource, failure$2("BYTES_LIMIT"));
|
|
7858
8189
|
}
|
|
7859
8190
|
const imageCap = call.imageCap;
|
|
7860
8191
|
const limit = Math.min(imageCap, remaining);
|
|
@@ -7870,9 +8201,9 @@ var AttachmentFetchToolRuntime = class {
|
|
|
7870
8201
|
} catch (error) {
|
|
7871
8202
|
if (error instanceof BudgetExceededError) {
|
|
7872
8203
|
call.budget.exceeded = true;
|
|
7873
|
-
return failedItem(resource, failure$
|
|
8204
|
+
return failedItem(resource, failure$2("BYTES_LIMIT"));
|
|
7874
8205
|
}
|
|
7875
|
-
if (error instanceof ImageTooLargeError) return failedItem(resource, failure$
|
|
8206
|
+
if (error instanceof ImageTooLargeError) return failedItem(resource, failure$2("IMAGE_TOO_LARGE"));
|
|
7876
8207
|
return downloadFailureItem(resource, error, call);
|
|
7877
8208
|
}
|
|
7878
8209
|
let saved;
|
|
@@ -7902,10 +8233,10 @@ var AttachmentFetchToolRuntime = class {
|
|
|
7902
8233
|
}
|
|
7903
8234
|
async fetchFile(call, downloadId, resource) {
|
|
7904
8235
|
const channel = call.channel;
|
|
7905
|
-
if (channel === void 0) return failedItem(resource, failure$
|
|
8236
|
+
if (channel === void 0) return failedItem(resource, failure$2("CHANNEL_UNAVAILABLE"));
|
|
7906
8237
|
if (call.budget.maxBytes <= call.budget.usedBytes) {
|
|
7907
8238
|
call.budget.exceeded = true;
|
|
7908
|
-
return failedItem(resource, failure$
|
|
8239
|
+
return failedItem(resource, failure$2("BYTES_LIMIT"));
|
|
7909
8240
|
}
|
|
7910
8241
|
let stream;
|
|
7911
8242
|
try {
|
|
@@ -7922,11 +8253,11 @@ var AttachmentFetchToolRuntime = class {
|
|
|
7922
8253
|
...resource.fileName === void 0 ? {} : { name: resource.fileName }
|
|
7923
8254
|
}), call.signal);
|
|
7924
8255
|
} catch (error) {
|
|
7925
|
-
if (call.budget.exceeded) return failedItem(resource, failure$
|
|
8256
|
+
if (call.budget.exceeded) return failedItem(resource, failure$2("BYTES_LIMIT"));
|
|
7926
8257
|
return downloadFailureItem(resource, error, call);
|
|
7927
8258
|
}
|
|
7928
8259
|
if (!saved.ok) {
|
|
7929
|
-
if (call.budget.exceeded) return failedItem(resource, failure$
|
|
8260
|
+
if (call.budget.exceeded) return failedItem(resource, failure$2("BYTES_LIMIT"));
|
|
7930
8261
|
if (call.signal.aborted) return cancelledItem(resource, call.timeout.value);
|
|
7931
8262
|
return failedItem(resource, saved);
|
|
7932
8263
|
}
|
|
@@ -7943,9 +8274,9 @@ var AttachmentFetchToolRuntime = class {
|
|
|
7943
8274
|
};
|
|
7944
8275
|
/** Map one thrown download/store error into a structured item. */
|
|
7945
8276
|
function downloadFailureItem(resource, error, call) {
|
|
7946
|
-
if (call.budget.exceeded) return failedItem(resource, failure$
|
|
8277
|
+
if (call.budget.exceeded) return failedItem(resource, failure$2("BYTES_LIMIT"));
|
|
7947
8278
|
if (call.signal.aborted) return cancelledItem(resource, call.timeout.value);
|
|
7948
|
-
return failedItem(resource, failure$
|
|
8279
|
+
return failedItem(resource, failure$2("DOWNLOAD_FAILED", `${attachmentFetchFailureReason("DOWNLOAD_FAILED")}:${describeFetchError(error)}`));
|
|
7949
8280
|
}
|
|
7950
8281
|
/**
|
|
7951
8282
|
* Run one promise with cooperative cancellation: an aborted signal rejects the
|
|
@@ -8076,7 +8407,7 @@ function failedItem(resource, result) {
|
|
|
8076
8407
|
};
|
|
8077
8408
|
}
|
|
8078
8409
|
function cancelledItem(resource, timedOut) {
|
|
8079
|
-
return failedItem(resource, failure$
|
|
8410
|
+
return failedItem(resource, failure$2(timedOut ? "TIMEOUT" : "ABORTED"));
|
|
8080
8411
|
}
|
|
8081
8412
|
function callFailure(messageId, result) {
|
|
8082
8413
|
return {
|
|
@@ -9006,7 +9337,7 @@ const SEND_FILE_PARAMETERS = {
|
|
|
9006
9337
|
required: ["path"],
|
|
9007
9338
|
additionalProperties: false
|
|
9008
9339
|
};
|
|
9009
|
-
const FAILURE_REASONS = {
|
|
9340
|
+
const FAILURE_REASONS$1 = {
|
|
9010
9341
|
TOOL_DISABLED: "feishu_send_file 已在插件配置中关闭",
|
|
9011
9342
|
ROUTE_NOT_FOUND: "当前会话没有绑定飞书话题或会话,无法确定发送目标",
|
|
9012
9343
|
CHANNEL_UNAVAILABLE: "飞书连接当前不可用,请稍后重试",
|
|
@@ -9099,7 +9430,7 @@ var SendFileToolRuntime = class {
|
|
|
9099
9430
|
try {
|
|
9100
9431
|
tools.register(this.definitionFor(sessionId));
|
|
9101
9432
|
} catch (error) {
|
|
9102
|
-
this.warnMountFailure(`dsh-channels: registering ${SEND_FILE_TOOL_NAME} failed: ${describeError(error)}`);
|
|
9433
|
+
this.warnMountFailure(`dsh-channels: registering ${SEND_FILE_TOOL_NAME} failed: ${describeError$1(error)}`);
|
|
9103
9434
|
}
|
|
9104
9435
|
}
|
|
9105
9436
|
warnMountFailure(message) {
|
|
@@ -9130,14 +9461,14 @@ var SendFileToolRuntime = class {
|
|
|
9130
9461
|
throw new Error(`${SEND_FILE_TOOL_NAME}: invalid ${where}: ${issue?.message ?? "invalid arguments"}`);
|
|
9131
9462
|
}
|
|
9132
9463
|
const args = parsed.data;
|
|
9133
|
-
if (!this.enabled()) return failure("TOOL_DISABLED");
|
|
9464
|
+
if (!this.enabled()) return failure$1("TOOL_DISABLED");
|
|
9134
9465
|
const target = this.deps.targetOfSession(sessionId);
|
|
9135
|
-
if (target === void 0) return failure("ROUTE_NOT_FOUND");
|
|
9466
|
+
if (target === void 0) return failure$1("ROUTE_NOT_FOUND");
|
|
9136
9467
|
const loaded = await loadWorkspaceFile(target.rootDir, args);
|
|
9137
9468
|
if (!loaded.ok) return loaded;
|
|
9138
|
-
if (exec.signal.aborted) return failure("ABORTED");
|
|
9469
|
+
if (exec.signal.aborted) return failure$1("ABORTED");
|
|
9139
9470
|
const channel = this.deps.channel();
|
|
9140
|
-
if (channel === void 0) return failure("CHANNEL_UNAVAILABLE");
|
|
9471
|
+
if (channel === void 0) return failure$1("CHANNEL_UNAVAILABLE");
|
|
9141
9472
|
const { kind, fileName, data } = loaded;
|
|
9142
9473
|
const media = kind === "image" ? {
|
|
9143
9474
|
kind,
|
|
@@ -9161,14 +9492,14 @@ var SendFileToolRuntime = class {
|
|
|
9161
9492
|
};
|
|
9162
9493
|
} catch (error) {
|
|
9163
9494
|
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");
|
|
9495
|
+
sent?.catch((late) => this.deps.warn("dsh-channels: %s failed after abort: %s", SEND_FILE_TOOL_NAME, describeError$1(late)));
|
|
9496
|
+
return failure$1("ABORTED");
|
|
9166
9497
|
}
|
|
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");
|
|
9498
|
+
const detail = describeError$1(error);
|
|
9499
|
+
if (isPermissionError(error)) return failure$1("PERMISSION_DENIED", `${FAILURE_REASONS$1.PERMISSION_DENIED}(${detail})`);
|
|
9500
|
+
if (error instanceof FeishuApiError && error.code !== void 0 && REPLY_TARGET_GONE_CODES.has(error.code)) return failure$1("REPLY_TARGET_GONE");
|
|
9170
9501
|
this.deps.warn("dsh-channels: %s failed: %s", SEND_FILE_TOOL_NAME, detail);
|
|
9171
|
-
return failure("SEND_FAILED", `${FAILURE_REASONS.SEND_FAILED}:${detail}`);
|
|
9502
|
+
return failure$1("SEND_FAILED", `${FAILURE_REASONS$1.SEND_FAILED}:${detail}`);
|
|
9172
9503
|
}
|
|
9173
9504
|
}
|
|
9174
9505
|
};
|
|
@@ -9184,41 +9515,41 @@ async function loadWorkspaceFile(rootDir, args) {
|
|
|
9184
9515
|
try {
|
|
9185
9516
|
root = await realpath(rootDir);
|
|
9186
9517
|
} catch (error) {
|
|
9187
|
-
return failure("FILE_NOT_FOUND", `当前话题工作目录不可用:${describeError(error)}`);
|
|
9518
|
+
return failure$1("FILE_NOT_FOUND", `当前话题工作目录不可用:${describeError$1(error)}`);
|
|
9188
9519
|
}
|
|
9189
9520
|
let real;
|
|
9190
9521
|
try {
|
|
9191
9522
|
real = await realpath(path.resolve(root, args.path));
|
|
9192
9523
|
} catch {
|
|
9193
|
-
return isInside(root, path.resolve(root, args.path)) ? failure("FILE_NOT_FOUND") : failure("PATH_OUTSIDE_WORKSPACE");
|
|
9524
|
+
return isInside(root, path.resolve(root, args.path)) ? failure$1("FILE_NOT_FOUND") : failure$1("PATH_OUTSIDE_WORKSPACE");
|
|
9194
9525
|
}
|
|
9195
|
-
if (!isInside(root, real)) return failure("PATH_OUTSIDE_WORKSPACE");
|
|
9526
|
+
if (!isInside(root, real)) return failure$1("PATH_OUTSIDE_WORKSPACE");
|
|
9196
9527
|
const requested = args.kind ?? "auto";
|
|
9197
9528
|
const ceiling = requested === "image" ? FEISHU_IMAGE_MAX_BYTES : FEISHU_FILE_MAX_BYTES;
|
|
9198
9529
|
let handle;
|
|
9199
9530
|
try {
|
|
9200
9531
|
handle = await open(real, constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0));
|
|
9201
9532
|
} catch (error) {
|
|
9202
|
-
return failure("FILE_NOT_FOUND", `${FAILURE_REASONS.FILE_NOT_FOUND}:${describeError(error)}`);
|
|
9533
|
+
return failure$1("FILE_NOT_FOUND", `${FAILURE_REASONS$1.FILE_NOT_FOUND}:${describeError$1(error)}`);
|
|
9203
9534
|
}
|
|
9204
9535
|
let data;
|
|
9205
9536
|
try {
|
|
9206
9537
|
const info = await handle.stat();
|
|
9207
|
-
if (!info.isFile()) return failure("NOT_A_FILE");
|
|
9538
|
+
if (!info.isFile()) return failure$1("NOT_A_FILE");
|
|
9208
9539
|
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");
|
|
9540
|
+
if (recheck === void 0 || recheck.dev !== info.dev || recheck.ino !== info.ino) return failure$1("PATH_OUTSIDE_WORKSPACE");
|
|
9541
|
+
if (info.size === 0) return failure$1("EMPTY_FILE");
|
|
9211
9542
|
if (info.size > ceiling) return tooLarge(requested === "image" ? "image" : "file", info.size);
|
|
9212
9543
|
data = await handle.readFile();
|
|
9213
9544
|
} catch (error) {
|
|
9214
|
-
return failure("FILE_NOT_FOUND", `${FAILURE_REASONS.FILE_NOT_FOUND}:${describeError(error)}`);
|
|
9545
|
+
return failure$1("FILE_NOT_FOUND", `${FAILURE_REASONS$1.FILE_NOT_FOUND}:${describeError$1(error)}`);
|
|
9215
9546
|
} finally {
|
|
9216
9547
|
await handle.close().catch(() => void 0);
|
|
9217
9548
|
}
|
|
9218
|
-
if (data.length === 0) return failure("EMPTY_FILE");
|
|
9549
|
+
if (data.length === 0) return failure$1("EMPTY_FILE");
|
|
9219
9550
|
if (data.length > ceiling) return tooLarge(requested === "image" ? "image" : "file", data.length);
|
|
9220
9551
|
const isImage = isFeishuImage(data);
|
|
9221
|
-
if (requested === "image" && !isImage) return failure("NOT_AN_IMAGE");
|
|
9552
|
+
if (requested === "image" && !isImage) return failure$1("NOT_AN_IMAGE");
|
|
9222
9553
|
return {
|
|
9223
9554
|
ok: true,
|
|
9224
9555
|
kind: requested === "file" ? "file" : requested === "image" ? "image" : isImage && data.length <= 10485760 ? "image" : "file",
|
|
@@ -9248,19 +9579,19 @@ function sanitizeFileName(value) {
|
|
|
9248
9579
|
function tooLarge(kind, bytes) {
|
|
9249
9580
|
const limit = kind === "image" ? FEISHU_IMAGE_MAX_BYTES : FEISHU_FILE_MAX_BYTES;
|
|
9250
9581
|
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}`);
|
|
9582
|
+
return failure$1("FILE_TOO_LARGE", `${FAILURE_REASONS$1.FILE_TOO_LARGE}:${formatBytes(bytes)} > ${kind === "image" ? "图片" : "文件"}上限 ${formatBytes(limit)}${hint}`);
|
|
9252
9583
|
}
|
|
9253
|
-
function failure(code, reason) {
|
|
9584
|
+
function failure$1(code, reason) {
|
|
9254
9585
|
return {
|
|
9255
9586
|
ok: false,
|
|
9256
9587
|
code,
|
|
9257
|
-
reason: reason ?? FAILURE_REASONS[code]
|
|
9588
|
+
reason: reason ?? FAILURE_REASONS$1[code]
|
|
9258
9589
|
};
|
|
9259
9590
|
}
|
|
9260
9591
|
/** Feishu codes for a recalled/deleted reply target (the SDK's `target_revoked`). */
|
|
9261
9592
|
const REPLY_TARGET_GONE_CODES = /* @__PURE__ */ new Set([230020, 230017]);
|
|
9262
9593
|
/** Feishu codes for a missing app scope. */
|
|
9263
|
-
const PERMISSION_CODES = /* @__PURE__ */ new Set([
|
|
9594
|
+
const PERMISSION_CODES$1 = /* @__PURE__ */ new Set([
|
|
9264
9595
|
99991672,
|
|
9265
9596
|
99991679,
|
|
9266
9597
|
230027
|
|
@@ -9295,13 +9626,13 @@ function feishuBody(error) {
|
|
|
9295
9626
|
function isPermissionError(error) {
|
|
9296
9627
|
return errorChain(error).some((item) => {
|
|
9297
9628
|
const body = feishuBody(item);
|
|
9298
|
-
if (body?.code !== void 0 && PERMISSION_CODES.has(body.code)) return true;
|
|
9629
|
+
if (body?.code !== void 0 && PERMISSION_CODES$1.has(body.code)) return true;
|
|
9299
9630
|
const text = `${body?.msg ?? ""} ${item instanceof Error ? item.message : ""}`;
|
|
9300
9631
|
return /status code 403|access denied|scope|permission/iu.test(text);
|
|
9301
9632
|
});
|
|
9302
9633
|
}
|
|
9303
9634
|
/** Readable, bounded text for a thrown value and its Feishu cause. */
|
|
9304
|
-
function describeError(error) {
|
|
9635
|
+
function describeError$1(error) {
|
|
9305
9636
|
const parts = [];
|
|
9306
9637
|
for (const item of errorChain(error)) {
|
|
9307
9638
|
const body = feishuBody(item);
|
|
@@ -9370,102 +9701,1393 @@ function presentSendFileResult(value) {
|
|
|
9370
9701
|
return `发送失败(${value.code}):${value.reason}`;
|
|
9371
9702
|
}
|
|
9372
9703
|
//#endregion
|
|
9373
|
-
//#region src/
|
|
9704
|
+
//#region src/cards/wiki.ts
|
|
9374
9705
|
/**
|
|
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.
|
|
9706
|
+
* Wiki (知识库) authorization cards: the topic owner's consent before the
|
|
9707
|
+
* model opens, creates, or edits a wiki document.
|
|
9381
9708
|
*
|
|
9382
|
-
* @module dsh-channels/
|
|
9709
|
+
* @module dsh-channels/cards/wiki
|
|
9383
9710
|
*/
|
|
9384
|
-
/**
|
|
9385
|
-
const
|
|
9386
|
-
|
|
9387
|
-
|
|
9388
|
-
|
|
9711
|
+
/** Cap on one rendered detail block so the card stays readable on a phone. */
|
|
9712
|
+
const DETAIL_MAX_CHARS = 800;
|
|
9713
|
+
const ACTION_LABELS = {
|
|
9714
|
+
read: "读取文档",
|
|
9715
|
+
create: "新建文档",
|
|
9716
|
+
edit: "修改文档"
|
|
9717
|
+
};
|
|
9718
|
+
const SETTLED_LINES = {
|
|
9719
|
+
allowed: "✅ 已允许",
|
|
9720
|
+
denied: "❌ 已拒绝",
|
|
9721
|
+
expired: "⏱ 已超时未处理,自动拒绝",
|
|
9722
|
+
cancelled: "↩️ 请求已取消"
|
|
9389
9723
|
};
|
|
9724
|
+
/** Clip one value to `max` characters, marking the cut. */
|
|
9725
|
+
function clip(value, max) {
|
|
9726
|
+
return value.length <= max ? value : `${value.slice(0, max)}…(已截断 ${value.length - max} 个字符)`;
|
|
9727
|
+
}
|
|
9728
|
+
/** One line of Markdown text with link/markup characters neutralized. */
|
|
9729
|
+
function plain(value) {
|
|
9730
|
+
return value.replace(/[\u0000-\u001f\u007f]+/gu, " ").replace(/([[\]()*_`~<>])/gu, "\\$1").trim();
|
|
9731
|
+
}
|
|
9732
|
+
/** The question summary shared by the pending and settled variants. */
|
|
9733
|
+
function questionElements(question) {
|
|
9734
|
+
const title = question.title === "" ? "(无标题)" : plain(question.title);
|
|
9735
|
+
const titleLine = question.url === void 0 || question.action === "create" ? title : `[${title}](${question.url})`;
|
|
9736
|
+
const elements = [markdown([
|
|
9737
|
+
`机器人请求以应用身份**${ACTION_LABELS[question.action]}**。`,
|
|
9738
|
+
`**文档**:${titleLine}`,
|
|
9739
|
+
`**位置**:${plain(question.location)}`
|
|
9740
|
+
].join("\n"))];
|
|
9741
|
+
for (const detail of question.details) elements.push(markdown(clip(detail, DETAIL_MAX_CHARS)));
|
|
9742
|
+
return elements;
|
|
9743
|
+
}
|
|
9744
|
+
function consentButton(question, decision) {
|
|
9745
|
+
return textButton({
|
|
9746
|
+
label: decision === "allow" ? "✅ 允许" : "❌ 拒绝",
|
|
9747
|
+
type: decision === "allow" ? "primary" : "danger",
|
|
9748
|
+
width: "fill",
|
|
9749
|
+
value: {
|
|
9750
|
+
decision,
|
|
9751
|
+
token: question.token
|
|
9752
|
+
}
|
|
9753
|
+
});
|
|
9754
|
+
}
|
|
9390
9755
|
/**
|
|
9391
|
-
*
|
|
9392
|
-
*
|
|
9393
|
-
*
|
|
9756
|
+
* The pending consent card, sent as a reply in the session's topic.
|
|
9757
|
+
*
|
|
9758
|
+
* @param question The consent asked for.
|
|
9759
|
+
* @param options Rendering options.
|
|
9760
|
+
* @returns Card JSON 2.0.
|
|
9394
9761
|
*/
|
|
9395
|
-
function
|
|
9396
|
-
|
|
9397
|
-
|
|
9398
|
-
|
|
9399
|
-
|
|
9400
|
-
|
|
9401
|
-
}
|
|
9402
|
-
|
|
9403
|
-
|
|
9404
|
-
|
|
9405
|
-
|
|
9406
|
-
role: "user",
|
|
9407
|
-
content: [{
|
|
9408
|
-
type: "text",
|
|
9409
|
-
text
|
|
9410
|
-
}],
|
|
9411
|
-
source: { kind: "user" }
|
|
9412
|
-
};
|
|
9762
|
+
function buildWikiConsentCard(question, options = {}) {
|
|
9763
|
+
const minutes = Math.max(1, Math.round(question.timeoutMs / 6e4));
|
|
9764
|
+
const scope = question.action === "read" ? "允许后,本话题内再次读取这篇文档不再询问" : "仅对这一次操作生效";
|
|
9765
|
+
const elements = [
|
|
9766
|
+
...questionElements(question),
|
|
9767
|
+
divider(),
|
|
9768
|
+
note(`⏱ ${minutes} 分钟内有效,超时自动拒绝 · ${scope}`)
|
|
9769
|
+
];
|
|
9770
|
+
if (options.unauthorizedNote === true) elements.push(note("ℹ️ 仅发起该话题的用户可操作此卡片"));
|
|
9771
|
+
elements.push(columnSet([consentButton(question, "allow"), consentButton(question, "deny")]));
|
|
9772
|
+
return standaloneCard("**📚 知识库授权**", elements);
|
|
9413
9773
|
}
|
|
9414
9774
|
/**
|
|
9415
|
-
*
|
|
9775
|
+
* The terminal replacement after the consent settles; no controls.
|
|
9416
9776
|
*
|
|
9417
|
-
*
|
|
9418
|
-
*
|
|
9419
|
-
*
|
|
9420
|
-
* containers carry no direct resources (the tool expands them through the
|
|
9421
|
-
* container id, per the P0 rule).
|
|
9777
|
+
* @param question The consent that was asked for.
|
|
9778
|
+
* @param state How it settled.
|
|
9779
|
+
* @returns Card JSON 2.0.
|
|
9422
9780
|
*/
|
|
9423
|
-
function
|
|
9424
|
-
|
|
9425
|
-
|
|
9426
|
-
|
|
9427
|
-
|
|
9428
|
-
|
|
9429
|
-
...resource.folder === true ? { folder: true } : {}
|
|
9430
|
-
}));
|
|
9431
|
-
return {
|
|
9432
|
-
messageId: msg.messageId,
|
|
9433
|
-
kind: forwarded ? "merge_forward" : "message",
|
|
9434
|
-
resources,
|
|
9435
|
-
senderUserId: msg.senderId
|
|
9436
|
-
};
|
|
9781
|
+
function buildWikiConsentSettledCard(question, state) {
|
|
9782
|
+
return standaloneCard(`**${SETTLED_LINES[state]}**`, [
|
|
9783
|
+
...questionElements(question),
|
|
9784
|
+
divider(),
|
|
9785
|
+
note(`📚 知识库${ACTION_LABELS[question.action]}`)
|
|
9786
|
+
]);
|
|
9437
9787
|
}
|
|
9438
|
-
|
|
9439
|
-
|
|
9440
|
-
|
|
9788
|
+
//#endregion
|
|
9789
|
+
//#region src/feishu/wiki/consent.ts
|
|
9790
|
+
/**
|
|
9791
|
+
* Topic-owner consent for wiki tool calls.
|
|
9792
|
+
*
|
|
9793
|
+
* A wiki tool that opens, creates, or edits a document asks the topic owner
|
|
9794
|
+
* on a consent card in the topic and waits for the answer. Only the topic
|
|
9795
|
+
* owner (`route.senderUserId`) can decide; a click by anyone else adds the
|
|
9796
|
+
* owner-only notice once. An unanswered card is denied after the configured
|
|
9797
|
+
* window (`approvalTimeoutMs`). A read consent is remembered per Session and
|
|
9798
|
+
* document, in memory only: it ends with the Session (`/renew`) or the
|
|
9799
|
+
* process. Create and edit consents are never remembered.
|
|
9800
|
+
*
|
|
9801
|
+
* @module dsh-channels/feishu/wiki/consent
|
|
9802
|
+
*/
|
|
9803
|
+
/** Consent cards for the wiki tools. */
|
|
9804
|
+
var WikiConsent = class {
|
|
9441
9805
|
deps;
|
|
9806
|
+
/** One table per channel instance (cards are patched through their channel). */
|
|
9807
|
+
table;
|
|
9808
|
+
/** sessionId → document ids with a standing read consent. */
|
|
9809
|
+
readGrants = /* @__PURE__ */ new Map();
|
|
9810
|
+
constructor(deps) {
|
|
9811
|
+
this.deps = deps;
|
|
9812
|
+
}
|
|
9442
9813
|
/**
|
|
9443
|
-
*
|
|
9444
|
-
*
|
|
9445
|
-
*
|
|
9814
|
+
* Whether the Session already holds a read consent for one document.
|
|
9815
|
+
* @param sessionId The Session.
|
|
9816
|
+
* @param documentId Docx document id.
|
|
9817
|
+
* @returns True when reading needs no new card.
|
|
9446
9818
|
*/
|
|
9447
|
-
|
|
9819
|
+
hasReadGrant(sessionId, documentId) {
|
|
9820
|
+
return this.readGrants.get(sessionId)?.has(documentId) === true;
|
|
9821
|
+
}
|
|
9448
9822
|
/**
|
|
9449
|
-
*
|
|
9450
|
-
*
|
|
9451
|
-
*
|
|
9452
|
-
*
|
|
9453
|
-
* their own — never racing each other into duplicate sessions, route
|
|
9454
|
-
* overwrites, or the "no route, no mention" drop window.
|
|
9823
|
+
* Record a standing read consent (after an allowed read, or for a document
|
|
9824
|
+
* the topic owner just approved creating).
|
|
9825
|
+
* @param sessionId The Session.
|
|
9826
|
+
* @param documentId Docx document id.
|
|
9455
9827
|
*/
|
|
9456
|
-
|
|
9828
|
+
grantRead(sessionId, documentId) {
|
|
9829
|
+
let grants = this.readGrants.get(sessionId);
|
|
9830
|
+
if (grants === void 0) {
|
|
9831
|
+
grants = /* @__PURE__ */ new Set();
|
|
9832
|
+
this.readGrants.set(sessionId, grants);
|
|
9833
|
+
}
|
|
9834
|
+
grants.add(documentId);
|
|
9835
|
+
}
|
|
9457
9836
|
/**
|
|
9458
|
-
*
|
|
9459
|
-
*
|
|
9460
|
-
*
|
|
9461
|
-
* racing it, otherwise the topic's turns reach the model out of order.
|
|
9837
|
+
* Ask the topic owner and wait for the decision. Never rejects.
|
|
9838
|
+
* @param request What to ask.
|
|
9839
|
+
* @returns The outcome; `unavailable` when no card could be delivered.
|
|
9462
9840
|
*/
|
|
9463
|
-
|
|
9464
|
-
|
|
9465
|
-
|
|
9466
|
-
|
|
9467
|
-
|
|
9468
|
-
|
|
9841
|
+
request(request) {
|
|
9842
|
+
if (request.signal.aborted) return Promise.resolve("cancelled");
|
|
9843
|
+
const channel = this.deps.channel();
|
|
9844
|
+
if (channel === void 0) return Promise.resolve("unavailable");
|
|
9845
|
+
const table = this.tableFor(channel);
|
|
9846
|
+
const timeoutMs = this.deps.timeoutMs();
|
|
9847
|
+
const question = {
|
|
9848
|
+
token: randomUUID(),
|
|
9849
|
+
action: request.action,
|
|
9850
|
+
title: request.title,
|
|
9851
|
+
...request.url === void 0 ? {} : { url: request.url },
|
|
9852
|
+
location: request.location,
|
|
9853
|
+
details: request.details,
|
|
9854
|
+
timeoutMs
|
|
9855
|
+
};
|
|
9856
|
+
return new Promise((resolve) => {
|
|
9857
|
+
const entry = {
|
|
9858
|
+
token: question.token,
|
|
9859
|
+
sessionId: request.sessionId,
|
|
9860
|
+
route: request.route,
|
|
9861
|
+
messageId: void 0,
|
|
9862
|
+
settled: false,
|
|
9863
|
+
timer: void 0,
|
|
9864
|
+
signal: request.signal,
|
|
9865
|
+
onAbort: void 0,
|
|
9866
|
+
unauthorizedNoted: false,
|
|
9867
|
+
question,
|
|
9868
|
+
resolve
|
|
9869
|
+
};
|
|
9870
|
+
const onAbort = () => this.settle(table, entry, "cancelled", "cancelled");
|
|
9871
|
+
entry.onAbort = onAbort;
|
|
9872
|
+
request.signal.addEventListener("abort", onAbort, { once: true });
|
|
9873
|
+
table.add(entry);
|
|
9874
|
+
table.deliver(entry, {
|
|
9875
|
+
card: buildWikiConsentCard(question),
|
|
9876
|
+
onWithdrawn: (e) => table.patch(e, buildWikiConsentSettledCard(e.question, "cancelled")),
|
|
9877
|
+
onDelivered: (e) => table.armTimeout(e, timeoutMs, () => this.settle(table, e, "expired", "expired")),
|
|
9878
|
+
onFailure: (e) => this.settle(table, e, "unavailable")
|
|
9879
|
+
});
|
|
9880
|
+
});
|
|
9881
|
+
}
|
|
9882
|
+
/**
|
|
9883
|
+
* Route one card button callback to its pending consent; unknown cards are
|
|
9884
|
+
* ignored.
|
|
9885
|
+
* @param event The card action.
|
|
9886
|
+
*/
|
|
9887
|
+
onCardAction(event) {
|
|
9888
|
+
const table = this.table?.entries;
|
|
9889
|
+
if (table === void 0) return;
|
|
9890
|
+
const entry = table.resolveAction(event, (candidate) => {
|
|
9891
|
+
if (candidate.unauthorizedNoted) return;
|
|
9892
|
+
candidate.unauthorizedNoted = true;
|
|
9893
|
+
table.patch(candidate, buildWikiConsentCard(candidate.question, { unauthorizedNote: true }));
|
|
9894
|
+
});
|
|
9895
|
+
if (entry === void 0) return;
|
|
9896
|
+
const decision = event.action.value?.decision;
|
|
9897
|
+
if (decision === "allow") this.settle(table, entry, "allowed", "allowed");
|
|
9898
|
+
else if (decision === "deny") this.settle(table, entry, "denied", "denied");
|
|
9899
|
+
}
|
|
9900
|
+
/** Withdraw every pending card and forget every read consent (shutdown). */
|
|
9901
|
+
dispose() {
|
|
9902
|
+
const table = this.table?.entries;
|
|
9903
|
+
table?.withdrawAll((entry) => this.settle(table, entry, "cancelled", "cancelled"));
|
|
9904
|
+
this.table = void 0;
|
|
9905
|
+
this.readGrants.clear();
|
|
9906
|
+
}
|
|
9907
|
+
/** Pending consent count (diagnostics/tests). */
|
|
9908
|
+
pendingCount() {
|
|
9909
|
+
return this.table?.entries.count() ?? 0;
|
|
9910
|
+
}
|
|
9911
|
+
tableFor(channel) {
|
|
9912
|
+
if (this.table?.channel !== channel) this.table = {
|
|
9913
|
+
channel,
|
|
9914
|
+
entries: new PendingCardTable(channel, this.deps.onError)
|
|
9915
|
+
};
|
|
9916
|
+
return this.table.entries;
|
|
9917
|
+
}
|
|
9918
|
+
settle(table, entry, outcome, cardState) {
|
|
9919
|
+
if (table.close(entry, () => {
|
|
9920
|
+
if (cardState !== void 0) table.patch(entry, buildWikiConsentSettledCard(entry.question, cardState));
|
|
9921
|
+
})) entry.resolve(outcome);
|
|
9922
|
+
}
|
|
9923
|
+
};
|
|
9924
|
+
//#endregion
|
|
9925
|
+
//#region src/feishu/wiki/links.ts
|
|
9926
|
+
/**
|
|
9927
|
+
* Feishu wiki (知识库) link parsing: the `wikiRoots` allow-list entries and
|
|
9928
|
+
* the document references the model passes to the wiki tools.
|
|
9929
|
+
*
|
|
9930
|
+
* Pure module: no I/O. Hosts are not checked because tenants use their own
|
|
9931
|
+
* subdomains (`<tenant>.feishu.cn`) and the API never uses the link host.
|
|
9932
|
+
*
|
|
9933
|
+
* @module dsh-channels/feishu/wiki/links
|
|
9934
|
+
*/
|
|
9935
|
+
/** Feishu wiki space id: decimal digits. */
|
|
9936
|
+
const SPACE_ID_PATTERN = /^\d{1,32}$/u;
|
|
9937
|
+
/** Feishu wiki node / docx token: letters and digits. */
|
|
9938
|
+
const TOKEN_PATTERN = /^[A-Za-z0-9]{8,64}$/u;
|
|
9939
|
+
/** Readable reasons for {@link WikiRootLinkError}. */
|
|
9940
|
+
const WIKI_ROOT_LINK_ERRORS = {
|
|
9941
|
+
"not-a-url": "不是有效的链接",
|
|
9942
|
+
"not-https": "必须是 https 链接",
|
|
9943
|
+
"not-a-wiki-link": "只支持知识空间链接 https://<租户>.feishu.cn/wiki/space/<space_id> 或节点链接 https://<租户>.feishu.cn/wiki/<node_token>"
|
|
9944
|
+
};
|
|
9945
|
+
/**
|
|
9946
|
+
* Parse one `wikiRoots` entry.
|
|
9947
|
+
*
|
|
9948
|
+
* @param link Link as configured (surrounding whitespace is ignored).
|
|
9949
|
+
* @returns The root, or the reason it is invalid.
|
|
9950
|
+
*/
|
|
9951
|
+
function parseWikiRootLink(link) {
|
|
9952
|
+
const trimmed = link.trim();
|
|
9953
|
+
let url;
|
|
9954
|
+
try {
|
|
9955
|
+
url = new URL(trimmed);
|
|
9956
|
+
} catch {
|
|
9957
|
+
return {
|
|
9958
|
+
ok: false,
|
|
9959
|
+
error: "not-a-url"
|
|
9960
|
+
};
|
|
9961
|
+
}
|
|
9962
|
+
if (url.protocol !== "https:") return {
|
|
9963
|
+
ok: false,
|
|
9964
|
+
error: "not-https"
|
|
9965
|
+
};
|
|
9966
|
+
const segments = url.pathname.split("/").filter((segment) => segment !== "");
|
|
9967
|
+
if (segments[0] !== "wiki") return {
|
|
9968
|
+
ok: false,
|
|
9969
|
+
error: "not-a-wiki-link"
|
|
9970
|
+
};
|
|
9971
|
+
if (segments.length === 3 && segments[1] === "space" && SPACE_ID_PATTERN.test(segments[2])) return {
|
|
9972
|
+
ok: true,
|
|
9973
|
+
root: {
|
|
9974
|
+
kind: "space",
|
|
9975
|
+
spaceId: segments[2],
|
|
9976
|
+
link: trimmed
|
|
9977
|
+
}
|
|
9978
|
+
};
|
|
9979
|
+
if (segments.length === 2 && segments[1] !== "space" && TOKEN_PATTERN.test(segments[1])) return {
|
|
9980
|
+
ok: true,
|
|
9981
|
+
root: {
|
|
9982
|
+
kind: "node",
|
|
9983
|
+
nodeToken: segments[1],
|
|
9984
|
+
link: trimmed
|
|
9985
|
+
}
|
|
9986
|
+
};
|
|
9987
|
+
return {
|
|
9988
|
+
ok: false,
|
|
9989
|
+
error: "not-a-wiki-link"
|
|
9990
|
+
};
|
|
9991
|
+
}
|
|
9992
|
+
/**
|
|
9993
|
+
* Parse every configured root. Callers validate the config first, so an
|
|
9994
|
+
* invalid entry here is a programming error.
|
|
9995
|
+
*
|
|
9996
|
+
* @param links Validated `wikiRoots` entries.
|
|
9997
|
+
* @returns The parsed roots, in configuration order.
|
|
9998
|
+
*/
|
|
9999
|
+
function parseWikiRoots(links) {
|
|
10000
|
+
return links.map((link) => {
|
|
10001
|
+
const parsed = parseWikiRootLink(link);
|
|
10002
|
+
if (!parsed.ok) throw new Error(`dsh-channels: invalid wikiRoots entry ${link}: ${WIKI_ROOT_LINK_ERRORS[parsed.error]}`);
|
|
10003
|
+
return parsed.root;
|
|
10004
|
+
});
|
|
10005
|
+
}
|
|
10006
|
+
/**
|
|
10007
|
+
* Parse a model-supplied reference: a wiki node link, a docx link, a space
|
|
10008
|
+
* link, or a bare node token.
|
|
10009
|
+
*
|
|
10010
|
+
* @param input Raw tool argument.
|
|
10011
|
+
* @returns The reference, or `undefined` when it names nothing recognizable.
|
|
10012
|
+
*/
|
|
10013
|
+
function parseWikiRef(input) {
|
|
10014
|
+
const trimmed = input.trim();
|
|
10015
|
+
if (TOKEN_PATTERN.test(trimmed)) return {
|
|
10016
|
+
kind: "node",
|
|
10017
|
+
token: trimmed
|
|
10018
|
+
};
|
|
10019
|
+
let url;
|
|
10020
|
+
try {
|
|
10021
|
+
url = new URL(trimmed);
|
|
10022
|
+
} catch {
|
|
10023
|
+
return;
|
|
10024
|
+
}
|
|
10025
|
+
if (url.protocol !== "https:") return void 0;
|
|
10026
|
+
const segments = url.pathname.split("/").filter((segment) => segment !== "");
|
|
10027
|
+
if (segments[0] === "wiki" && segments.length === 3 && segments[1] === "space" && SPACE_ID_PATTERN.test(segments[2])) return {
|
|
10028
|
+
kind: "space",
|
|
10029
|
+
spaceId: segments[2]
|
|
10030
|
+
};
|
|
10031
|
+
if (segments.length === 2 && TOKEN_PATTERN.test(segments[1])) {
|
|
10032
|
+
if (segments[0] === "wiki" && segments[1] !== "space") return {
|
|
10033
|
+
kind: "node",
|
|
10034
|
+
token: segments[1]
|
|
10035
|
+
};
|
|
10036
|
+
if (segments[0] === "docx") return {
|
|
10037
|
+
kind: "docx",
|
|
10038
|
+
token: segments[1]
|
|
10039
|
+
};
|
|
10040
|
+
}
|
|
10041
|
+
}
|
|
10042
|
+
//#endregion
|
|
10043
|
+
//#region src/feishu/wiki/scope.ts
|
|
10044
|
+
/**
|
|
10045
|
+
* Longest parent chain walked for a node root. Feishu limits wiki depth well
|
|
10046
|
+
* below this; a longer chain is treated as outside.
|
|
10047
|
+
*/
|
|
10048
|
+
const MAX_PARENT_DEPTH = 64;
|
|
10049
|
+
/**
|
|
10050
|
+
* Find the configured root containing `node`.
|
|
10051
|
+
*
|
|
10052
|
+
* @param api Wiki endpoints (for parent lookups).
|
|
10053
|
+
* @param roots Configured roots.
|
|
10054
|
+
* @param node The node to check.
|
|
10055
|
+
* @returns The containing root, or `undefined` when none contains it.
|
|
10056
|
+
*/
|
|
10057
|
+
async function containingRoot(api, roots, node) {
|
|
10058
|
+
for (const root of roots) if (root.kind === "space" && root.spaceId === node.spaceId) return { root };
|
|
10059
|
+
const nodeRoots = roots.filter((root) => root.kind === "node");
|
|
10060
|
+
if (nodeRoots.length === 0) return void 0;
|
|
10061
|
+
const byToken = new Map(nodeRoots.map((root) => [root.nodeToken, root]));
|
|
10062
|
+
let current = node;
|
|
10063
|
+
for (let depth = 0; depth <= MAX_PARENT_DEPTH; depth += 1) {
|
|
10064
|
+
const match = byToken.get(current.nodeToken);
|
|
10065
|
+
if (match !== void 0) return { root: match };
|
|
10066
|
+
if (current.parentNodeToken === void 0) return void 0;
|
|
10067
|
+
current = await api.getNode(current.parentNodeToken, "wiki");
|
|
10068
|
+
}
|
|
10069
|
+
}
|
|
10070
|
+
/**
|
|
10071
|
+
* Whether a whole space (its top level) is inside the roots: only a space
|
|
10072
|
+
* root covers a space's top level.
|
|
10073
|
+
*
|
|
10074
|
+
* @param roots Configured roots.
|
|
10075
|
+
* @param spaceId The space.
|
|
10076
|
+
* @returns True when a space root names it.
|
|
10077
|
+
*/
|
|
10078
|
+
function spaceIsRoot(roots, spaceId) {
|
|
10079
|
+
return roots.some((root) => root.kind === "space" && root.spaceId === spaceId);
|
|
10080
|
+
}
|
|
10081
|
+
//#endregion
|
|
10082
|
+
//#region src/feishu/wiki/tools.ts
|
|
10083
|
+
/**
|
|
10084
|
+
* Model-facing wiki (知识库) tools: `feishu_wiki_list`, `feishu_wiki_read`
|
|
10085
|
+
* (switch `feishuTools.wikiRead`), `feishu_wiki_create`, and
|
|
10086
|
+
* `feishu_wiki_edit` (switch `feishuTools.wikiWrite`).
|
|
10087
|
+
*
|
|
10088
|
+
* Every call acts with the app identity and is gated, in order, by:
|
|
10089
|
+
* - the switch and a non-empty `wikiRoots` (otherwise nothing is mounted);
|
|
10090
|
+
* - the topic owner being the administrator or on the wiki whitelist
|
|
10091
|
+
* (`/auth wiki add`), checked per call so revocation applies at once;
|
|
10092
|
+
* - `wikiRoots` containment of the node (see `scope.ts`);
|
|
10093
|
+
* - docx only for reading and editing;
|
|
10094
|
+
* - edits only on documents the app owns (wiki node `owner` equals the bot
|
|
10095
|
+
* open_id) whose current revision equals the `revision_id` the model read;
|
|
10096
|
+
* - the topic owner's consent card for opening a document (remembered per
|
|
10097
|
+
* Session and document), for every create, and for every edit.
|
|
10098
|
+
*
|
|
10099
|
+
* Browsing (`feishu_wiki_list`) needs no card. Every failure is a structured
|
|
10100
|
+
* value with a stable code and a readable reason.
|
|
10101
|
+
*
|
|
10102
|
+
* @module dsh-channels/feishu/wiki/tools
|
|
10103
|
+
*/
|
|
10104
|
+
/** Model-facing tool names; stable across releases (session history references them). */
|
|
10105
|
+
const WIKI_LIST_TOOL_NAME = "feishu_wiki_list";
|
|
10106
|
+
const WIKI_READ_TOOL_NAME = "feishu_wiki_read";
|
|
10107
|
+
const WIKI_CREATE_TOOL_NAME = "feishu_wiki_create";
|
|
10108
|
+
const WIKI_EDIT_TOOL_NAME = "feishu_wiki_edit";
|
|
10109
|
+
/** Longest accepted document title (characters). */
|
|
10110
|
+
const MAX_TITLE_CHARS = 200;
|
|
10111
|
+
/** Longest content one create/edit operation may carry (characters). */
|
|
10112
|
+
const MAX_CONTENT_CHARS = 2e5;
|
|
10113
|
+
/** Characters of each content shown on a consent card. */
|
|
10114
|
+
const PREVIEW_CHARS = 300;
|
|
10115
|
+
const refSchema = z.string().trim().min(1).max(2048);
|
|
10116
|
+
const blockIdSchema = z.string().trim().min(1).max(128);
|
|
10117
|
+
const contentSchema = z.string().max(MAX_CONTENT_CHARS);
|
|
10118
|
+
const formatSchema = z.enum(["markdown", "xml"]);
|
|
10119
|
+
const wikiListArgsSchema = z.object({
|
|
10120
|
+
parent: refSchema.optional(),
|
|
10121
|
+
page_token: z.string().trim().min(1).max(512).optional()
|
|
10122
|
+
}).strict();
|
|
10123
|
+
const wikiReadArgsSchema = z.object({
|
|
10124
|
+
doc: refSchema,
|
|
10125
|
+
format: formatSchema.optional(),
|
|
10126
|
+
scope: z.enum([
|
|
10127
|
+
"full",
|
|
10128
|
+
"outline",
|
|
10129
|
+
"section",
|
|
10130
|
+
"range",
|
|
10131
|
+
"keyword"
|
|
10132
|
+
]).optional(),
|
|
10133
|
+
start_block_id: blockIdSchema.optional(),
|
|
10134
|
+
end_block_id: blockIdSchema.optional(),
|
|
10135
|
+
keyword: z.string().trim().min(1).max(500).optional(),
|
|
10136
|
+
max_depth: z.number().int().min(-1).max(20).optional()
|
|
10137
|
+
}).strict();
|
|
10138
|
+
const wikiCreateArgsSchema = z.object({
|
|
10139
|
+
parent: refSchema,
|
|
10140
|
+
title: z.string().trim().min(1).max(MAX_TITLE_CHARS),
|
|
10141
|
+
content: contentSchema.optional(),
|
|
10142
|
+
format: formatSchema.optional()
|
|
10143
|
+
}).strict();
|
|
10144
|
+
const rangeFields = {
|
|
10145
|
+
block_id: blockIdSchema.optional(),
|
|
10146
|
+
start_block_id: blockIdSchema.optional(),
|
|
10147
|
+
end_block_id: blockIdSchema.optional()
|
|
10148
|
+
};
|
|
10149
|
+
const wikiEditOperationSchema = z.discriminatedUnion("command", [
|
|
10150
|
+
z.object({
|
|
10151
|
+
command: z.literal("append"),
|
|
10152
|
+
content: contentSchema.min(1)
|
|
10153
|
+
}).strict(),
|
|
10154
|
+
z.object({
|
|
10155
|
+
command: z.literal("insert_after"),
|
|
10156
|
+
block_id: blockIdSchema,
|
|
10157
|
+
content: contentSchema.min(1)
|
|
10158
|
+
}).strict(),
|
|
10159
|
+
z.object({
|
|
10160
|
+
command: z.literal("replace"),
|
|
10161
|
+
...rangeFields,
|
|
10162
|
+
content: contentSchema.min(1)
|
|
10163
|
+
}).strict(),
|
|
10164
|
+
z.object({
|
|
10165
|
+
command: z.literal("delete"),
|
|
10166
|
+
...rangeFields
|
|
10167
|
+
}).strict(),
|
|
10168
|
+
z.object({
|
|
10169
|
+
command: z.literal("str_replace"),
|
|
10170
|
+
pattern: z.string().min(1).max(1e4),
|
|
10171
|
+
content: contentSchema
|
|
10172
|
+
}).strict(),
|
|
10173
|
+
z.object({
|
|
10174
|
+
command: z.literal("overwrite"),
|
|
10175
|
+
content: contentSchema.min(1)
|
|
10176
|
+
}).strict()
|
|
10177
|
+
]);
|
|
10178
|
+
const wikiEditArgsSchema = z.object({
|
|
10179
|
+
doc: refSchema,
|
|
10180
|
+
revision_id: z.number().int().min(0),
|
|
10181
|
+
format: formatSchema.optional(),
|
|
10182
|
+
operations: z.array(wikiEditOperationSchema).min(1).max(20)
|
|
10183
|
+
}).strict();
|
|
10184
|
+
const FAILURE_REASONS = {
|
|
10185
|
+
TOOL_DISABLED: "知识库工具已在插件配置中关闭",
|
|
10186
|
+
ROUTE_NOT_FOUND: "当前会话没有绑定飞书话题,无法确定授权人",
|
|
10187
|
+
NOT_AUTHORIZED: "当前话题的发起人不在知识库白名单中;请管理员发送 /auth wiki add @该用户 后重试",
|
|
10188
|
+
CHANNEL_UNAVAILABLE: "飞书连接当前不可用,请稍后重试",
|
|
10189
|
+
INVALID_REFERENCE: "无法识别的知识库引用:请传知识库节点链接 https://<租户>.feishu.cn/wiki/<node_token>、node_token,或知识空间链接 https://<租户>.feishu.cn/wiki/space/<space_id>",
|
|
10190
|
+
INVALID_OPERATION: "编辑操作参数不完整",
|
|
10191
|
+
OUTSIDE_ROOTS: "该位置不在插件配置的知识库范围(wikiRoots)内,不能访问",
|
|
10192
|
+
NOT_FOUND: "知识库节点不存在或已被删除",
|
|
10193
|
+
NO_ACCESS: "机器人对该知识库节点没有访问权限;请知识库管理员把应用(或包含机器人的群)加为该空间成员",
|
|
10194
|
+
UNSUPPORTED_TYPE: "目前只支持读取和修改飞书文档(docx);表格、多维表格等类型暂不支持",
|
|
10195
|
+
NOT_OWNED: "只能修改由本应用创建(所有者是本应用)的文档",
|
|
10196
|
+
REVISION_CONFLICT: "文档在你读取之后被修改过;请重新用 feishu_wiki_read 读取最新内容,基于新的 revision_id 再修改",
|
|
10197
|
+
CONSENT_DENIED: "话题发起人拒绝了这次知识库操作",
|
|
10198
|
+
CONSENT_EXPIRED: "话题发起人没有在授权卡有效期内确认,已自动拒绝",
|
|
10199
|
+
CONSENT_UNAVAILABLE: "授权卡发送失败,无法征得话题发起人同意,已取消操作",
|
|
10200
|
+
PERMISSION_DENIED: "飞书拒绝调用:应用缺少知识库或文档权限(wiki:wiki、docx:document),请管理员在开发者后台开通并发布版本",
|
|
10201
|
+
CONTENT_FAILED: "文档已创建,但写入内容失败",
|
|
10202
|
+
EDIT_FAILED: "修改失败",
|
|
10203
|
+
EDIT_PARTIAL: "飞书报告操作只部分生效;已停止后续操作,请用 feishu_wiki_read 读取最新内容核验后再继续",
|
|
10204
|
+
API_FAILED: "飞书接口调用失败",
|
|
10205
|
+
ABORTED: "已取消"
|
|
10206
|
+
};
|
|
10207
|
+
const OUTPUT_SCHEMA = {
|
|
10208
|
+
type: "object",
|
|
10209
|
+
properties: {
|
|
10210
|
+
ok: { type: "boolean" },
|
|
10211
|
+
code: { type: "string" },
|
|
10212
|
+
reason: { type: "string" }
|
|
10213
|
+
},
|
|
10214
|
+
required: ["ok"]
|
|
10215
|
+
};
|
|
10216
|
+
const LIST_DESCRIPTION = "浏览飞书知识库(只能访问插件配置的知识库范围 wikiRoots)。不传 parent 时列出可访问的根(知识空间或节点);传 parent(知识库节点链接、node_token,或知识空间链接)时列出它的直接子节点,has_child 为 true 的节点可以继续往下浏览。结果分页,带 page_token 时用它取下一页。返回的 node_token 可直接传给其他知识库工具。";
|
|
10217
|
+
const READ_DESCRIPTION = "读取知识库中的一篇飞书文档(docx)。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 表示这篇文档能否被修改(只有本应用创建的文档可以修改)。";
|
|
10218
|
+
const CREATE_DESCRIPTION = "在知识库中新建一篇飞书文档(docx)。parent 传父节点(知识库节点链接或 node_token)或知识空间链接(建在空间顶层),必须位于插件配置的知识库范围内;新文档本身也可以作为目录,之后把它的 node_token 作为 parent 在下面继续新建子文档。title 为标题;content 为正文(可选,不要在正文里重复标题)。format 默认 markdown;xml 为飞书文档 XML(类 HTML 标签:<p> <h1>-<h6> <ul>/<ol><li> <pre><code> <table> <callout> 等),只有 xml 读取会给每个块带上 id 属性(block id),需要按块修改时用 xml 读取。每次新建都会在话题里弹出授权卡,由话题发起人确认后才执行;等待期间不要重复调用。";
|
|
10219
|
+
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 读取。每次修改都会在话题里弹出授权卡,由话题发起人确认后才执行;等待期间不要重复调用。`;
|
|
10220
|
+
/**
|
|
10221
|
+
* The wiki tool runtime: mounts the enabled tools into Agent scopes and runs
|
|
10222
|
+
* each call through the gates in the module contract.
|
|
10223
|
+
*/
|
|
10224
|
+
var WikiToolRuntime = class {
|
|
10225
|
+
deps;
|
|
10226
|
+
mountAttempts = /* @__PURE__ */ new WeakMap();
|
|
10227
|
+
spaceNames = /* @__PURE__ */ new Map();
|
|
10228
|
+
warnedUnavailable = false;
|
|
10229
|
+
warnedMountFailure = false;
|
|
10230
|
+
constructor(deps) {
|
|
10231
|
+
this.deps = deps;
|
|
10232
|
+
}
|
|
10233
|
+
/** Tool names the current configuration mounts. */
|
|
10234
|
+
enabledTools() {
|
|
10235
|
+
const config = this.deps.config();
|
|
10236
|
+
if (config.wikiRoots.length === 0) return [];
|
|
10237
|
+
return [...config.feishuTools.wikiRead ? [WIKI_LIST_TOOL_NAME, WIKI_READ_TOOL_NAME] : [], ...config.feishuTools.wikiWrite ? [WIKI_CREATE_TOOL_NAME, WIKI_EDIT_TOOL_NAME] : []];
|
|
10238
|
+
}
|
|
10239
|
+
/**
|
|
10240
|
+
* Register the enabled tools inside one Agent scope; same mount policy as
|
|
10241
|
+
* the other channel tools (a missing or slow `tools` service never blocks
|
|
10242
|
+
* session creation).
|
|
10243
|
+
*
|
|
10244
|
+
* @param agentCtx Agent scope to register in.
|
|
10245
|
+
* @param sessionId Session the tools act for.
|
|
10246
|
+
*/
|
|
10247
|
+
async mount(agentCtx, sessionId) {
|
|
10248
|
+
if (this.enabledTools().length === 0) return;
|
|
10249
|
+
const existing = this.mountAttempts.get(agentCtx);
|
|
10250
|
+
if (existing !== void 0) return await existing;
|
|
10251
|
+
const attempt = this.mountAttempt(agentCtx, sessionId);
|
|
10252
|
+
this.mountAttempts.set(agentCtx, attempt);
|
|
10253
|
+
await attempt;
|
|
10254
|
+
}
|
|
10255
|
+
async mountAttempt(agentCtx, sessionId) {
|
|
10256
|
+
let injected = false;
|
|
10257
|
+
if (await waitForAvailability(Promise.resolve(agentCtx.inject(["tools"], (ctx) => {
|
|
10258
|
+
injected = true;
|
|
10259
|
+
this.registerInScope(ctx, sessionId);
|
|
10260
|
+
})), this.warnedUnavailable ? 250 : 2e3) === "failed") {
|
|
10261
|
+
this.warnMountFailure("dsh-channels: mounting the wiki tools failed");
|
|
10262
|
+
return;
|
|
10263
|
+
}
|
|
10264
|
+
if (!injected && !this.warnedUnavailable) {
|
|
10265
|
+
this.warnedUnavailable = true;
|
|
10266
|
+
this.deps.warn("dsh-channels: 知识库工具未挂载:宿主 tools 服务不可用(会话继续)");
|
|
10267
|
+
}
|
|
10268
|
+
}
|
|
10269
|
+
registerInScope(agentCtx, sessionId) {
|
|
10270
|
+
const tools = agentCtx.tools;
|
|
10271
|
+
if (tools === void 0 || typeof tools.register !== "function") {
|
|
10272
|
+
this.warnMountFailure("dsh-channels: 知识库工具未注册:宿主 tools 服务不可用");
|
|
10273
|
+
return;
|
|
10274
|
+
}
|
|
10275
|
+
for (const name of this.enabledTools()) try {
|
|
10276
|
+
tools.register(this.definitionFor(name, sessionId));
|
|
10277
|
+
} catch (error) {
|
|
10278
|
+
this.warnMountFailure(`dsh-channels: registering ${name} failed: ${describeError(error)}`);
|
|
10279
|
+
}
|
|
10280
|
+
}
|
|
10281
|
+
warnMountFailure(message) {
|
|
10282
|
+
if (this.warnedMountFailure) return;
|
|
10283
|
+
this.warnedMountFailure = true;
|
|
10284
|
+
this.deps.warn(message);
|
|
10285
|
+
}
|
|
10286
|
+
definitionFor(name, sessionId) {
|
|
10287
|
+
const base = {
|
|
10288
|
+
output: {
|
|
10289
|
+
schema: OUTPUT_SCHEMA,
|
|
10290
|
+
render: (_args, value) => renderWikiResult(value)
|
|
10291
|
+
},
|
|
10292
|
+
presentResult: (_args, value) => presentWikiResult(value)
|
|
10293
|
+
};
|
|
10294
|
+
switch (name) {
|
|
10295
|
+
case WIKI_LIST_TOOL_NAME: return {
|
|
10296
|
+
...base,
|
|
10297
|
+
name,
|
|
10298
|
+
description: LIST_DESCRIPTION,
|
|
10299
|
+
parameters: LIST_PARAMETERS,
|
|
10300
|
+
execute: async (rawArgs) => await this.list(sessionId, rawArgs),
|
|
10301
|
+
presentCall: () => "浏览知识库",
|
|
10302
|
+
isConcurrencySafe: () => true
|
|
10303
|
+
};
|
|
10304
|
+
case WIKI_READ_TOOL_NAME: return {
|
|
10305
|
+
...base,
|
|
10306
|
+
name,
|
|
10307
|
+
description: READ_DESCRIPTION,
|
|
10308
|
+
parameters: READ_PARAMETERS,
|
|
10309
|
+
execute: async (rawArgs, exec) => await this.read(sessionId, rawArgs, exec),
|
|
10310
|
+
presentCall: (args) => `读取知识库文档:${display(args.doc)}`,
|
|
10311
|
+
isConcurrencySafe: () => false
|
|
10312
|
+
};
|
|
10313
|
+
case WIKI_CREATE_TOOL_NAME: return {
|
|
10314
|
+
...base,
|
|
10315
|
+
name,
|
|
10316
|
+
description: CREATE_DESCRIPTION,
|
|
10317
|
+
parameters: CREATE_PARAMETERS,
|
|
10318
|
+
execute: async (rawArgs, exec) => await this.create(sessionId, rawArgs, exec),
|
|
10319
|
+
presentCall: (args) => `新建知识库文档:${display(args.title)}`,
|
|
10320
|
+
isConcurrencySafe: () => false
|
|
10321
|
+
};
|
|
10322
|
+
default: return {
|
|
10323
|
+
...base,
|
|
10324
|
+
name,
|
|
10325
|
+
description: EDIT_DESCRIPTION,
|
|
10326
|
+
parameters: EDIT_PARAMETERS,
|
|
10327
|
+
execute: async (rawArgs, exec) => await this.edit(sessionId, rawArgs, exec),
|
|
10328
|
+
presentCall: (args) => `修改知识库文档:${display(args.doc)}`,
|
|
10329
|
+
isConcurrencySafe: () => false
|
|
10330
|
+
};
|
|
10331
|
+
}
|
|
10332
|
+
}
|
|
10333
|
+
gate(sessionId, tool) {
|
|
10334
|
+
if (!this.enabledTools().includes(tool)) return failure("TOOL_DISABLED");
|
|
10335
|
+
const route = this.deps.routeOfSession(sessionId);
|
|
10336
|
+
if (route === void 0) return failure("ROUTE_NOT_FOUND");
|
|
10337
|
+
if (!this.deps.mayUseWiki(route.senderUserId)) return failure("NOT_AUTHORIZED");
|
|
10338
|
+
const channel = this.deps.channel();
|
|
10339
|
+
const api = channel?.wiki;
|
|
10340
|
+
if (channel === void 0 || api === void 0) return failure("CHANNEL_UNAVAILABLE");
|
|
10341
|
+
return {
|
|
10342
|
+
ok: true,
|
|
10343
|
+
context: {
|
|
10344
|
+
route,
|
|
10345
|
+
api,
|
|
10346
|
+
roots: parseWikiRoots(this.deps.config().wikiRoots),
|
|
10347
|
+
botOpenId: channel.botOpenId()
|
|
10348
|
+
}
|
|
10349
|
+
};
|
|
10350
|
+
}
|
|
10351
|
+
/** Resolve a node/docx reference and check containment. */
|
|
10352
|
+
async containedNode(context, ref) {
|
|
10353
|
+
if (ref.kind === "space") return failure("INVALID_REFERENCE", "这里需要一个文档或节点,而不是整个知识空间");
|
|
10354
|
+
const node = await context.api.getNode(ref.token, ref.kind === "docx" ? "docx" : "wiki");
|
|
10355
|
+
return await containingRoot(context.api, context.roots, node) === void 0 ? failure("OUTSIDE_ROOTS") : node;
|
|
10356
|
+
}
|
|
10357
|
+
async consent(sessionId, context, action, fields, signal) {
|
|
10358
|
+
return consentFailure(await this.deps.consent.request({
|
|
10359
|
+
sessionId,
|
|
10360
|
+
route: context.route,
|
|
10361
|
+
action,
|
|
10362
|
+
...fields,
|
|
10363
|
+
signal
|
|
10364
|
+
}));
|
|
10365
|
+
}
|
|
10366
|
+
async spaceName(api, spaceId) {
|
|
10367
|
+
const cached = this.spaceNames.get(spaceId);
|
|
10368
|
+
if (cached !== void 0) return cached;
|
|
10369
|
+
const name = await api.spaceName(spaceId).catch(() => "");
|
|
10370
|
+
const label = name === "" ? `知识空间 ${spaceId}` : name;
|
|
10371
|
+
this.spaceNames.set(spaceId, label);
|
|
10372
|
+
return label;
|
|
10373
|
+
}
|
|
10374
|
+
/** `空间 / 父节点` for a node (one parent lookup). */
|
|
10375
|
+
async locationOf(api, node) {
|
|
10376
|
+
const space = await this.spaceName(api, node.spaceId);
|
|
10377
|
+
if (node.parentNodeToken === void 0) return space;
|
|
10378
|
+
const parent = await api.getNode(node.parentNodeToken, "wiki").catch(() => void 0);
|
|
10379
|
+
return parent === void 0 || parent.title === "" ? space : `${space} / ${parent.title}`;
|
|
10380
|
+
}
|
|
10381
|
+
nodeView(context, node) {
|
|
10382
|
+
return {
|
|
10383
|
+
title: node.title,
|
|
10384
|
+
node_token: node.nodeToken,
|
|
10385
|
+
obj_type: node.objType,
|
|
10386
|
+
node_type: node.nodeType,
|
|
10387
|
+
has_child: node.hasChild,
|
|
10388
|
+
url: nodeUrl(context.roots, node),
|
|
10389
|
+
owned_by_app: ownedByApp(node, context.botOpenId)
|
|
10390
|
+
};
|
|
10391
|
+
}
|
|
10392
|
+
async list(sessionId, rawArgs) {
|
|
10393
|
+
const args = parseArgs(WIKI_LIST_TOOL_NAME, wikiListArgsSchema, rawArgs);
|
|
10394
|
+
const gate = this.gate(sessionId, WIKI_LIST_TOOL_NAME);
|
|
10395
|
+
if (!gate.ok) return gate;
|
|
10396
|
+
const { context } = gate;
|
|
10397
|
+
return await guarded$1(this.deps, WIKI_LIST_TOOL_NAME, async () => {
|
|
10398
|
+
if (args.parent === void 0) return {
|
|
10399
|
+
ok: true,
|
|
10400
|
+
kind: "roots",
|
|
10401
|
+
items: await Promise.all(context.roots.map((root) => this.rootView(context, root)))
|
|
10402
|
+
};
|
|
10403
|
+
const ref = parseWikiRef(args.parent);
|
|
10404
|
+
if (ref === void 0) return failure("INVALID_REFERENCE");
|
|
10405
|
+
let spaceId;
|
|
10406
|
+
let parentToken;
|
|
10407
|
+
if (ref.kind === "space") {
|
|
10408
|
+
if (!spaceIsRoot(context.roots, ref.spaceId)) return failure("OUTSIDE_ROOTS");
|
|
10409
|
+
spaceId = ref.spaceId;
|
|
10410
|
+
} else {
|
|
10411
|
+
const node = await this.containedNode(context, ref);
|
|
10412
|
+
if ("ok" in node) return node;
|
|
10413
|
+
spaceId = node.spaceId;
|
|
10414
|
+
parentToken = node.nodeToken;
|
|
10415
|
+
}
|
|
10416
|
+
const page = await context.api.listChildren(spaceId, parentToken, args.page_token);
|
|
10417
|
+
return {
|
|
10418
|
+
ok: true,
|
|
10419
|
+
kind: "children",
|
|
10420
|
+
parent: args.parent,
|
|
10421
|
+
items: page.items.map((node) => this.nodeView(context, node)),
|
|
10422
|
+
...page.pageToken === void 0 ? {} : { page_token: page.pageToken }
|
|
10423
|
+
};
|
|
10424
|
+
});
|
|
10425
|
+
}
|
|
10426
|
+
async rootView(context, root) {
|
|
10427
|
+
try {
|
|
10428
|
+
if (root.kind === "space") return {
|
|
10429
|
+
root: "space",
|
|
10430
|
+
title: await this.spaceName(context.api, root.spaceId),
|
|
10431
|
+
link: root.link,
|
|
10432
|
+
space_id: root.spaceId
|
|
10433
|
+
};
|
|
10434
|
+
const node = await context.api.getNode(root.nodeToken, "wiki");
|
|
10435
|
+
return {
|
|
10436
|
+
root: "node",
|
|
10437
|
+
title: node.title,
|
|
10438
|
+
link: root.link,
|
|
10439
|
+
node_token: node.nodeToken,
|
|
10440
|
+
has_child: node.hasChild
|
|
10441
|
+
};
|
|
10442
|
+
} catch (error) {
|
|
10443
|
+
return {
|
|
10444
|
+
...root.kind === "space" ? {
|
|
10445
|
+
root: "space",
|
|
10446
|
+
space_id: root.spaceId
|
|
10447
|
+
} : {
|
|
10448
|
+
root: "node",
|
|
10449
|
+
node_token: root.nodeToken
|
|
10450
|
+
},
|
|
10451
|
+
title: "",
|
|
10452
|
+
link: root.link,
|
|
10453
|
+
error: failureOf(error).reason
|
|
10454
|
+
};
|
|
10455
|
+
}
|
|
10456
|
+
}
|
|
10457
|
+
async read(sessionId, rawArgs, exec) {
|
|
10458
|
+
const args = parseArgs(WIKI_READ_TOOL_NAME, wikiReadArgsSchema, rawArgs);
|
|
10459
|
+
const gate = this.gate(sessionId, WIKI_READ_TOOL_NAME);
|
|
10460
|
+
if (!gate.ok) return gate;
|
|
10461
|
+
const { context } = gate;
|
|
10462
|
+
return await guarded$1(this.deps, WIKI_READ_TOOL_NAME, async () => {
|
|
10463
|
+
const ref = parseWikiRef(args.doc);
|
|
10464
|
+
if (ref === void 0) return failure("INVALID_REFERENCE");
|
|
10465
|
+
const node = await this.containedNode(context, ref);
|
|
10466
|
+
if ("ok" in node) return node;
|
|
10467
|
+
if (node.objType !== "docx") return failure("UNSUPPORTED_TYPE", `${FAILURE_REASONS.UNSUPPORTED_TYPE}(该节点类型:${node.objType})`);
|
|
10468
|
+
const url = nodeUrl(context.roots, node);
|
|
10469
|
+
if (!this.deps.consent.hasReadGrant(sessionId, node.objToken)) {
|
|
10470
|
+
const denied = await this.consent(sessionId, context, "read", {
|
|
10471
|
+
title: node.title,
|
|
10472
|
+
url,
|
|
10473
|
+
location: await this.locationOf(context.api, node),
|
|
10474
|
+
details: []
|
|
10475
|
+
}, exec.signal);
|
|
10476
|
+
if (denied !== void 0) return denied;
|
|
10477
|
+
this.deps.consent.grantRead(sessionId, node.objToken);
|
|
10478
|
+
}
|
|
10479
|
+
if (exec.signal.aborted) return failure("ABORTED");
|
|
10480
|
+
const format = args.format ?? "markdown";
|
|
10481
|
+
const document = await context.api.fetchDocument(node.objToken, {
|
|
10482
|
+
format,
|
|
10483
|
+
withBlockIds: true,
|
|
10484
|
+
scope: args.scope ?? "full",
|
|
10485
|
+
...args.start_block_id === void 0 ? {} : { startBlockId: args.start_block_id },
|
|
10486
|
+
...args.end_block_id === void 0 ? {} : { endBlockId: args.end_block_id },
|
|
10487
|
+
...args.keyword === void 0 ? {} : { keyword: args.keyword },
|
|
10488
|
+
...args.max_depth === void 0 ? {} : { maxDepth: args.max_depth }
|
|
10489
|
+
});
|
|
10490
|
+
return {
|
|
10491
|
+
ok: true,
|
|
10492
|
+
title: node.title,
|
|
10493
|
+
node_token: node.nodeToken,
|
|
10494
|
+
url,
|
|
10495
|
+
revision_id: document.revisionId,
|
|
10496
|
+
format,
|
|
10497
|
+
editable: this.deps.config().feishuTools.wikiWrite && ownedByApp(node, context.botOpenId),
|
|
10498
|
+
content: document.content
|
|
10499
|
+
};
|
|
10500
|
+
});
|
|
10501
|
+
}
|
|
10502
|
+
async create(sessionId, rawArgs, exec) {
|
|
10503
|
+
const args = parseArgs(WIKI_CREATE_TOOL_NAME, wikiCreateArgsSchema, rawArgs);
|
|
10504
|
+
const gate = this.gate(sessionId, WIKI_CREATE_TOOL_NAME);
|
|
10505
|
+
if (!gate.ok) return gate;
|
|
10506
|
+
const { context } = gate;
|
|
10507
|
+
return await guarded$1(this.deps, WIKI_CREATE_TOOL_NAME, async () => {
|
|
10508
|
+
const ref = parseWikiRef(args.parent);
|
|
10509
|
+
if (ref === void 0) return failure("INVALID_REFERENCE");
|
|
10510
|
+
let spaceId;
|
|
10511
|
+
let parentToken;
|
|
10512
|
+
let location;
|
|
10513
|
+
let parentUrl;
|
|
10514
|
+
if (ref.kind === "space") {
|
|
10515
|
+
if (!spaceIsRoot(context.roots, ref.spaceId)) return failure("OUTSIDE_ROOTS");
|
|
10516
|
+
spaceId = ref.spaceId;
|
|
10517
|
+
location = `${await this.spaceName(context.api, spaceId)}(顶层)`;
|
|
10518
|
+
} else {
|
|
10519
|
+
const parent = await this.containedNode(context, ref);
|
|
10520
|
+
if ("ok" in parent) return parent;
|
|
10521
|
+
spaceId = parent.spaceId;
|
|
10522
|
+
parentToken = parent.nodeToken;
|
|
10523
|
+
parentUrl = nodeUrl(context.roots, parent);
|
|
10524
|
+
location = `${await this.spaceName(context.api, spaceId)} / ${parent.title === "" ? parent.nodeToken : parent.title}`;
|
|
10525
|
+
}
|
|
10526
|
+
const format = args.format ?? "markdown";
|
|
10527
|
+
const content = args.content === void 0 || args.content.trim() === "" ? void 0 : args.content;
|
|
10528
|
+
const details = content === void 0 ? ["**内容**:空文档"] : [`**内容预览**(${format === "xml" ? "XML" : "Markdown"},共 ${content.length} 字)\n${previewBlock(content)}`];
|
|
10529
|
+
const denied = await this.consent(sessionId, context, "create", {
|
|
10530
|
+
title: args.title,
|
|
10531
|
+
...parentUrl === void 0 ? {} : { url: parentUrl },
|
|
10532
|
+
location,
|
|
10533
|
+
details
|
|
10534
|
+
}, exec.signal);
|
|
10535
|
+
if (denied !== void 0) return denied;
|
|
10536
|
+
if (exec.signal.aborted) return failure("ABORTED");
|
|
10537
|
+
const node = await context.api.createDocxNode(spaceId, parentToken, args.title);
|
|
10538
|
+
const url = nodeUrl(context.roots, node);
|
|
10539
|
+
this.deps.consent.grantRead(sessionId, node.objToken);
|
|
10540
|
+
if (content === void 0) return {
|
|
10541
|
+
ok: true,
|
|
10542
|
+
title: node.title || args.title,
|
|
10543
|
+
node_token: node.nodeToken,
|
|
10544
|
+
url,
|
|
10545
|
+
warnings: []
|
|
10546
|
+
};
|
|
10547
|
+
try {
|
|
10548
|
+
const written = await context.api.updateDocument(node.objToken, {
|
|
10549
|
+
command: "block_insert_after",
|
|
10550
|
+
format,
|
|
10551
|
+
content,
|
|
10552
|
+
blockId: "-1"
|
|
10553
|
+
});
|
|
10554
|
+
if (written.result === "failed") return {
|
|
10555
|
+
...failure("CONTENT_FAILED", `${FAILURE_REASONS.CONTENT_FAILED}:${written.warnings.join(";") || "飞书返回 failed"};可用 feishu_wiki_edit 重新写入`),
|
|
10556
|
+
node_token: node.nodeToken,
|
|
10557
|
+
url
|
|
10558
|
+
};
|
|
10559
|
+
if (written.result === "partial_success") return {
|
|
10560
|
+
...failure("CONTENT_FAILED", `文档已创建,但内容只部分写入:${written.warnings.join(";") || "飞书返回 partial_success"};请先用 feishu_wiki_read 核验,再用 feishu_wiki_edit 补齐`),
|
|
10561
|
+
node_token: node.nodeToken,
|
|
10562
|
+
url
|
|
10563
|
+
};
|
|
10564
|
+
return {
|
|
10565
|
+
ok: true,
|
|
10566
|
+
title: node.title || args.title,
|
|
10567
|
+
node_token: node.nodeToken,
|
|
10568
|
+
url,
|
|
10569
|
+
...written.revisionId === void 0 ? {} : { revision_id: written.revisionId },
|
|
10570
|
+
warnings: written.warnings
|
|
10571
|
+
};
|
|
10572
|
+
} catch (error) {
|
|
10573
|
+
const reason = failureOf(error).reason;
|
|
10574
|
+
return {
|
|
10575
|
+
...failure("CONTENT_FAILED", `${FAILURE_REASONS.CONTENT_FAILED}:${reason};可用 feishu_wiki_edit 重新写入`),
|
|
10576
|
+
node_token: node.nodeToken,
|
|
10577
|
+
url
|
|
10578
|
+
};
|
|
10579
|
+
}
|
|
10580
|
+
});
|
|
10581
|
+
}
|
|
10582
|
+
async edit(sessionId, rawArgs, exec) {
|
|
10583
|
+
const args = parseArgs(WIKI_EDIT_TOOL_NAME, wikiEditArgsSchema, rawArgs);
|
|
10584
|
+
const gate = this.gate(sessionId, WIKI_EDIT_TOOL_NAME);
|
|
10585
|
+
if (!gate.ok) return gate;
|
|
10586
|
+
const { context } = gate;
|
|
10587
|
+
const format = args.format ?? "markdown";
|
|
10588
|
+
const requests = [];
|
|
10589
|
+
for (const [index, operation] of args.operations.entries()) {
|
|
10590
|
+
const request = updateRequestOf(operation, format);
|
|
10591
|
+
if (typeof request === "string") return failure("INVALID_OPERATION", `第 ${index + 1} 个操作(${operation.command}):${request}`);
|
|
10592
|
+
requests.push(request);
|
|
10593
|
+
}
|
|
10594
|
+
return await guarded$1(this.deps, WIKI_EDIT_TOOL_NAME, async () => {
|
|
10595
|
+
const ref = parseWikiRef(args.doc);
|
|
10596
|
+
if (ref === void 0) return failure("INVALID_REFERENCE");
|
|
10597
|
+
const node = await this.containedNode(context, ref);
|
|
10598
|
+
if ("ok" in node) return node;
|
|
10599
|
+
if (node.objType !== "docx") return failure("UNSUPPORTED_TYPE", `${FAILURE_REASONS.UNSUPPORTED_TYPE}(该节点类型:${node.objType})`);
|
|
10600
|
+
if (!ownedByApp(node, context.botOpenId)) return failure("NOT_OWNED", `${FAILURE_REASONS.NOT_OWNED}(该文档所有者:${node.owner ?? "未知"})`);
|
|
10601
|
+
const current = await context.api.documentRevision(node.objToken);
|
|
10602
|
+
if (current !== args.revision_id) return {
|
|
10603
|
+
...failure("REVISION_CONFLICT", `${FAILURE_REASONS.REVISION_CONFLICT}(读取时 ${args.revision_id},当前 ${current})`),
|
|
10604
|
+
revision_id: current
|
|
10605
|
+
};
|
|
10606
|
+
const url = nodeUrl(context.roots, node);
|
|
10607
|
+
const denied = await this.consent(sessionId, context, "edit", {
|
|
10608
|
+
title: node.title,
|
|
10609
|
+
url,
|
|
10610
|
+
location: await this.locationOf(context.api, node),
|
|
10611
|
+
details: operationLines(args.operations)
|
|
10612
|
+
}, exec.signal);
|
|
10613
|
+
if (denied !== void 0) return denied;
|
|
10614
|
+
if (await context.api.documentRevision(node.objToken) !== args.revision_id) return failure("REVISION_CONFLICT", `${FAILURE_REASONS.REVISION_CONFLICT}(等待授权期间文档被修改)`);
|
|
10615
|
+
const warnings = [];
|
|
10616
|
+
let revision = args.revision_id;
|
|
10617
|
+
for (const [index, request] of requests.entries()) {
|
|
10618
|
+
if (exec.signal.aborted) return {
|
|
10619
|
+
...failure("ABORTED", `已取消;前 ${index} 个操作已执行`),
|
|
10620
|
+
applied: index
|
|
10621
|
+
};
|
|
10622
|
+
let result;
|
|
10623
|
+
try {
|
|
10624
|
+
result = await context.api.updateDocument(node.objToken, {
|
|
10625
|
+
...request,
|
|
10626
|
+
revisionId: revision
|
|
10627
|
+
});
|
|
10628
|
+
} catch (error) {
|
|
10629
|
+
const reason = failureOf(error).reason;
|
|
10630
|
+
return {
|
|
10631
|
+
...failure("EDIT_FAILED", `第 ${index + 1} 个操作失败:${reason};前 ${index} 个操作已执行`),
|
|
10632
|
+
applied: index
|
|
10633
|
+
};
|
|
10634
|
+
}
|
|
10635
|
+
warnings.push(...result.warnings);
|
|
10636
|
+
if (result.revisionId !== void 0) revision = result.revisionId;
|
|
10637
|
+
if (result.result === "failed") return {
|
|
10638
|
+
...failure("EDIT_FAILED", `第 ${index + 1} 个操作失败:${result.warnings.join(";") || "飞书返回 failed"};前 ${index} 个操作已执行`),
|
|
10639
|
+
applied: index
|
|
10640
|
+
};
|
|
10641
|
+
if (result.result === "partial_success") return {
|
|
10642
|
+
...failure("EDIT_PARTIAL", `第 ${index + 1} 个操作只部分生效(${result.warnings.join(";") || "飞书返回 partial_success"});前 ${index} 个操作已执行,已停止后续操作;请用 feishu_wiki_read 读取核验后再继续`),
|
|
10643
|
+
applied: index,
|
|
10644
|
+
revision_id: revision
|
|
10645
|
+
};
|
|
10646
|
+
}
|
|
10647
|
+
return {
|
|
10648
|
+
ok: true,
|
|
10649
|
+
title: node.title,
|
|
10650
|
+
node_token: node.nodeToken,
|
|
10651
|
+
url,
|
|
10652
|
+
applied: requests.length,
|
|
10653
|
+
revision_id: revision,
|
|
10654
|
+
warnings
|
|
10655
|
+
};
|
|
10656
|
+
});
|
|
10657
|
+
}
|
|
10658
|
+
};
|
|
10659
|
+
function failure(code, reason) {
|
|
10660
|
+
return {
|
|
10661
|
+
ok: false,
|
|
10662
|
+
code,
|
|
10663
|
+
reason: reason ?? FAILURE_REASONS[code]
|
|
10664
|
+
};
|
|
10665
|
+
}
|
|
10666
|
+
function parseArgs(tool, schema, rawArgs) {
|
|
10667
|
+
const parsed = schema.safeParse(rawArgs);
|
|
10668
|
+
if (parsed.success) return parsed.data;
|
|
10669
|
+
const issue = parsed.error.issues[0];
|
|
10670
|
+
const where = issue === void 0 || issue.path.length === 0 ? "arguments" : issue.path.join(".");
|
|
10671
|
+
throw new Error(`${tool}: invalid ${where}: ${issue?.message ?? "invalid arguments"}`);
|
|
10672
|
+
}
|
|
10673
|
+
/** Run one gated call body, mapping thrown Feishu errors to failures. */
|
|
10674
|
+
async function guarded$1(deps, tool, body) {
|
|
10675
|
+
try {
|
|
10676
|
+
return await body();
|
|
10677
|
+
} catch (error) {
|
|
10678
|
+
const mapped = failureOf(error);
|
|
10679
|
+
if (mapped.code === "API_FAILED") deps.warn("dsh-channels: %s failed: %s", tool, mapped.reason);
|
|
10680
|
+
return mapped;
|
|
10681
|
+
}
|
|
10682
|
+
}
|
|
10683
|
+
/** Feishu codes: node missing/deleted, no resource access, missing app scope. */
|
|
10684
|
+
const NOT_FOUND_CODES = /* @__PURE__ */ new Set([
|
|
10685
|
+
131005,
|
|
10686
|
+
131012,
|
|
10687
|
+
131013,
|
|
10688
|
+
131014,
|
|
10689
|
+
131016
|
|
10690
|
+
]);
|
|
10691
|
+
const NO_ACCESS_CODES = /* @__PURE__ */ new Set([
|
|
10692
|
+
131006,
|
|
10693
|
+
1770032,
|
|
10694
|
+
1770002
|
|
10695
|
+
]);
|
|
10696
|
+
const PERMISSION_CODES = /* @__PURE__ */ new Set([99991672, 99991679]);
|
|
10697
|
+
function failureOf(error) {
|
|
10698
|
+
const detail = describeError(error);
|
|
10699
|
+
if (error instanceof FeishuApiError && error.code !== void 0) {
|
|
10700
|
+
if (NOT_FOUND_CODES.has(error.code)) return failure("NOT_FOUND", `${FAILURE_REASONS.NOT_FOUND}(${detail})`);
|
|
10701
|
+
if (NO_ACCESS_CODES.has(error.code)) return failure("NO_ACCESS", `${FAILURE_REASONS.NO_ACCESS}(${detail})`);
|
|
10702
|
+
if (PERMISSION_CODES.has(error.code)) return failure("PERMISSION_DENIED", `${FAILURE_REASONS.PERMISSION_DENIED}(${detail})`);
|
|
10703
|
+
}
|
|
10704
|
+
return failure("API_FAILED", `${FAILURE_REASONS.API_FAILED}:${detail}`);
|
|
10705
|
+
}
|
|
10706
|
+
function consentFailure(outcome) {
|
|
10707
|
+
switch (outcome) {
|
|
10708
|
+
case "allowed": return;
|
|
10709
|
+
case "denied": return failure("CONSENT_DENIED");
|
|
10710
|
+
case "expired": return failure("CONSENT_EXPIRED");
|
|
10711
|
+
case "cancelled": return failure("ABORTED");
|
|
10712
|
+
case "unavailable": return failure("CONSENT_UNAVAILABLE");
|
|
10713
|
+
}
|
|
10714
|
+
}
|
|
10715
|
+
/** Whether the app owns a node: Feishu reports the bot open_id as the owner. */
|
|
10716
|
+
function ownedByApp(node, botOpenId) {
|
|
10717
|
+
return botOpenId !== void 0 && botOpenId !== "" && node.owner === botOpenId;
|
|
10718
|
+
}
|
|
10719
|
+
/** A node's web link; falls back to the first root's origin. */
|
|
10720
|
+
function nodeUrl(roots, node) {
|
|
10721
|
+
if (node.url !== void 0) return node.url;
|
|
10722
|
+
const first = roots[0];
|
|
10723
|
+
return `${first === void 0 ? "https://feishu.cn" : new URL(first.link).origin}/wiki/${node.nodeToken}`;
|
|
10724
|
+
}
|
|
10725
|
+
/** Map one validated operation to a `docs_ai` update, or the reason it is incomplete. */
|
|
10726
|
+
function updateRequestOf(operation, format) {
|
|
10727
|
+
switch (operation.command) {
|
|
10728
|
+
case "append": return {
|
|
10729
|
+
command: "block_insert_after",
|
|
10730
|
+
format,
|
|
10731
|
+
content: operation.content,
|
|
10732
|
+
blockId: "-1"
|
|
10733
|
+
};
|
|
10734
|
+
case "insert_after": return {
|
|
10735
|
+
command: "block_insert_after",
|
|
10736
|
+
format,
|
|
10737
|
+
content: operation.content,
|
|
10738
|
+
blockId: operation.block_id
|
|
10739
|
+
};
|
|
10740
|
+
case "replace": {
|
|
10741
|
+
const range = rangeOf(operation);
|
|
10742
|
+
return typeof range === "string" ? range : {
|
|
10743
|
+
command: "block_replace",
|
|
10744
|
+
format,
|
|
10745
|
+
content: operation.content,
|
|
10746
|
+
...range
|
|
10747
|
+
};
|
|
10748
|
+
}
|
|
10749
|
+
case "delete": {
|
|
10750
|
+
const range = rangeOf(operation);
|
|
10751
|
+
return typeof range === "string" ? range : {
|
|
10752
|
+
command: "block_delete",
|
|
10753
|
+
format,
|
|
10754
|
+
...range
|
|
10755
|
+
};
|
|
10756
|
+
}
|
|
10757
|
+
case "str_replace": return {
|
|
10758
|
+
command: "str_replace",
|
|
10759
|
+
format,
|
|
10760
|
+
pattern: operation.pattern,
|
|
10761
|
+
content: operation.content
|
|
10762
|
+
};
|
|
10763
|
+
case "overwrite": return {
|
|
10764
|
+
command: "overwrite",
|
|
10765
|
+
format,
|
|
10766
|
+
content: operation.content
|
|
10767
|
+
};
|
|
10768
|
+
}
|
|
10769
|
+
}
|
|
10770
|
+
/** `block_id` alone, or `start_block_id` + `end_block_id` together. */
|
|
10771
|
+
function rangeOf(operation) {
|
|
10772
|
+
const hasRange = operation.start_block_id !== void 0 || operation.end_block_id !== void 0;
|
|
10773
|
+
if (operation.block_id !== void 0 && !hasRange) return { blockId: operation.block_id };
|
|
10774
|
+
if (operation.block_id === void 0 && operation.start_block_id !== void 0 && operation.end_block_id !== void 0) return {
|
|
10775
|
+
startBlockId: operation.start_block_id,
|
|
10776
|
+
endBlockId: operation.end_block_id
|
|
10777
|
+
};
|
|
10778
|
+
return "需要 block_id,或成对的 start_block_id 与 end_block_id(二者不能同时使用)";
|
|
10779
|
+
}
|
|
10780
|
+
/** The consent card's operation list. */
|
|
10781
|
+
/** Consent-card details: a header, then one entry per operation so every operation stays visible. */
|
|
10782
|
+
function operationLines(operations) {
|
|
10783
|
+
const lines = operations.map((operation, index) => {
|
|
10784
|
+
const n = `${index + 1}.`;
|
|
10785
|
+
switch (operation.command) {
|
|
10786
|
+
case "append": return `${n} 在文末追加 ${operation.content.length} 字:${preview(operation.content)}`;
|
|
10787
|
+
case "insert_after": return `${n} 在块 \`${operation.block_id}\` 后插入 ${operation.content.length} 字:${preview(operation.content)}`;
|
|
10788
|
+
case "replace": return `${n} 替换${blockRangeLabel(operation)}为 ${operation.content.length} 字:${preview(operation.content)}`;
|
|
10789
|
+
case "delete": return `${n} 删除${blockRangeLabel(operation)}`;
|
|
10790
|
+
case "str_replace": return `${n} 把文本「${preview(operation.pattern)}」替换为「${preview(operation.content)}」`;
|
|
10791
|
+
case "overwrite": return `${n} ⚠️ 清空整篇并重写为 ${operation.content.length} 字:${preview(operation.content)}`;
|
|
10792
|
+
}
|
|
10793
|
+
});
|
|
10794
|
+
return [`**将执行 ${operations.length} 个操作**`, ...lines];
|
|
10795
|
+
}
|
|
10796
|
+
function blockRangeLabel(operation) {
|
|
10797
|
+
return operation.block_id !== void 0 ? `块 \`${operation.block_id}\`` : `块 \`${operation.start_block_id ?? "?"}\` 到 \`${operation.end_block_id ?? "?"}\``;
|
|
10798
|
+
}
|
|
10799
|
+
/** One-line clipped preview with Markdown control characters neutralized. */
|
|
10800
|
+
function preview(value, max = 80) {
|
|
10801
|
+
const flat = value.replace(/\s+/gu, " ").trim().replace(/([[\]()*_`~<>])/gu, "\\$1");
|
|
10802
|
+
return flat.length <= max ? flat : `${flat.slice(0, max)}…`;
|
|
10803
|
+
}
|
|
10804
|
+
/** A fenced preview of the first characters of a content. */
|
|
10805
|
+
function previewBlock(content) {
|
|
10806
|
+
return `\`\`\`\n${(content.length <= PREVIEW_CHARS ? content : `${content.slice(0, PREVIEW_CHARS)}\n…`).replace(/```/gu, "```")}\n\`\`\``;
|
|
10807
|
+
}
|
|
10808
|
+
function display(value) {
|
|
10809
|
+
return typeof value === "string" ? preview(value, 120) : "?";
|
|
10810
|
+
}
|
|
10811
|
+
/** Readable, bounded text for a thrown value. */
|
|
10812
|
+
function describeError(error) {
|
|
10813
|
+
const cleaned = (error instanceof FeishuApiError ? error.detail : error instanceof Error ? error.message : String(error)).replace(/[\u0000-\u001f\u007f]+/gu, " ").trim();
|
|
10814
|
+
return cleaned.length <= 300 ? cleaned : `${cleaned.slice(0, 300)}…`;
|
|
10815
|
+
}
|
|
10816
|
+
function isValue(value) {
|
|
10817
|
+
return typeof value === "object" && value !== null && typeof value.ok === "boolean";
|
|
10818
|
+
}
|
|
10819
|
+
/**
|
|
10820
|
+
* Model-visible content of one call: a read returns its document content as
|
|
10821
|
+
* plain text after a short header; everything else is JSON.
|
|
10822
|
+
*
|
|
10823
|
+
* @param value The tool value.
|
|
10824
|
+
* @returns One text block.
|
|
10825
|
+
*/
|
|
10826
|
+
function renderWikiResult(value) {
|
|
10827
|
+
if (!isValue(value)) return [{
|
|
10828
|
+
type: "text",
|
|
10829
|
+
text: "知识库工具结果不可用"
|
|
10830
|
+
}];
|
|
10831
|
+
if (value.ok && "content" in value) {
|
|
10832
|
+
const { content, ...meta } = value;
|
|
10833
|
+
return [{
|
|
10834
|
+
type: "text",
|
|
10835
|
+
text: `${JSON.stringify(meta)}\n\n${content}`
|
|
10836
|
+
}];
|
|
10837
|
+
}
|
|
10838
|
+
return [{
|
|
10839
|
+
type: "text",
|
|
10840
|
+
text: JSON.stringify(value)
|
|
10841
|
+
}];
|
|
10842
|
+
}
|
|
10843
|
+
/**
|
|
10844
|
+
* One-line result presentation.
|
|
10845
|
+
*
|
|
10846
|
+
* @param value The tool value.
|
|
10847
|
+
* @returns Display text.
|
|
10848
|
+
*/
|
|
10849
|
+
function presentWikiResult(value) {
|
|
10850
|
+
if (!isValue(value)) return "知识库工具结果不可用";
|
|
10851
|
+
if (!value.ok) return `失败(${value.code}):${value.reason}`;
|
|
10852
|
+
if ("items" in value) return `列出 ${value.items.length} 项`;
|
|
10853
|
+
if ("content" in value) return `已读取《${value.title}》(${value.content.length} 字)`;
|
|
10854
|
+
if ("applied" in value) return `已修改《${value.title}》(${value.applied} 个操作)`;
|
|
10855
|
+
return `已新建《${value.title}》`;
|
|
10856
|
+
}
|
|
10857
|
+
const REF_DESCRIPTION = "知识库节点链接 https://<租户>.feishu.cn/wiki/<node_token> 或 node_token";
|
|
10858
|
+
const FORMAT_PROPERTY = {
|
|
10859
|
+
type: "string",
|
|
10860
|
+
enum: ["markdown", "xml"],
|
|
10861
|
+
description: "内容格式,默认 markdown;xml 读取时带 block id"
|
|
10862
|
+
};
|
|
10863
|
+
const BLOCK_ID_PROPERTY = {
|
|
10864
|
+
type: "string",
|
|
10865
|
+
description: "块 id(xml 读取结果中的 id 属性)"
|
|
10866
|
+
};
|
|
10867
|
+
const LIST_PARAMETERS = {
|
|
10868
|
+
type: "object",
|
|
10869
|
+
properties: {
|
|
10870
|
+
parent: {
|
|
10871
|
+
type: "string",
|
|
10872
|
+
description: `要列出子节点的位置:${REF_DESCRIPTION},或知识空间链接 https://<租户>.feishu.cn/wiki/space/<space_id>;不传则列出可访问的根`
|
|
10873
|
+
},
|
|
10874
|
+
page_token: {
|
|
10875
|
+
type: "string",
|
|
10876
|
+
description: "上一页返回的 page_token"
|
|
10877
|
+
}
|
|
10878
|
+
},
|
|
10879
|
+
additionalProperties: false
|
|
10880
|
+
};
|
|
10881
|
+
const READ_PARAMETERS = {
|
|
10882
|
+
type: "object",
|
|
10883
|
+
properties: {
|
|
10884
|
+
doc: {
|
|
10885
|
+
type: "string",
|
|
10886
|
+
description: REF_DESCRIPTION
|
|
10887
|
+
},
|
|
10888
|
+
format: FORMAT_PROPERTY,
|
|
10889
|
+
scope: {
|
|
10890
|
+
type: "string",
|
|
10891
|
+
enum: [
|
|
10892
|
+
"full",
|
|
10893
|
+
"outline",
|
|
10894
|
+
"section",
|
|
10895
|
+
"range",
|
|
10896
|
+
"keyword"
|
|
10897
|
+
],
|
|
10898
|
+
description: "读取范围,默认 full"
|
|
10899
|
+
},
|
|
10900
|
+
start_block_id: {
|
|
10901
|
+
...BLOCK_ID_PROPERTY,
|
|
10902
|
+
description: "section 的标题块 id,或 range 的起点"
|
|
10903
|
+
},
|
|
10904
|
+
end_block_id: {
|
|
10905
|
+
...BLOCK_ID_PROPERTY,
|
|
10906
|
+
description: "range 的终点,-1 表示到文末"
|
|
10907
|
+
},
|
|
10908
|
+
keyword: {
|
|
10909
|
+
type: "string",
|
|
10910
|
+
description: "keyword 模式的关键词,多个用 | 分隔"
|
|
10911
|
+
},
|
|
10912
|
+
max_depth: {
|
|
10913
|
+
type: "integer",
|
|
10914
|
+
description: "outline:标题层级上限;其他:子树深度,-1 不限"
|
|
10915
|
+
}
|
|
10916
|
+
},
|
|
10917
|
+
required: ["doc"],
|
|
10918
|
+
additionalProperties: false
|
|
10919
|
+
};
|
|
10920
|
+
const CREATE_PARAMETERS = {
|
|
10921
|
+
type: "object",
|
|
10922
|
+
properties: {
|
|
10923
|
+
parent: {
|
|
10924
|
+
type: "string",
|
|
10925
|
+
description: `父位置:${REF_DESCRIPTION},或知识空间链接(建在空间顶层)`
|
|
10926
|
+
},
|
|
10927
|
+
title: {
|
|
10928
|
+
type: "string",
|
|
10929
|
+
description: "文档标题"
|
|
10930
|
+
},
|
|
10931
|
+
content: {
|
|
10932
|
+
type: "string",
|
|
10933
|
+
description: "正文(可选)"
|
|
10934
|
+
},
|
|
10935
|
+
format: FORMAT_PROPERTY
|
|
10936
|
+
},
|
|
10937
|
+
required: ["parent", "title"],
|
|
10938
|
+
additionalProperties: false
|
|
10939
|
+
};
|
|
10940
|
+
const EDIT_PARAMETERS = {
|
|
10941
|
+
type: "object",
|
|
10942
|
+
properties: {
|
|
10943
|
+
doc: {
|
|
10944
|
+
type: "string",
|
|
10945
|
+
description: REF_DESCRIPTION
|
|
10946
|
+
},
|
|
10947
|
+
revision_id: {
|
|
10948
|
+
type: "integer",
|
|
10949
|
+
description: "feishu_wiki_read 返回的 revision_id"
|
|
10950
|
+
},
|
|
10951
|
+
format: FORMAT_PROPERTY,
|
|
10952
|
+
operations: {
|
|
10953
|
+
type: "array",
|
|
10954
|
+
minItems: 1,
|
|
10955
|
+
maxItems: 20,
|
|
10956
|
+
items: {
|
|
10957
|
+
type: "object",
|
|
10958
|
+
properties: {
|
|
10959
|
+
command: {
|
|
10960
|
+
type: "string",
|
|
10961
|
+
enum: [
|
|
10962
|
+
"append",
|
|
10963
|
+
"insert_after",
|
|
10964
|
+
"replace",
|
|
10965
|
+
"delete",
|
|
10966
|
+
"str_replace",
|
|
10967
|
+
"overwrite"
|
|
10968
|
+
]
|
|
10969
|
+
},
|
|
10970
|
+
block_id: BLOCK_ID_PROPERTY,
|
|
10971
|
+
start_block_id: BLOCK_ID_PROPERTY,
|
|
10972
|
+
end_block_id: BLOCK_ID_PROPERTY,
|
|
10973
|
+
pattern: {
|
|
10974
|
+
type: "string",
|
|
10975
|
+
description: "str_replace 要查找的文本"
|
|
10976
|
+
},
|
|
10977
|
+
content: {
|
|
10978
|
+
type: "string",
|
|
10979
|
+
description: "写入的内容"
|
|
10980
|
+
}
|
|
10981
|
+
},
|
|
10982
|
+
required: ["command"],
|
|
10983
|
+
additionalProperties: false
|
|
10984
|
+
}
|
|
10985
|
+
}
|
|
10986
|
+
},
|
|
10987
|
+
required: [
|
|
10988
|
+
"doc",
|
|
10989
|
+
"revision_id",
|
|
10990
|
+
"operations"
|
|
10991
|
+
],
|
|
10992
|
+
additionalProperties: false
|
|
10993
|
+
};
|
|
10994
|
+
//#endregion
|
|
10995
|
+
//#region src/routing/inbound.ts
|
|
10996
|
+
/**
|
|
10997
|
+
* Inbound routing: dedup, admission, the route decision tree, and the
|
|
10998
|
+
* session-establishment / continuation flow.
|
|
10999
|
+
*
|
|
11000
|
+
* Everything here is channel-plane policy; the command plane (slash commands)
|
|
11001
|
+
* and the answerers stay in the service and are reached through narrow ports.
|
|
11002
|
+
* Dependency reads go through accessors so the moved logic stays readable.
|
|
11003
|
+
*
|
|
11004
|
+
* @module dsh-channels/routing/inbound
|
|
11005
|
+
*/
|
|
11006
|
+
/** Confirmation line for each keyword approval reply. */
|
|
11007
|
+
const APPROVAL_TEXT_REPLIES = {
|
|
11008
|
+
allowed: "已允许本次操作。",
|
|
11009
|
+
remembered: "已允许,本话题内同类操作不再询问。",
|
|
11010
|
+
denied: "已拒绝本次操作。"
|
|
11011
|
+
};
|
|
11012
|
+
/**
|
|
11013
|
+
* User-facing text for attachments that could not be archived. Delivered as a
|
|
11014
|
+
* plain reply (never a card) so the user learns why the Agent cannot read a
|
|
11015
|
+
* file instead of silently getting an answer about older attachments.
|
|
11016
|
+
*/
|
|
11017
|
+
function buildAttachmentFailureNotice(failures) {
|
|
11018
|
+
return [
|
|
11019
|
+
"⚠️ 附件保存失败,Agent 无法读取:",
|
|
11020
|
+
...failures.map((failure) => `• ${failure.name}:${failure.reason}`),
|
|
11021
|
+
"可尝试压缩/拆分后重发,或把文件直接放到工作区目录。"
|
|
11022
|
+
].join("\n");
|
|
11023
|
+
}
|
|
11024
|
+
/** Build one durable user message from inbound Feishu text. */
|
|
11025
|
+
function feishuUserMessage(text) {
|
|
11026
|
+
return {
|
|
11027
|
+
id: randomUUID(),
|
|
11028
|
+
role: "user",
|
|
11029
|
+
content: [{
|
|
11030
|
+
type: "text",
|
|
11031
|
+
text
|
|
11032
|
+
}],
|
|
11033
|
+
source: { kind: "user" }
|
|
11034
|
+
};
|
|
11035
|
+
}
|
|
11036
|
+
/**
|
|
11037
|
+
* Whitelist registration input for one triggering message.
|
|
11038
|
+
*
|
|
11039
|
+
* The fetch tool requires explicit per-route authorization; the trigger
|
|
11040
|
+
* message is the one exception the admission layer can vouch for. Resources
|
|
11041
|
+
* are copied from the same normalization the manifest uses, and merged-forward
|
|
11042
|
+
* containers carry no direct resources (the tool expands them through the
|
|
11043
|
+
* container id, per the P0 rule).
|
|
11044
|
+
*/
|
|
11045
|
+
function knownAttachmentMessageOf(msg) {
|
|
11046
|
+
const forwarded = msg.rawContentType === "merge_forward";
|
|
11047
|
+
const resources = attachmentResources(msg).map((resource) => ({
|
|
11048
|
+
type: resource.type,
|
|
11049
|
+
fileKey: resource.fileKey,
|
|
11050
|
+
...resource.fileName === void 0 ? {} : { fileName: resource.fileName },
|
|
11051
|
+
...resource.folder === true ? { folder: true } : {}
|
|
11052
|
+
}));
|
|
11053
|
+
return {
|
|
11054
|
+
messageId: msg.messageId,
|
|
11055
|
+
kind: forwarded ? "merge_forward" : "message",
|
|
11056
|
+
resources,
|
|
11057
|
+
senderUserId: msg.senderId
|
|
11058
|
+
};
|
|
11059
|
+
}
|
|
11060
|
+
/** Feishu message-id shape accepted for quote resolution (`om_...`). */
|
|
11061
|
+
const QUOTE_MESSAGE_ID = /^om_[A-Za-z0-9_-]{1,128}$/u;
|
|
11062
|
+
var InboundRouter = class {
|
|
11063
|
+
deps;
|
|
11064
|
+
/**
|
|
11065
|
+
* In-process dedup guard: message id → accept timestamp. Set synchronously
|
|
11066
|
+
* at acceptance so a same-process redelivery can never slip through the
|
|
11067
|
+
* window between the persisted dedup check and its `put` landing.
|
|
11068
|
+
*/
|
|
11069
|
+
accepted = /* @__PURE__ */ new Map();
|
|
11070
|
+
/**
|
|
11071
|
+
* chat-scoped key → in-flight session establishment. Concurrent first
|
|
11072
|
+
* messages of one (chat, user) pair all observe the same promise: they wait
|
|
11073
|
+
* for the establishment to settle, then either continue the just-created
|
|
11074
|
+
* session (same topic, or degraded chat route) or fall through to start
|
|
11075
|
+
* their own — never racing each other into duplicate sessions, route
|
|
11076
|
+
* overwrites, or the "no route, no mention" drop window.
|
|
11077
|
+
*/
|
|
11078
|
+
establishing = /* @__PURE__ */ new Map();
|
|
11079
|
+
/**
|
|
11080
|
+
* route key → first-turn delivery gate. A new Session persists its route
|
|
11081
|
+
* before the prompt is assembled (image prefetch); a message that observes
|
|
11082
|
+
* the route in that window must wait for the first followup instead of
|
|
11083
|
+
* racing it, otherwise the topic's turns reach the model out of order.
|
|
11084
|
+
*/
|
|
11085
|
+
firstDeliveries = /* @__PURE__ */ new Map();
|
|
11086
|
+
constructor(deps) {
|
|
11087
|
+
this.deps = deps;
|
|
11088
|
+
}
|
|
11089
|
+
get ctx() {
|
|
11090
|
+
return this.deps.ctx;
|
|
9469
11091
|
}
|
|
9470
11092
|
get config() {
|
|
9471
11093
|
return this.deps.config;
|
|
@@ -12113,6 +13735,21 @@ const configSchema = z.object({
|
|
|
12113
13735
|
* `im:resource` (or `im:resource:upload`) scope; without it a call returns
|
|
12114
13736
|
* a readable failure and the turn continues.
|
|
12115
13737
|
*
|
|
13738
|
+
* - `wikiRead` (default off): register `feishu_wiki_list` and
|
|
13739
|
+
* `feishu_wiki_read` so the model can browse the wiki nodes under
|
|
13740
|
+
* `wikiRoots` and read docx documents there. Needs `wiki:wiki` (or
|
|
13741
|
+
* `wiki:wiki:readonly`) and `docx:document` (or `docx:document:readonly`).
|
|
13742
|
+
* - `wikiWrite` (default off): register `feishu_wiki_create` and
|
|
13743
|
+
* `feishu_wiki_edit` so the model can create docx documents under
|
|
13744
|
+
* `wikiRoots` and edit documents this app owns. Needs `wiki:wiki` and
|
|
13745
|
+
* `docx:document`.
|
|
13746
|
+
*
|
|
13747
|
+
* The wiki tools act with the app identity and are mounted only while
|
|
13748
|
+
* `wikiRoots` is non-empty. Each call is limited to topics whose owner is
|
|
13749
|
+
* the administrator or on the wiki whitelist (`/auth wiki add`); opening a
|
|
13750
|
+
* document, creating one, and editing one each need the topic owner's
|
|
13751
|
+
* consent on an authorization card (a read consent lasts for the topic).
|
|
13752
|
+
*
|
|
12116
13753
|
* The Web panel/metrics ship with `panel_docs_release`.
|
|
12117
13754
|
*/
|
|
12118
13755
|
feishuTools: z.object({
|
|
@@ -12122,7 +13759,9 @@ const configSchema = z.object({
|
|
|
12122
13759
|
attachmentFetchTimeoutMs: z.number().int().min(1e3).max(6e5).default(DEFAULT_ATTACHMENT_FETCH_TIMEOUT_MS),
|
|
12123
13760
|
topicMessages: z.boolean().default(true),
|
|
12124
13761
|
topicMessagesTimeoutMs: z.number().int().min(1e3).max(12e4).default(DEFAULT_TOPIC_MESSAGES_TIMEOUT_MS),
|
|
12125
|
-
sendFile: z.boolean().default(true)
|
|
13762
|
+
sendFile: z.boolean().default(true),
|
|
13763
|
+
wikiRead: z.boolean().default(false),
|
|
13764
|
+
wikiWrite: z.boolean().default(false)
|
|
12126
13765
|
}).default({
|
|
12127
13766
|
attachmentFetch: true,
|
|
12128
13767
|
attachmentFetchMaxResources: 10,
|
|
@@ -12130,9 +13769,26 @@ const configSchema = z.object({
|
|
|
12130
13769
|
attachmentFetchTimeoutMs: DEFAULT_ATTACHMENT_FETCH_TIMEOUT_MS,
|
|
12131
13770
|
topicMessages: true,
|
|
12132
13771
|
topicMessagesTimeoutMs: DEFAULT_TOPIC_MESSAGES_TIMEOUT_MS,
|
|
12133
|
-
sendFile: true
|
|
13772
|
+
sendFile: true,
|
|
13773
|
+
wikiRead: false,
|
|
13774
|
+
wikiWrite: false
|
|
12134
13775
|
}),
|
|
12135
13776
|
/**
|
|
13777
|
+
* Wiki (知识库) locations the wiki tools may touch: whole spaces
|
|
13778
|
+
* (`https://<tenant>.feishu.cn/wiki/space/<space_id>`) or single nodes with
|
|
13779
|
+
* everything below them (`https://<tenant>.feishu.cn/wiki/<node_token>`).
|
|
13780
|
+
* Browsing, reading, creating, and editing outside these roots fail. Empty
|
|
13781
|
+
* (default) mounts no wiki tool even when `feishuTools.wikiRead`/`wikiWrite`
|
|
13782
|
+
* is on.
|
|
13783
|
+
*/
|
|
13784
|
+
wikiRoots: z.array(z.string().trim().superRefine((link, issue) => {
|
|
13785
|
+
const parsed = parseWikiRootLink(link);
|
|
13786
|
+
if (!parsed.ok) issue.addIssue({
|
|
13787
|
+
code: "custom",
|
|
13788
|
+
message: WIKI_ROOT_LINK_ERRORS[parsed.error]
|
|
13789
|
+
});
|
|
13790
|
+
})).max(20).default([]),
|
|
13791
|
+
/**
|
|
12136
13792
|
* Send a one-time onboarding card on the first private message of a chat
|
|
12137
13793
|
* (facts + command cheat sheet). Persisted per chat; never repeats.
|
|
12138
13794
|
*/
|
|
@@ -12285,6 +13941,9 @@ const EDITABLE_KEYS = [
|
|
|
12285
13941
|
"feishuTools.topicMessages",
|
|
12286
13942
|
"feishuTools.topicMessagesTimeoutMs",
|
|
12287
13943
|
"feishuTools.sendFile",
|
|
13944
|
+
"feishuTools.wikiRead",
|
|
13945
|
+
"feishuTools.wikiWrite",
|
|
13946
|
+
"wikiRoots",
|
|
12288
13947
|
"promptContextTimeoutMs",
|
|
12289
13948
|
"provider",
|
|
12290
13949
|
"model",
|
|
@@ -12395,6 +14054,12 @@ const PANEL_COPY = {
|
|
|
12395
14054
|
discardTab: "放弃本页更改",
|
|
12396
14055
|
saveHintClean: "没有未保存的更改。",
|
|
12397
14056
|
dirtyCount: "未保存项",
|
|
14057
|
+
listAdd: "添加",
|
|
14058
|
+
listRemove: "移除",
|
|
14059
|
+
listEntry: "第 {n} 项",
|
|
14060
|
+
listTooMany: "最多 {max} 项(空输入框不计)",
|
|
14061
|
+
listNotArray: "必须是字符串列表",
|
|
14062
|
+
listEntryNotString: "必须是字符串",
|
|
12398
14063
|
browseDirectory: "选择目录…",
|
|
12399
14064
|
browsingDirectory: "选择中…",
|
|
12400
14065
|
directoryPicked: "已选择目录",
|
|
@@ -12750,6 +14415,7 @@ async function openFeishuDomain(ctx) {
|
|
|
12750
14415
|
const accessGroupsRaw = domain.table("access_groups");
|
|
12751
14416
|
const accessUsersRaw = domain.table("access_users");
|
|
12752
14417
|
const accessRequestsRaw = domain.table("access_requests");
|
|
14418
|
+
const accessWikiUsersRaw = domain.table("access_wiki_users");
|
|
12753
14419
|
return {
|
|
12754
14420
|
routes: {
|
|
12755
14421
|
get: (key) => table.get(key),
|
|
@@ -12793,11 +14459,19 @@ async function openFeishuDomain(ctx) {
|
|
|
12793
14459
|
delete: (key) => accessRequestsRaw.delete(key),
|
|
12794
14460
|
entries: () => accessRequestsRaw.entries()
|
|
12795
14461
|
},
|
|
14462
|
+
accessWikiUsers: {
|
|
14463
|
+
get: (key) => accessWikiUsersRaw.get(key),
|
|
14464
|
+
put: (key, value) => accessWikiUsersRaw.put(key, value),
|
|
14465
|
+
delete: (key) => accessWikiUsersRaw.delete(key),
|
|
14466
|
+
entries: () => accessWikiUsersRaw.entries()
|
|
14467
|
+
},
|
|
12796
14468
|
getGlobal: () => domain.global.get() ?? {},
|
|
12797
14469
|
setGlobal: (value) => domain.global.set(value),
|
|
12798
14470
|
close: () => domain.close()
|
|
12799
14471
|
};
|
|
12800
14472
|
}
|
|
14473
|
+
/** `/auth wiki` usage line (input-error card). */
|
|
14474
|
+
const AUTH_WIKI_USAGE = "用法:/auth wiki add <@用户|user_id> 或 /auth wiki remove <@用户|user_id>。知识库白名单里的用户(以及管理员)发起的话题可以使用知识库工具。";
|
|
12801
14475
|
/**
|
|
12802
14476
|
* Resolve one credential reference through the host credentials seam.
|
|
12803
14477
|
*
|
|
@@ -12866,6 +14540,8 @@ var FeishuService = class {
|
|
|
12866
14540
|
attachmentFetchTools;
|
|
12867
14541
|
topicMessagesTools;
|
|
12868
14542
|
sendFileTools;
|
|
14543
|
+
wikiConsent;
|
|
14544
|
+
wikiTools;
|
|
12869
14545
|
/** FIFO admission for ordinary turns and commands targeting one route. */
|
|
12870
14546
|
routeOperations = new RouteOperationQueue();
|
|
12871
14547
|
/** Active Host command executions, aborted before shutdown tears peers down. */
|
|
@@ -12956,6 +14632,7 @@ var FeishuService = class {
|
|
|
12956
14632
|
if (this.effectiveAttachmentBackend() === "native") mounts.push(this.attachmentFetchTools.mount(agentCtx, sessionId));
|
|
12957
14633
|
mounts.push(this.topicMessagesTools.mount(agentCtx, sessionId));
|
|
12958
14634
|
mounts.push(this.sendFileTools.mount(agentCtx, sessionId));
|
|
14635
|
+
mounts.push(this.wikiTools.mount(agentCtx, sessionId));
|
|
12959
14636
|
return Promise.all(mounts).then(() => void 0);
|
|
12960
14637
|
},
|
|
12961
14638
|
warn: (message, ...args) => this.ctx.logger?.warn?.(message, ...args)
|
|
@@ -13010,6 +14687,21 @@ var FeishuService = class {
|
|
|
13010
14687
|
fetchAvailable: () => this.effectiveAttachmentBackend() === "native",
|
|
13011
14688
|
warn: (message, ...args) => this.ctx.logger?.warn?.(message, ...args)
|
|
13012
14689
|
});
|
|
14690
|
+
this.wikiConsent = new WikiConsent({
|
|
14691
|
+
channel: () => this.started ? this.channel : void 0,
|
|
14692
|
+
timeoutMs: () => this.config.approvalTimeoutMs,
|
|
14693
|
+
onError: (sessionId, error) => {
|
|
14694
|
+
this.ctx.logger?.warn?.("dsh-channels wiki consent error (session %s): %s", sessionId, String(error));
|
|
14695
|
+
}
|
|
14696
|
+
});
|
|
14697
|
+
this.wikiTools = new WikiToolRuntime({
|
|
14698
|
+
config: () => this.config,
|
|
14699
|
+
channel: () => this.started ? this.channel : void 0,
|
|
14700
|
+
routeOfSession: (sessionId) => this.approvalRouteFor(sessionId),
|
|
14701
|
+
mayUseWiki: (userId) => this.accessController.isAdmin(userId) || this.accessStore.hasWikiUser(userId),
|
|
14702
|
+
consent: this.wikiConsent,
|
|
14703
|
+
warn: (message, ...args) => this.ctx.logger?.warn?.(message, ...args)
|
|
14704
|
+
});
|
|
13013
14705
|
this.sendFileTools = new SendFileToolRuntime({
|
|
13014
14706
|
config: () => this.config,
|
|
13015
14707
|
channel: () => this.started ? this.channel : void 0,
|
|
@@ -13696,7 +15388,9 @@ var FeishuService = class {
|
|
|
13696
15388
|
requireMention: this.config.requireMention,
|
|
13697
15389
|
ownerLookupFailed: this.config.accessMode === "guarded" && this.configuredAdminUserId() === void 0 && this.appOwner !== void 0 && "error" in this.appOwner && this.appOwner.apiRejected,
|
|
13698
15390
|
slashCommandPanel: this.config.slashCommandPanel,
|
|
13699
|
-
sendFile: this.config.feishuTools.sendFile
|
|
15391
|
+
sendFile: this.config.feishuTools.sendFile,
|
|
15392
|
+
wikiRead: this.config.feishuTools.wikiRead && this.config.wikiRoots.length > 0,
|
|
15393
|
+
wikiWrite: this.config.feishuTools.wikiWrite && this.config.wikiRoots.length > 0
|
|
13700
15394
|
});
|
|
13701
15395
|
state = result.consoleUrl === void 0 ? {
|
|
13702
15396
|
state: "ok",
|
|
@@ -13890,6 +15584,7 @@ var FeishuService = class {
|
|
|
13890
15584
|
accessRequests.onCardAction(event);
|
|
13891
15585
|
answerer.onCardAction(event);
|
|
13892
15586
|
questions.onCardAction(event);
|
|
15587
|
+
this.wikiConsent.onCardAction(event);
|
|
13893
15588
|
this.onStopAction(event);
|
|
13894
15589
|
this.onPickAction(event).catch((error) => {
|
|
13895
15590
|
this.ctx.logger?.warn?.("dsh-channels: picker card action failed: %s", String(error));
|
|
@@ -13968,6 +15663,7 @@ var FeishuService = class {
|
|
|
13968
15663
|
this.approvalAnswerer?.dispose();
|
|
13969
15664
|
this.approvalAnswerer = void 0;
|
|
13970
15665
|
this.questionAnswerer?.dispose();
|
|
15666
|
+
this.wikiConsent.dispose();
|
|
13971
15667
|
this.questionAnswerer = void 0;
|
|
13972
15668
|
if (this.gcTimer !== void 0) {
|
|
13973
15669
|
clearInterval(this.gcTimer);
|
|
@@ -14097,8 +15793,8 @@ var FeishuService = class {
|
|
|
14097
15793
|
`**准入模式**:\`${this.config.accessMode}\``,
|
|
14098
15794
|
`**管理员**:${this.adminStatusLine()}`,
|
|
14099
15795
|
...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
|
|
15796
|
+
`**白名单**:群 ${groups.length} 个 · 用户 ${users.length} 个 · 知识库 ${this.accessStore.wikiUserRecords().length} 个 · 待处理申请 ${pending.length} 个`,
|
|
15797
|
+
"群授权:`/auth allow` 或 `/auth revoke`(在目标群内发);用户授权:`/auth user add @用户` 或 `/auth user add <user_id>`;知识库授权:`/auth wiki add @用户`。"
|
|
14102
15798
|
]), direct);
|
|
14103
15799
|
return;
|
|
14104
15800
|
}
|
|
@@ -14195,17 +15891,68 @@ var FeishuService = class {
|
|
|
14195
15891
|
}, direct);
|
|
14196
15892
|
return;
|
|
14197
15893
|
}
|
|
15894
|
+
if (action === "wiki") {
|
|
15895
|
+
const sub = words[1];
|
|
15896
|
+
const target = sub === "add" || sub === "remove" ? resolveAuthUserTarget(words.slice(2).join(" "), msg.mentions, this.channel?.botOpenId()) : {
|
|
15897
|
+
ok: false,
|
|
15898
|
+
reason: "usage"
|
|
15899
|
+
};
|
|
15900
|
+
if (!target.ok) {
|
|
15901
|
+
await this.delivery.commandCard(msg, {
|
|
15902
|
+
kind: "input-error",
|
|
15903
|
+
command: "auth",
|
|
15904
|
+
text: target.reason === "usage" ? AUTH_WIKI_USAGE : AUTH_USER_TARGET_ERRORS[target.reason]
|
|
15905
|
+
}, direct);
|
|
15906
|
+
return;
|
|
15907
|
+
}
|
|
15908
|
+
const { userId } = target;
|
|
15909
|
+
const label = target.name === void 0 ? `\`${userId}\`` : `${target.name}(\`${userId}\`)`;
|
|
15910
|
+
if (sub === "add") {
|
|
15911
|
+
if (this.accessStore.hasWikiUser(userId)) {
|
|
15912
|
+
await this.delivery.commandCard(msg, {
|
|
15913
|
+
kind: "success",
|
|
15914
|
+
command: "auth",
|
|
15915
|
+
text: `该用户已在知识库白名单中:${label}。`
|
|
15916
|
+
}, direct);
|
|
15917
|
+
return;
|
|
15918
|
+
}
|
|
15919
|
+
await this.accessStore.addWikiUser({
|
|
15920
|
+
userId,
|
|
15921
|
+
...target.name === void 0 ? {} : { name: target.name },
|
|
15922
|
+
source: "command",
|
|
15923
|
+
addedBy: msg.senderId,
|
|
15924
|
+
addedAt: now
|
|
15925
|
+
});
|
|
15926
|
+
await this.delivery.commandCard(msg, {
|
|
15927
|
+
kind: "success",
|
|
15928
|
+
command: "auth",
|
|
15929
|
+
text: `✅ 已把 ${label} 加入知识库白名单:他发起的话题里可以使用知识库工具(每次读取、新建、修改仍需他在授权卡上确认)。`
|
|
15930
|
+
}, direct);
|
|
15931
|
+
return;
|
|
15932
|
+
}
|
|
15933
|
+
const removed = await this.accessStore.removeWikiUser(userId);
|
|
15934
|
+
await this.delivery.commandCard(msg, {
|
|
15935
|
+
kind: removed ? "success" : "unavailable",
|
|
15936
|
+
command: "auth",
|
|
15937
|
+
text: removed ? `已把 ${label} 移出知识库白名单,立即生效。` : `用户 ${label} 不在知识库白名单中。`
|
|
15938
|
+
}, direct);
|
|
15939
|
+
return;
|
|
15940
|
+
}
|
|
14198
15941
|
if (action === "list") {
|
|
14199
15942
|
const groups = this.accessStore.groupRecords();
|
|
14200
15943
|
const users = this.accessStore.userRecords();
|
|
14201
15944
|
const pending = this.accessStore.pendingRequests();
|
|
14202
15945
|
const groupLines = groups.length === 0 ? ["(暂无)"] : groups.slice(0, 30).map((group) => `- ${group.name ?? group.chatId} \`${group.chatId}\``);
|
|
14203
15946
|
const userLines = users.length === 0 ? ["(暂无)"] : users.slice(0, 30).map((user) => `- ${user.name ?? user.userId} \`${user.userId}\``);
|
|
15947
|
+
const wikiUsers = this.accessStore.wikiUserRecords();
|
|
15948
|
+
const wikiLines = wikiUsers.length === 0 ? ["(暂无;管理员始终可用)"] : wikiUsers.slice(0, 30).map((user) => `- ${user.name ?? user.userId} \`${user.userId}\``);
|
|
14204
15949
|
await this.delivery.standaloneReply(msg, standaloneInfoCard("**🔐 访问授权清单**", [
|
|
14205
15950
|
`**已授权群(${groups.length})**`,
|
|
14206
15951
|
...groupLines,
|
|
14207
15952
|
`**已授权用户(${users.length})**`,
|
|
14208
15953
|
...userLines,
|
|
15954
|
+
`**知识库白名单(${wikiUsers.length})**`,
|
|
15955
|
+
...wikiLines,
|
|
14209
15956
|
`**待处理申请**:${pending.length} 个(可在 Web 设置面板处理)`
|
|
14210
15957
|
]), direct);
|
|
14211
15958
|
return;
|
|
@@ -14213,7 +15960,7 @@ var FeishuService = class {
|
|
|
14213
15960
|
await this.delivery.commandCard(msg, {
|
|
14214
15961
|
kind: "input-error",
|
|
14215
15962
|
command: "auth",
|
|
14216
|
-
text: "用法:/auth [status|allow|revoke|list|user add <@用户|user_id>|user remove <@用户|user_id>]"
|
|
15963
|
+
text: "用法:/auth [status|allow|revoke|list|user add <@用户|user_id>|user remove <@用户|user_id>|wiki add <@用户|user_id>|wiki remove <@用户|user_id>]"
|
|
14217
15964
|
}, direct);
|
|
14218
15965
|
} catch (error) {
|
|
14219
15966
|
this.ctx.logger?.warn?.("dsh-channels: /auth failed: %s", String(error));
|
|
@@ -14359,7 +16106,8 @@ var FeishuService = class {
|
|
|
14359
16106
|
/**
|
|
14360
16107
|
* Picker-card click: run the chosen `/<command> <arg>` exactly as if the
|
|
14361
16108
|
* operator had typed it under the picker card, so access admission, route
|
|
14362
|
-
* ownership and validation are the typed command's.
|
|
16109
|
+
* ownership and validation are the typed command's. The result replaces
|
|
16110
|
+
* the picker card in place instead of arriving as a new card. Stale pickers (the
|
|
14363
16111
|
* topic was rebound, or the card was forwarded elsewhere) and non-owner
|
|
14364
16112
|
* clicks are ignored.
|
|
14365
16113
|
*/
|
|
@@ -14371,7 +16119,7 @@ var FeishuService = class {
|
|
|
14371
16119
|
const userId = event.operator.userId;
|
|
14372
16120
|
if (route === void 0 || route.chatId !== event.chatId || userId !== route.senderUserId) return;
|
|
14373
16121
|
const line = `/${action.command} ${action.arg}`;
|
|
14374
|
-
await this.handleSlashMessage({
|
|
16122
|
+
await this.delivery.replacingCard(event.messageId, () => this.handleSlashMessage({
|
|
14375
16123
|
messageId: event.messageId,
|
|
14376
16124
|
chatId: route.chatId,
|
|
14377
16125
|
chatType: route.chatType,
|
|
@@ -14384,7 +16132,7 @@ var FeishuService = class {
|
|
|
14384
16132
|
mentionedBot: true,
|
|
14385
16133
|
...route.kind === "thread" ? { threadId: route.key } : {},
|
|
14386
16134
|
createTime: Date.now()
|
|
14387
|
-
}, line);
|
|
16135
|
+
}, line));
|
|
14388
16136
|
}
|
|
14389
16137
|
/**
|
|
14390
16138
|
* `/permissions`: show this Session's effective sandbox+approval preset and
|
|
@@ -14565,7 +16313,7 @@ var FeishuService = class {
|
|
|
14565
16313
|
return;
|
|
14566
16314
|
}
|
|
14567
16315
|
if (spec.name === "health" && result.kind === "success") {
|
|
14568
|
-
await this.delivery.standaloneReply(msg, buildHealthCard(this.healthSnapshot()));
|
|
16316
|
+
await this.delivery.standaloneReply(msg, buildHealthCard(this.healthSnapshot()), { createThread: false });
|
|
14569
16317
|
return;
|
|
14570
16318
|
}
|
|
14571
16319
|
if (spec.name === "status" && result.kind === "success") {
|
|
@@ -15488,8 +17236,81 @@ const BOOLEAN_KEYS = /* @__PURE__ */ new Set([
|
|
|
15488
17236
|
"prefetchImages",
|
|
15489
17237
|
"feishuTools.attachmentFetch",
|
|
15490
17238
|
"feishuTools.topicMessages",
|
|
15491
|
-
"feishuTools.sendFile"
|
|
17239
|
+
"feishuTools.sendFile",
|
|
17240
|
+
"feishuTools.wikiRead",
|
|
17241
|
+
"feishuTools.wikiWrite"
|
|
15492
17242
|
]);
|
|
17243
|
+
/** Editable keys rendered as `string-list` (one input per entry). */
|
|
17244
|
+
const STRING_LIST_FIELDS = { wikiRoots: {
|
|
17245
|
+
maxItems: 20,
|
|
17246
|
+
itemFormat: "wiki-root-link"
|
|
17247
|
+
} };
|
|
17248
|
+
/** Entry checks per item format: the readable reason, or `undefined` when valid. */
|
|
17249
|
+
const ITEM_FORMAT_CHECKS = { "wiki-root-link": (entry) => {
|
|
17250
|
+
const parsed = parseWikiRootLink(entry);
|
|
17251
|
+
return parsed.ok ? void 0 : WIKI_ROOT_LINK_ERRORS[parsed.error];
|
|
17252
|
+
} };
|
|
17253
|
+
/** `第 N 项「entry」:reason`, shared wording with the Client check. */
|
|
17254
|
+
function listEntryMessage(index, entry, reason) {
|
|
17255
|
+
const position = PANEL_COPY.listEntry.replace("{n}", String(index + 1));
|
|
17256
|
+
return entry === void 0 ? `${position}${reason}` : `${position}「${entry}」:${reason}`;
|
|
17257
|
+
}
|
|
17258
|
+
/**
|
|
17259
|
+
* Normalize one submitted `string-list` value: entries are trimmed, empty
|
|
17260
|
+
* entries dropped, the count bounded by `maxItems`, and every remaining entry
|
|
17261
|
+
* checked against `itemFormat`. Issue indexes refer to the submitted array
|
|
17262
|
+
* (empty inputs included), so the panel can mark the exact input.
|
|
17263
|
+
*
|
|
17264
|
+
* @param key - The editable key the issues are reported under.
|
|
17265
|
+
* @param raw - The untrusted submitted value.
|
|
17266
|
+
* @param spec - The field's bounds and entry format.
|
|
17267
|
+
* @returns The cleaned entries, or every per-entry / whole-field issue.
|
|
17268
|
+
*/
|
|
17269
|
+
function normalizeStringList(key, raw, spec) {
|
|
17270
|
+
if (!Array.isArray(raw)) return {
|
|
17271
|
+
ok: false,
|
|
17272
|
+
issues: [{
|
|
17273
|
+
key,
|
|
17274
|
+
message: PANEL_COPY.listNotArray
|
|
17275
|
+
}]
|
|
17276
|
+
};
|
|
17277
|
+
const issues = [];
|
|
17278
|
+
const value = [];
|
|
17279
|
+
const check = spec.itemFormat === void 0 ? void 0 : ITEM_FORMAT_CHECKS[spec.itemFormat];
|
|
17280
|
+
raw.forEach((item, index) => {
|
|
17281
|
+
if (typeof item !== "string") {
|
|
17282
|
+
issues.push({
|
|
17283
|
+
key,
|
|
17284
|
+
index,
|
|
17285
|
+
message: listEntryMessage(index, void 0, PANEL_COPY.listEntryNotString)
|
|
17286
|
+
});
|
|
17287
|
+
return;
|
|
17288
|
+
}
|
|
17289
|
+
const entry = item.trim();
|
|
17290
|
+
if (entry === "") return;
|
|
17291
|
+
const reason = check?.(entry);
|
|
17292
|
+
if (reason !== void 0) {
|
|
17293
|
+
issues.push({
|
|
17294
|
+
key,
|
|
17295
|
+
index,
|
|
17296
|
+
message: listEntryMessage(index, entry, reason)
|
|
17297
|
+
});
|
|
17298
|
+
return;
|
|
17299
|
+
}
|
|
17300
|
+
value.push(entry);
|
|
17301
|
+
});
|
|
17302
|
+
if (raw.filter((item) => typeof item !== "string" || item.trim() !== "").length > spec.maxItems) issues.push({
|
|
17303
|
+
key,
|
|
17304
|
+
message: PANEL_COPY.listTooMany.replace("{max}", String(spec.maxItems))
|
|
17305
|
+
});
|
|
17306
|
+
return issues.length > 0 ? {
|
|
17307
|
+
ok: false,
|
|
17308
|
+
issues
|
|
17309
|
+
} : {
|
|
17310
|
+
ok: true,
|
|
17311
|
+
value
|
|
17312
|
+
};
|
|
17313
|
+
}
|
|
15493
17314
|
const SELECT_OPTIONS = {
|
|
15494
17315
|
attachmentStore: [{
|
|
15495
17316
|
value: "native",
|
|
@@ -15673,6 +17494,20 @@ const FIELD_META = {
|
|
|
15673
17494
|
label: "启用 feishu_send_file 工具",
|
|
15674
17495
|
help: "让模型把当前话题工作目录里生成的图片/文件发回当前会话(图片 ≤10 MB,文件 ≤30 MB)。需开通 im:resource 权限。对新会话生效。"
|
|
15675
17496
|
},
|
|
17497
|
+
"feishuTools.wikiRead": {
|
|
17498
|
+
label: "启用知识库读取工具",
|
|
17499
|
+
help: "让模型浏览「知识库范围」内的知识库节点并读取 docx 文档(feishu_wiki_list / feishu_wiki_read),以应用身份访问。需开通 wiki:wiki(或 wiki:wiki:readonly)与 docx:document(或 docx:document:readonly)权限。只有话题所有者是管理员或在知识库白名单(/auth wiki add @成员)中才能使用;打开文档前会在授权卡上征得话题所有者同意,每个话题一次。知识库范围为空时不挂载。对新会话生效。"
|
|
17500
|
+
},
|
|
17501
|
+
"feishuTools.wikiWrite": {
|
|
17502
|
+
label: "启用知识库写入工具",
|
|
17503
|
+
help: "让模型在「知识库范围」内新建 docx 文档,并编辑本应用拥有的文档(feishu_wiki_create / feishu_wiki_edit),以应用身份访问;每次新建或编辑都需话题所有者在审批卡上同意。需开通 wiki:wiki 与 docx:document 权限;使用条件同读取工具(管理员或知识库白名单)。知识库范围为空时不挂载。对新会话生效。"
|
|
17504
|
+
},
|
|
17505
|
+
wikiRoots: {
|
|
17506
|
+
label: "知识库范围(wikiRoots)",
|
|
17507
|
+
help: "知识库工具只能访问这些位置,每个输入框填一个链接:整个知识空间 https://<租户>.feishu.cn/wiki/space/<space_id>,或节点 https://<租户>.feishu.cn/wiki/<node_token>(该节点及其下所有节点);最多 20 项,空输入框保存时忽略。留空 = 不挂载任何知识库工具(即使上面的开关已开启)。对新会话生效。",
|
|
17508
|
+
placeholder: "https://<租户>.feishu.cn/wiki/space/<space_id>",
|
|
17509
|
+
wide: true
|
|
17510
|
+
},
|
|
15676
17511
|
provider: {
|
|
15677
17512
|
label: "模型服务商",
|
|
15678
17513
|
help: "留空 = 使用部署默认;例如 deepseek-official。",
|
|
@@ -15738,6 +17573,9 @@ const GROUP_OF = {
|
|
|
15738
17573
|
"feishuTools.topicMessages": "tools",
|
|
15739
17574
|
"feishuTools.topicMessagesTimeoutMs": "tools",
|
|
15740
17575
|
"feishuTools.sendFile": "tools",
|
|
17576
|
+
"feishuTools.wikiRead": "tools",
|
|
17577
|
+
"feishuTools.wikiWrite": "tools",
|
|
17578
|
+
wikiRoots: "tools",
|
|
15741
17579
|
provider: "model",
|
|
15742
17580
|
model: "model",
|
|
15743
17581
|
reasoningEffort: "model",
|
|
@@ -15750,10 +17588,11 @@ const GROUP_OF = {
|
|
|
15750
17588
|
const FIELD_DESCRIPTORS = EDITABLE_KEYS.map((key) => {
|
|
15751
17589
|
const meta = FIELD_META[key];
|
|
15752
17590
|
const options = SELECT_OPTIONS[key];
|
|
17591
|
+
const list = STRING_LIST_FIELDS[key];
|
|
15753
17592
|
return {
|
|
15754
17593
|
key,
|
|
15755
17594
|
label: meta.label,
|
|
15756
|
-
kind: BOOLEAN_KEYS.has(key) ? "boolean" : options !== void 0 ? "select" : meta.min !== void 0 ? "number" : "text",
|
|
17595
|
+
kind: list !== void 0 ? "string-list" : BOOLEAN_KEYS.has(key) ? "boolean" : options !== void 0 ? "select" : meta.min !== void 0 ? "number" : "text",
|
|
15757
17596
|
group: GROUP_OF[key],
|
|
15758
17597
|
help: meta.help,
|
|
15759
17598
|
...options === void 0 ? {} : { options },
|
|
@@ -15761,6 +17600,8 @@ const FIELD_DESCRIPTORS = EDITABLE_KEYS.map((key) => {
|
|
|
15761
17600
|
...meta.max === void 0 ? {} : { max: meta.max },
|
|
15762
17601
|
...meta.step === void 0 ? {} : { step: meta.step },
|
|
15763
17602
|
...meta.placeholder === void 0 ? {} : { placeholder: meta.placeholder },
|
|
17603
|
+
...list === void 0 ? {} : { maxItems: list.maxItems },
|
|
17604
|
+
...list?.itemFormat === void 0 ? {} : { itemFormat: list.itemFormat },
|
|
15764
17605
|
...meta.wide === void 0 ? {} : { wide: meta.wide },
|
|
15765
17606
|
restartRequired: RESTART_SET.has(key)
|
|
15766
17607
|
};
|
|
@@ -16166,6 +18007,7 @@ var SettingsSurface = class {
|
|
|
16166
18007
|
};
|
|
16167
18008
|
const raw = {};
|
|
16168
18009
|
const clearedKeys = [];
|
|
18010
|
+
const listIssues = [];
|
|
16169
18011
|
for (const key of EDITABLE_KEYS) {
|
|
16170
18012
|
const value = values[key];
|
|
16171
18013
|
if (value === void 0) continue;
|
|
@@ -16173,16 +18015,23 @@ var SettingsSurface = class {
|
|
|
16173
18015
|
clearedKeys.push(key);
|
|
16174
18016
|
continue;
|
|
16175
18017
|
}
|
|
18018
|
+
const list = STRING_LIST_FIELDS[key];
|
|
18019
|
+
if (list !== void 0) {
|
|
18020
|
+
const normalized = normalizeStringList(key, value, list);
|
|
18021
|
+
if (normalized.ok) setOverlayValue(raw, key, normalized.value);
|
|
18022
|
+
else listIssues.push(...normalized.issues);
|
|
18023
|
+
continue;
|
|
18024
|
+
}
|
|
16176
18025
|
setOverlayValue(raw, key, value);
|
|
16177
18026
|
}
|
|
16178
18027
|
const parsed = parseOverlay(raw);
|
|
16179
|
-
if (!parsed.ok) return {
|
|
18028
|
+
if (!parsed.ok || listIssues.length > 0) return {
|
|
16180
18029
|
ok: false,
|
|
16181
18030
|
error: "配置校验失败",
|
|
16182
|
-
issues: parsed.issues.map((issue) => ({
|
|
18031
|
+
issues: [...listIssues, ...parsed.ok ? [] : parsed.issues.map((issue) => ({
|
|
16183
18032
|
key: issue.path,
|
|
16184
18033
|
message: issue.message
|
|
16185
|
-
}))
|
|
18034
|
+
}))]
|
|
16186
18035
|
};
|
|
16187
18036
|
if (parsed.patch.baseDir !== void 0) {
|
|
16188
18037
|
const problem = await prepareWorkspaceBaseDir(parsed.patch.baseDir);
|