@modesto_ma/dsh-channels 0.5.0-beta.1 → 0.5.0-beta.3

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