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

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