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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/index.js CHANGED
@@ -3400,26 +3400,72 @@ const SEND_FILE_REQUIREMENT = {
3400
3400
  level: "optional",
3401
3401
  purpose: "上传图片/文件,让模型用 feishu_send_file 把生成的文件发给用户"
3402
3402
  };
3403
- /** Wiki read needs (`feishu_wiki_list` / `feishu_wiki_read`). */
3404
- const WIKI_READ_REQUIREMENTS = [{
3405
- anyOf: ["wiki:wiki", "wiki:wiki:readonly"],
3403
+ /** Spreadsheet and Base read needs, shared by the wiki read and write sets. */
3404
+ const SHEET_BASE_READ_REQUIREMENTS = [{
3405
+ anyOf: [
3406
+ "sheets:spreadsheet",
3407
+ "sheets:spreadsheet:readonly",
3408
+ "sheets:spreadsheet:read"
3409
+ ],
3406
3410
  level: "optional",
3407
- purpose: "浏览知识库节点(feishu_wiki_list / feishu_wiki_read)"
3411
+ purpose: "读取知识库电子表格(feishu_wiki_sheet_read)"
3408
3412
  }, {
3409
- anyOf: ["docx:document", "docx:document:readonly"],
3413
+ anyOf: [
3414
+ "bitable:app",
3415
+ "bitable:app:readonly",
3416
+ "base:record:read"
3417
+ ],
3410
3418
  level: "optional",
3411
- purpose: "读取知识库中的文档内容(feishu_wiki_read)"
3419
+ purpose: "读取知识库多维表格(feishu_wiki_base_read)"
3412
3420
  }];
3421
+ /** Wiki read needs (`feishu_wiki_list` / `feishu_wiki_read` and the sheet/base read tools). */
3422
+ const WIKI_READ_REQUIREMENTS = [
3423
+ {
3424
+ anyOf: ["wiki:wiki", "wiki:wiki:readonly"],
3425
+ level: "optional",
3426
+ purpose: "浏览知识库节点(feishu_wiki_list / feishu_wiki_read)"
3427
+ },
3428
+ {
3429
+ anyOf: ["docx:document", "docx:document:readonly"],
3430
+ level: "optional",
3431
+ purpose: "读取知识库中的文档内容(feishu_wiki_read)"
3432
+ },
3433
+ ...SHEET_BASE_READ_REQUIREMENTS
3434
+ ];
3413
3435
  /** 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
- }];
3436
+ const WIKI_WRITE_REQUIREMENTS = [
3437
+ {
3438
+ anyOf: ["wiki:wiki"],
3439
+ level: "optional",
3440
+ purpose: "在知识库中新建节点(feishu_wiki_create)"
3441
+ },
3442
+ {
3443
+ anyOf: ["docx:document"],
3444
+ level: "optional",
3445
+ purpose: "写入与修改知识库文档内容(feishu_wiki_create / feishu_wiki_edit)"
3446
+ },
3447
+ {
3448
+ anyOf: ["sheets:spreadsheet", "sheets:spreadsheet:write_only"],
3449
+ level: "optional",
3450
+ purpose: "写入知识库电子表格(feishu_wiki_sheet_edit)"
3451
+ },
3452
+ {
3453
+ anyOf: ["bitable:app", "base:record:create"],
3454
+ level: "optional",
3455
+ purpose: "新增知识库多维表格记录(feishu_wiki_base_edit)"
3456
+ },
3457
+ {
3458
+ anyOf: ["bitable:app", "base:record:update"],
3459
+ level: "optional",
3460
+ purpose: "修改本应用创建的多维表格中的记录(feishu_wiki_base_edit)"
3461
+ },
3462
+ {
3463
+ anyOf: ["bitable:app", "base:record:delete"],
3464
+ level: "optional",
3465
+ purpose: "删除本应用创建的多维表格中的记录(feishu_wiki_base_edit)"
3466
+ },
3467
+ ...SHEET_BASE_READ_REQUIREMENTS
3468
+ ];
3423
3469
  /** Group receive needs while ordinary group messages require an @ mention. */
3424
3470
  const MENTION_GROUP_REQUIREMENTS = [{
3425
3471
  anyOf: ["im:message.group_at_msg:readonly", "im:message.group_msg"],
@@ -3548,6 +3594,14 @@ function thrownError(error) {
3548
3594
  if (code === void 0 && msg === void 0) return new FeishuApiError(error instanceof Error ? error.message : String(error));
3549
3595
  return new FeishuApiError([code === void 0 ? void 0 : `code=${code}`, msg].filter(Boolean).join(" "), code);
3550
3596
  }
3597
+ /**
3598
+ * One Feishu OpenAPI call: unwraps `data` and maps a transport error or a
3599
+ * non-zero `code` to {@link FeishuApiError}.
3600
+ *
3601
+ * @param request The SDK's generic request.
3602
+ * @param input Method, URL, query, and body.
3603
+ * @returns The response `data`.
3604
+ */
3551
3605
  async function call(request, input) {
3552
3606
  let response;
3553
3607
  try {
@@ -3574,6 +3628,12 @@ function wikiNode(raw, where) {
3574
3628
  ...raw.url === void 0 || raw.url === "" ? {} : { url: raw.url }
3575
3629
  };
3576
3630
  }
3631
+ /**
3632
+ * Encode one URL path segment.
3633
+ *
3634
+ * @param value Raw segment.
3635
+ * @returns The encoded segment.
3636
+ */
3577
3637
  function segment(value) {
3578
3638
  return encodeURIComponent(value);
3579
3639
  }
@@ -3618,13 +3678,13 @@ function createWikiApi(request) {
3618
3678
  ...data.has_more === true && data.page_token !== void 0 && data.page_token !== "" ? { pageToken: data.page_token } : {}
3619
3679
  };
3620
3680
  },
3621
- async createDocxNode(spaceId, parentNodeToken, title) {
3681
+ async createNode(spaceId, parentNodeToken, title, objType) {
3622
3682
  const url = `/open-apis/wiki/v2/spaces/${segment(spaceId)}/nodes`;
3623
3683
  return wikiNode((await call(request, {
3624
3684
  method: "POST",
3625
3685
  url,
3626
3686
  data: {
3627
- obj_type: "docx",
3687
+ obj_type: objType,
3628
3688
  node_type: "origin",
3629
3689
  title,
3630
3690
  ...parentNodeToken === void 0 ? {} : { parent_node_token: parentNodeToken }
@@ -3695,6 +3755,292 @@ function createWikiApi(request) {
3695
3755
  };
3696
3756
  }
3697
3757
  //#endregion
3758
+ //#region src/feishu/wiki/base-api.ts
3759
+ /**
3760
+ * Feishu Base (多维表格) calls used by the wiki base tools, made with the app
3761
+ * identity (tenant access token).
3762
+ *
3763
+ * Everything goes through `base/v3/bases/:base_token/...` (the legacy
3764
+ * `bitable/v1` API is not used). Record reads return a column matrix that
3765
+ * this module turns into one field map per record; record writes take flat
3766
+ * maps keyed by field name or field id. Writes carry no precondition, are
3767
+ * applied asynchronously (a read straight after a write may not show it), and
3768
+ * must not run in parallel on one table.
3769
+ *
3770
+ * @module dsh-channels/feishu/wiki/base-api
3771
+ */
3772
+ function stringArray(value) {
3773
+ return Array.isArray(value) && value.every((item) => typeof item === "string") ? value : void 0;
3774
+ }
3775
+ function ignoredFieldsOf(value) {
3776
+ if (!Array.isArray(value)) return [];
3777
+ return value.flatMap((item) => {
3778
+ if (typeof item !== "object" || item === null) return [];
3779
+ const { name, id, reason } = item;
3780
+ const label = typeof name === "string" && name !== "" ? name : typeof id === "string" ? id : "?";
3781
+ return [typeof reason === "string" && reason !== "" ? `${label}: ${reason}` : label];
3782
+ });
3783
+ }
3784
+ function recordPage(raw, where) {
3785
+ const names = stringArray(raw.fields) ?? [];
3786
+ const ids = stringArray(raw.record_id_list) ?? [];
3787
+ const rows = Array.isArray(raw.data) ? raw.data : [];
3788
+ if (rows.length !== ids.length) throw new FeishuApiError(`${where}: record ids and rows differ in length`);
3789
+ return {
3790
+ records: ids.map((recordId, index) => {
3791
+ const row = rows[index];
3792
+ if (!Array.isArray(row) || row.length !== names.length) throw new FeishuApiError(`${where}: row ${index} does not match the field list`);
3793
+ return {
3794
+ recordId,
3795
+ fields: Object.fromEntries(names.map((name, column) => [name, row[column] ?? null]))
3796
+ };
3797
+ }),
3798
+ hasMore: raw.has_more === true,
3799
+ ...typeof raw.rev === "number" ? { rev: raw.rev } : {},
3800
+ ...typeof raw.timezone === "string" && raw.timezone !== "" ? { timezone: raw.timezone } : {},
3801
+ ignoredFields: ignoredFieldsOf(raw.ignored_fields)
3802
+ };
3803
+ }
3804
+ /**
3805
+ * {@link BaseApi} over the SDK's generic authenticated request.
3806
+ *
3807
+ * @param request `Client.request` of the connected SDK client.
3808
+ * @returns The API.
3809
+ */
3810
+ function createBaseApi(request) {
3811
+ const tablePath = (baseToken, tableId) => `/open-apis/base/v3/bases/${segment(baseToken)}/tables/${segment(tableId)}`;
3812
+ return {
3813
+ async listTables(baseToken) {
3814
+ const data = await call(request, {
3815
+ method: "GET",
3816
+ url: `/open-apis/base/v3/bases/${segment(baseToken)}/tables`,
3817
+ params: {
3818
+ offset: 0,
3819
+ limit: 300
3820
+ }
3821
+ });
3822
+ return (Array.isArray(data.tables) ? data.tables : Array.isArray(data.items) ? data.items : []).flatMap((item) => {
3823
+ if (typeof item !== "object" || item === null) return [];
3824
+ const { id, table_id, name, table_name } = item;
3825
+ const tableId = typeof id === "string" ? id : typeof table_id === "string" ? table_id : void 0;
3826
+ if (tableId === void 0) return [];
3827
+ return [{
3828
+ tableId,
3829
+ name: typeof name === "string" ? name : typeof table_name === "string" ? table_name : ""
3830
+ }];
3831
+ });
3832
+ },
3833
+ async listFields(baseToken, tableId) {
3834
+ const data = await call(request, {
3835
+ method: "GET",
3836
+ url: `${tablePath(baseToken, tableId)}/fields`,
3837
+ params: {
3838
+ offset: 0,
3839
+ limit: 300
3840
+ }
3841
+ });
3842
+ return (Array.isArray(data.fields) ? data.fields : Array.isArray(data.items) ? data.items : []).flatMap((item) => {
3843
+ if (typeof item !== "object" || item === null) return [];
3844
+ const { id, field_id, name, field_name, type, ...properties } = item;
3845
+ const fieldId = typeof id === "string" ? id : typeof field_id === "string" ? field_id : void 0;
3846
+ if (fieldId === void 0) return [];
3847
+ return [{
3848
+ fieldId,
3849
+ name: typeof name === "string" ? name : typeof field_name === "string" ? field_name : "",
3850
+ type: typeof type === "string" ? type : "not_support",
3851
+ properties
3852
+ }];
3853
+ });
3854
+ },
3855
+ async listRecords(baseToken, tableId, query) {
3856
+ const url = `${tablePath(baseToken, tableId)}/records`;
3857
+ const params = new URLSearchParams({
3858
+ offset: String(query.offset),
3859
+ limit: String(query.limit)
3860
+ });
3861
+ for (const field of query.fields ?? []) params.append("field_id", field);
3862
+ if (query.viewId !== void 0) params.set("view_id", query.viewId);
3863
+ if (query.filter !== void 0) params.set("filter", JSON.stringify(query.filter));
3864
+ if (query.sort !== void 0) params.set("sort", JSON.stringify(query.sort));
3865
+ return recordPage(await call(request, {
3866
+ method: "GET",
3867
+ url: `${url}?${params.toString()}`
3868
+ }), url);
3869
+ },
3870
+ async searchRecords(baseToken, tableId, search) {
3871
+ const url = `${tablePath(baseToken, tableId)}/records/search`;
3872
+ return recordPage(await call(request, {
3873
+ method: "POST",
3874
+ url,
3875
+ data: {
3876
+ keyword: search.keyword,
3877
+ search_fields: search.searchFields,
3878
+ ...search.fields === void 0 ? {} : { select_fields: search.fields },
3879
+ ...search.viewId === void 0 ? {} : { view_id: search.viewId },
3880
+ ...search.filter === void 0 ? {} : { filter: search.filter },
3881
+ ...search.sort === void 0 ? {} : { sort: search.sort },
3882
+ offset: search.offset,
3883
+ limit: search.limit
3884
+ }
3885
+ }), url);
3886
+ },
3887
+ async createRecords(baseToken, tableId, records) {
3888
+ return stringArray((await call(request, {
3889
+ method: "POST",
3890
+ url: `${tablePath(baseToken, tableId)}/records/batch_create`,
3891
+ data: { create_records: records }
3892
+ })).record_id_list) ?? [];
3893
+ },
3894
+ async updateRecords(baseToken, tableId, updates) {
3895
+ return ignoredFieldsOf((await call(request, {
3896
+ method: "POST",
3897
+ url: `${tablePath(baseToken, tableId)}/records/batch_update`,
3898
+ data: { update_records: updates }
3899
+ })).ignored_fields);
3900
+ },
3901
+ async deleteRecords(baseToken, tableId, recordIds) {
3902
+ await call(request, {
3903
+ method: "POST",
3904
+ url: `${tablePath(baseToken, tableId)}/records/batch_delete`,
3905
+ data: { record_id_list: recordIds }
3906
+ });
3907
+ }
3908
+ };
3909
+ }
3910
+ //#endregion
3911
+ //#region src/feishu/wiki/sheet-api.ts
3912
+ /**
3913
+ * Feishu spreadsheet (电子表格) calls used by the wiki sheet tools, made with
3914
+ * the app identity (tenant access token).
3915
+ *
3916
+ * Everything goes through the `sheet_ai/v2` tool endpoints: `POST
3917
+ * spreadsheets/:token/tools/invoke_read` (or `invoke_write`) with body
3918
+ * `{tool_name, input}`, where `input` is a JSON string that also carries
3919
+ * `excel_id`, and the result is the JSON string `data.output`. Read tools sent
3920
+ * to `invoke_write` (or the reverse) are rejected by the gateway. Writes carry
3921
+ * no base revision and no idempotency key; callers check the workbook
3922
+ * revision before writing.
3923
+ *
3924
+ * @module dsh-channels/feishu/wiki/sheet-api
3925
+ */
3926
+ function invokePath(token, kind) {
3927
+ return `/open-apis/sheet_ai/v2/spreadsheets/${segment(token)}/tools/invoke_${kind}`;
3928
+ }
3929
+ async function invoke(request, token, kind, toolName, input) {
3930
+ const data = await call(request, {
3931
+ method: "POST",
3932
+ url: invokePath(token, kind),
3933
+ data: {
3934
+ tool_name: toolName,
3935
+ input: JSON.stringify({
3936
+ excel_id: token,
3937
+ ...input
3938
+ })
3939
+ }
3940
+ });
3941
+ if (typeof data.output !== "string" || data.output === "") return {};
3942
+ let output;
3943
+ try {
3944
+ output = JSON.parse(data.output);
3945
+ } catch (error) {
3946
+ throw new FeishuApiError(`${toolName}: output is not JSON: ${error instanceof Error ? error.message : String(error)}`);
3947
+ }
3948
+ if (Array.isArray(output)) return { sheets: output };
3949
+ if (typeof output !== "object" || output === null) throw new FeishuApiError(`${toolName}: output is not an object`);
3950
+ return output;
3951
+ }
3952
+ function optionalString(value) {
3953
+ return typeof value === "string" && value !== "" ? value : void 0;
3954
+ }
3955
+ function optionalNumber(value) {
3956
+ return typeof value === "number" && Number.isFinite(value) ? value : void 0;
3957
+ }
3958
+ function sheetInfo(raw, position) {
3959
+ const sheetId = optionalString(raw.sheet_id);
3960
+ if (sheetId === void 0) return void 0;
3961
+ const rowCount = optionalNumber(raw.row_count);
3962
+ const columnCount = optionalNumber(raw.column_count);
3963
+ return {
3964
+ sheetId,
3965
+ title: optionalString(raw.title) ?? optionalString(raw.sheet_name) ?? "",
3966
+ index: optionalNumber(raw.index) ?? position,
3967
+ resourceType: optionalString(raw.resource_type) ?? "sheet",
3968
+ hidden: raw.hidden === true || raw.is_hidden === true,
3969
+ ...rowCount === void 0 ? {} : { rowCount },
3970
+ ...columnCount === void 0 ? {} : { columnCount }
3971
+ };
3972
+ }
3973
+ function cellOf(value) {
3974
+ if (value === null) return {};
3975
+ if (typeof value === "string" && value.startsWith("=")) return { formula: value };
3976
+ return { value };
3977
+ }
3978
+ /**
3979
+ * {@link SheetApi} over the SDK's generic authenticated request.
3980
+ *
3981
+ * @param request `Client.request` of the connected SDK client.
3982
+ * @returns The API.
3983
+ */
3984
+ function formulaError(raw) {
3985
+ if (typeof raw !== "object" || raw === null) return [];
3986
+ const entry = raw;
3987
+ const cell = optionalString(entry.cell);
3988
+ if (cell === void 0) return [];
3989
+ const formula = optionalString(entry.formula);
3990
+ const errorType = optionalString(entry.error_type);
3991
+ const detail = optionalString(entry.detail);
3992
+ return [{
3993
+ cell,
3994
+ ...formula === void 0 ? {} : { formula },
3995
+ ...errorType === void 0 ? {} : { errorType },
3996
+ ...detail === void 0 ? {} : { detail }
3997
+ }];
3998
+ }
3999
+ function createSheetApi(request) {
4000
+ return {
4001
+ async workbook(token) {
4002
+ const output = await invoke(request, token, "read", "get_workbook_structure", {});
4003
+ const sheets = (Array.isArray(output.sheets) ? output.sheets : []).flatMap((raw, position) => {
4004
+ const info = typeof raw === "object" && raw !== null ? sheetInfo(raw, position) : void 0;
4005
+ return info === void 0 ? [] : [info];
4006
+ });
4007
+ const revision = optionalNumber(output.revision);
4008
+ return {
4009
+ ...revision === void 0 ? {} : { revision },
4010
+ sheets
4011
+ };
4012
+ },
4013
+ async readCsv(token, read) {
4014
+ const output = await invoke(request, token, "read", "get_range_as_csv", {
4015
+ sheet_id: read.sheetId,
4016
+ range: read.range,
4017
+ max_chars: read.maxChars,
4018
+ max_rows: 1e9,
4019
+ skip_hidden: false
4020
+ });
4021
+ const actualRange = optionalString(output.actual_range);
4022
+ const currentRegion = optionalString(output.current_region);
4023
+ const warning = optionalString(output.warning_message);
4024
+ return {
4025
+ csv: typeof output.annotated_csv === "string" ? output.annotated_csv : "",
4026
+ ...actualRange === void 0 ? {} : { actualRange },
4027
+ ...currentRegion === void 0 ? {} : { currentRegion },
4028
+ hasMore: output.has_more === true,
4029
+ ...warning === void 0 ? {} : { warning }
4030
+ };
4031
+ },
4032
+ async writeCells(token, write) {
4033
+ const output = await invoke(request, token, "write", "set_cell_range", {
4034
+ sheet_id: write.sheetId,
4035
+ range: write.range,
4036
+ cells: write.rows.map((row) => row.map(cellOf)),
4037
+ ...write.allowOverwrite === false ? { allow_overwrite: false } : {}
4038
+ });
4039
+ return { formulaErrors: (Array.isArray(output.formula_errors) ? output.formula_errors : []).flatMap(formulaError) };
4040
+ }
4041
+ };
4042
+ }
4043
+ //#endregion
3698
4044
  //#region src/feishu-channel.ts
3699
4045
  /**
3700
4046
  * Thin wrapper over the official SDK's `createLarkChannel` high-level module.
@@ -3888,6 +4234,8 @@ var LarkChannelAdapter = class {
3888
4234
  dmMode;
3889
4235
  slashCommands;
3890
4236
  wiki;
4237
+ sheets;
4238
+ bases;
3891
4239
  constructor(options) {
3892
4240
  this.channel = createLarkChannel({
3893
4241
  appId: options.appId,
@@ -3906,7 +4254,10 @@ var LarkChannelAdapter = class {
3906
4254
  this.dmMode = options.dmMode;
3907
4255
  const rawClient = this.channel.rawClient;
3908
4256
  this.slashCommands = slashCommandApi(async (input) => await rawClient.request(input));
3909
- this.wiki = createWikiApi(async (input) => await rawClient.request(input));
4257
+ const wikiRequest = async (input) => await rawClient.request(input);
4258
+ this.wiki = createWikiApi(wikiRequest);
4259
+ this.sheets = createSheetApi(wikiRequest);
4260
+ this.bases = createBaseApi(wikiRequest);
3910
4261
  }
3911
4262
  onMessage(handler) {
3912
4263
  return this.channel.on("message", (message) => {
@@ -9921,6 +10272,112 @@ var WikiConsent = class {
9921
10272
  })) entry.resolve(outcome);
9922
10273
  }
9923
10274
  };
10275
+ const FAILURE_REASONS = {
10276
+ TOOL_DISABLED: "知识库工具已在插件配置中关闭",
10277
+ ROUTE_NOT_FOUND: "当前会话没有绑定飞书话题,无法确定授权人",
10278
+ NOT_AUTHORIZED: "当前话题的发起人不在知识库白名单中;请管理员发送 /auth wiki add @该用户 后重试",
10279
+ CHANNEL_UNAVAILABLE: "飞书连接当前不可用,请稍后重试",
10280
+ INVALID_REFERENCE: "无法识别的知识库引用:请传知识库节点链接 https://<租户>.feishu.cn/wiki/<node_token>、node_token,或知识空间链接 https://<租户>.feishu.cn/wiki/space/<space_id>",
10281
+ INVALID_OPERATION: "操作参数不正确",
10282
+ OUTSIDE_ROOTS: "该位置不在插件配置的知识库范围(wikiRoots)内,不能访问",
10283
+ NOT_FOUND: "知识库节点不存在或已被删除",
10284
+ NO_ACCESS: "机器人对该知识库节点没有访问权限;请知识库管理员把应用(或包含机器人的群)加为该空间成员",
10285
+ UNSUPPORTED_TYPE: "这个工具不支持该类型的节点:飞书文档(docx)用 feishu_wiki_read / feishu_wiki_edit,电子表格(sheet)用 feishu_wiki_sheet_read / feishu_wiki_sheet_edit,多维表格(bitable)用 feishu_wiki_base_read / feishu_wiki_base_edit;其他类型暂不支持",
10286
+ NOT_OWNED: "只能修改由本应用创建(所有者是本应用)的文档",
10287
+ REVISION_CONFLICT: "文档在你读取之后被修改过;请重新用 feishu_wiki_read 读取最新内容,基于新的 revision_id 再修改",
10288
+ CONSENT_DENIED: "话题发起人拒绝了这次知识库操作",
10289
+ CONSENT_EXPIRED: "话题发起人没有在授权卡有效期内确认,已自动拒绝",
10290
+ CONSENT_UNAVAILABLE: "授权卡发送失败,无法征得话题发起人同意,已取消操作",
10291
+ PERMISSION_DENIED: "飞书拒绝调用:应用缺少知识库或文档权限(wiki:wiki、docx:document),请管理员在开发者后台开通并发布版本",
10292
+ CONTENT_FAILED: "文档已创建,但写入内容失败",
10293
+ EDIT_FAILED: "修改失败",
10294
+ EDIT_PARTIAL: "飞书报告操作只部分生效;已停止后续操作,请用 feishu_wiki_read 读取最新内容核验后再继续",
10295
+ API_FAILED: "飞书接口调用失败",
10296
+ ABORTED: "已取消"
10297
+ };
10298
+ function failure(code, reason) {
10299
+ return {
10300
+ ok: false,
10301
+ code,
10302
+ reason: reason ?? FAILURE_REASONS[code]
10303
+ };
10304
+ }
10305
+ function parseArgs(tool, schema, rawArgs) {
10306
+ const parsed = schema.safeParse(rawArgs);
10307
+ if (parsed.success) return parsed.data;
10308
+ const issue = parsed.error.issues[0];
10309
+ const where = issue === void 0 || issue.path.length === 0 ? "arguments" : issue.path.join(".");
10310
+ throw new Error(`${tool}: invalid ${where}: ${issue?.message ?? "invalid arguments"}`);
10311
+ }
10312
+ /** Run one gated call body, mapping thrown Feishu errors to failures. */
10313
+ async function guarded$1(deps, tool, body) {
10314
+ try {
10315
+ return await body();
10316
+ } catch (error) {
10317
+ const mapped = failureOf(error);
10318
+ if (mapped.code === "API_FAILED") deps.warn("dsh-channels: %s failed: %s", tool, mapped.reason);
10319
+ return mapped;
10320
+ }
10321
+ }
10322
+ /** Feishu codes: node missing/deleted, no resource access, missing app scope. */
10323
+ const NOT_FOUND_CODES = /* @__PURE__ */ new Set([
10324
+ 131005,
10325
+ 131012,
10326
+ 131013,
10327
+ 131014,
10328
+ 131016
10329
+ ]);
10330
+ const NO_ACCESS_CODES = /* @__PURE__ */ new Set([
10331
+ 131006,
10332
+ 1770032,
10333
+ 1770002
10334
+ ]);
10335
+ const PERMISSION_CODES = /* @__PURE__ */ new Set([99991672, 99991679]);
10336
+ function failureOf(error) {
10337
+ const detail = describeError(error);
10338
+ if (error instanceof FeishuApiError && error.code !== void 0) {
10339
+ if (NOT_FOUND_CODES.has(error.code)) return failure("NOT_FOUND", `${FAILURE_REASONS.NOT_FOUND}(${detail})`);
10340
+ if (NO_ACCESS_CODES.has(error.code)) return failure("NO_ACCESS", `${FAILURE_REASONS.NO_ACCESS}(${detail})`);
10341
+ if (PERMISSION_CODES.has(error.code)) return failure("PERMISSION_DENIED", `${FAILURE_REASONS.PERMISSION_DENIED}(${detail})`);
10342
+ }
10343
+ return failure("API_FAILED", `${FAILURE_REASONS.API_FAILED}:${detail}`);
10344
+ }
10345
+ function consentFailure(outcome) {
10346
+ switch (outcome) {
10347
+ case "allowed": return;
10348
+ case "denied": return failure("CONSENT_DENIED");
10349
+ case "expired": return failure("CONSENT_EXPIRED");
10350
+ case "cancelled": return failure("ABORTED");
10351
+ case "unavailable": return failure("CONSENT_UNAVAILABLE");
10352
+ }
10353
+ }
10354
+ /** Whether the app owns a node: Feishu reports the bot open_id as the owner. */
10355
+ function ownedByApp(node, botOpenId) {
10356
+ return botOpenId !== void 0 && botOpenId !== "" && node.owner === botOpenId;
10357
+ }
10358
+ /** A node's web link; falls back to the first root's origin. */
10359
+ function nodeUrl(roots, node) {
10360
+ if (node.url !== void 0) return node.url;
10361
+ const first = roots[0];
10362
+ return `${first === void 0 ? "https://feishu.cn" : new URL(first.link).origin}/wiki/${node.nodeToken}`;
10363
+ }
10364
+ /** One-line clipped preview with Markdown control characters neutralized. */
10365
+ function preview(value, max = 80) {
10366
+ const flat = value.replace(/\s+/gu, " ").trim().replace(/([[\]()*_`~<>])/gu, "\\$1");
10367
+ return flat.length <= max ? flat : `${flat.slice(0, max)}…`;
10368
+ }
10369
+ /** A fenced preview of the first characters of a content. */
10370
+ function previewBlock(content) {
10371
+ return `\`\`\`\n${(content.length <= 300 ? content : `${content.slice(0, 300)}\n…`).replace(/```/gu, "`​``")}\n\`\`\``;
10372
+ }
10373
+ function display(value) {
10374
+ return typeof value === "string" ? preview(value, 120) : "?";
10375
+ }
10376
+ /** Readable, bounded text for a thrown value. */
10377
+ function describeError(error) {
10378
+ const cleaned = (error instanceof FeishuApiError ? error.detail : error instanceof Error ? error.message : String(error)).replace(/[\u0000-\u001f\u007f]+/gu, " ").trim();
10379
+ return cleaned.length <= 300 ? cleaned : `${cleaned.slice(0, 300)}…`;
10380
+ }
9924
10381
  //#endregion
9925
10382
  //#region src/feishu/wiki/links.ts
9926
10383
  /**
@@ -9985,99 +10442,1040 @@ function parseWikiRootLink(link) {
9985
10442
  }
9986
10443
  };
9987
10444
  return {
9988
- ok: false,
9989
- error: "not-a-wiki-link"
10445
+ ok: false,
10446
+ error: "not-a-wiki-link"
10447
+ };
10448
+ }
10449
+ /**
10450
+ * Parse every configured root. Callers validate the config first, so an
10451
+ * invalid entry here is a programming error.
10452
+ *
10453
+ * @param links Validated `wikiRoots` entries.
10454
+ * @returns The parsed roots, in configuration order.
10455
+ */
10456
+ function parseWikiRoots(links) {
10457
+ return links.map((link) => {
10458
+ const parsed = parseWikiRootLink(link);
10459
+ if (!parsed.ok) throw new Error(`dsh-channels: invalid wikiRoots entry ${link}: ${WIKI_ROOT_LINK_ERRORS[parsed.error]}`);
10460
+ return parsed.root;
10461
+ });
10462
+ }
10463
+ /**
10464
+ * Parse a model-supplied reference: a wiki node link, a docx link, a space
10465
+ * link, or a bare node token.
10466
+ *
10467
+ * @param input Raw tool argument.
10468
+ * @returns The reference, or `undefined` when it names nothing recognizable.
10469
+ */
10470
+ function parseWikiRef(input) {
10471
+ const trimmed = input.trim();
10472
+ if (TOKEN_PATTERN.test(trimmed)) return {
10473
+ kind: "node",
10474
+ token: trimmed
10475
+ };
10476
+ let url;
10477
+ try {
10478
+ url = new URL(trimmed);
10479
+ } catch {
10480
+ return;
10481
+ }
10482
+ if (url.protocol !== "https:") return void 0;
10483
+ const segments = url.pathname.split("/").filter((segment) => segment !== "");
10484
+ if (segments[0] === "wiki" && segments.length === 3 && segments[1] === "space" && SPACE_ID_PATTERN.test(segments[2])) return {
10485
+ kind: "space",
10486
+ spaceId: segments[2]
10487
+ };
10488
+ if (segments.length === 2 && TOKEN_PATTERN.test(segments[1])) {
10489
+ if (segments[0] === "wiki" && segments[1] !== "space") return {
10490
+ kind: "node",
10491
+ token: segments[1]
10492
+ };
10493
+ if (segments[0] === "docx") return {
10494
+ kind: "docx",
10495
+ token: segments[1]
10496
+ };
10497
+ }
10498
+ }
10499
+ //#endregion
10500
+ //#region src/feishu/wiki/scope.ts
10501
+ /**
10502
+ * Longest parent chain walked for a node root. Feishu limits wiki depth well
10503
+ * below this; a longer chain is treated as outside.
10504
+ */
10505
+ const MAX_PARENT_DEPTH = 64;
10506
+ /**
10507
+ * Find the configured root containing `node`.
10508
+ *
10509
+ * @param api Wiki endpoints (for parent lookups).
10510
+ * @param roots Configured roots.
10511
+ * @param node The node to check.
10512
+ * @returns The containing root, or `undefined` when none contains it.
10513
+ */
10514
+ async function containingRoot(api, roots, node) {
10515
+ for (const root of roots) if (root.kind === "space" && root.spaceId === node.spaceId) return { root };
10516
+ const nodeRoots = roots.filter((root) => root.kind === "node");
10517
+ if (nodeRoots.length === 0) return void 0;
10518
+ const byToken = new Map(nodeRoots.map((root) => [root.nodeToken, root]));
10519
+ let current = node;
10520
+ for (let depth = 0; depth <= MAX_PARENT_DEPTH; depth += 1) {
10521
+ const match = byToken.get(current.nodeToken);
10522
+ if (match !== void 0) return { root: match };
10523
+ if (current.parentNodeToken === void 0) return void 0;
10524
+ current = await api.getNode(current.parentNodeToken, "wiki");
10525
+ }
10526
+ }
10527
+ /**
10528
+ * Whether a whole space (its top level) is inside the roots: only a space
10529
+ * root covers a space's top level.
10530
+ *
10531
+ * @param roots Configured roots.
10532
+ * @param spaceId The space.
10533
+ * @returns True when a space root names it.
10534
+ */
10535
+ function spaceIsRoot(roots, spaceId) {
10536
+ return roots.some((root) => root.kind === "space" && root.spaceId === spaceId);
10537
+ }
10538
+ //#endregion
10539
+ //#region src/feishu/wiki/base-tools.ts
10540
+ /**
10541
+ * Wiki Base (多维表格) tools: `feishu_wiki_base_read` (switch
10542
+ * `feishuTools.wikiRead`) and `feishu_wiki_base_edit` (switch
10543
+ * `feishuTools.wikiWrite`).
10544
+ *
10545
+ * Both run through the same gates as the docx tools (whitelist, `wikiRoots`
10546
+ * containment, consent cards). Reading shares the docx read grant per Session
10547
+ * and document. Editing changes records only (no table or field structure):
10548
+ * on a base the app owns it may create, update, and delete records; on any
10549
+ * other base it may only create records, so existing data written by people
10550
+ * is never changed. Every edit asks on a consent card. Feishu offers no
10551
+ * revision precondition for record writes, and applies them asynchronously.
10552
+ *
10553
+ * @module dsh-channels/feishu/wiki/base-tools
10554
+ */
10555
+ /** Model-facing tool names; stable across releases (session history references them). */
10556
+ const WIKI_BASE_READ_TOOL_NAME = "feishu_wiki_base_read";
10557
+ const WIKI_BASE_EDIT_TOOL_NAME = "feishu_wiki_base_edit";
10558
+ /** Record page size bounds of `feishu_wiki_base_read`. */
10559
+ const READ_DEFAULT_LIMIT = 50;
10560
+ const READ_MAX_LIMIT = 200;
10561
+ /** Records of each operation shown on a consent card. */
10562
+ const CARD_PREVIEW_RECORDS = 3;
10563
+ const refSchema$2 = z.string().trim().min(1).max(2048);
10564
+ const nameSchema = z.string().trim().min(1).max(200);
10565
+ const recordIdSchema = z.string().trim().min(1).max(64);
10566
+ const fieldMapSchema = z.record(z.string().min(1).max(200), z.unknown()).refine((map) => Object.keys(map).length > 0, "至少包含一个字段");
10567
+ const wikiBaseReadArgsSchema = z.object({
10568
+ doc: refSchema$2,
10569
+ table: nameSchema.optional(),
10570
+ filter: z.object({
10571
+ logic: z.enum(["and", "or"]).optional(),
10572
+ conditions: z.array(z.array(z.unknown()).min(2).max(3)).min(1).max(50)
10573
+ }).strict().optional(),
10574
+ sort: z.array(z.object({
10575
+ field: nameSchema,
10576
+ desc: z.boolean().optional()
10577
+ }).strict()).min(1).max(10).optional(),
10578
+ keyword: z.string().trim().min(1).max(200).optional(),
10579
+ search_fields: z.array(nameSchema).min(1).max(20).optional(),
10580
+ fields: z.array(nameSchema).min(1).max(100).optional(),
10581
+ view_id: nameSchema.optional(),
10582
+ offset: z.number().int().min(0).optional(),
10583
+ limit: z.number().int().min(1).max(READ_MAX_LIMIT).optional()
10584
+ }).strict();
10585
+ const baseOperationSchema = z.discriminatedUnion("command", [
10586
+ z.object({
10587
+ command: z.literal("create"),
10588
+ records: z.array(fieldMapSchema).min(1).max(200)
10589
+ }).strict(),
10590
+ z.object({
10591
+ command: z.literal("update"),
10592
+ records: z.array(z.object({
10593
+ record_id: recordIdSchema,
10594
+ fields: fieldMapSchema
10595
+ }).strict()).min(1).max(200)
10596
+ }).strict(),
10597
+ z.object({
10598
+ command: z.literal("delete"),
10599
+ record_ids: z.array(recordIdSchema).min(1).max(200)
10600
+ }).strict()
10601
+ ]);
10602
+ const wikiBaseEditArgsSchema = z.object({
10603
+ doc: refSchema$2,
10604
+ table: nameSchema,
10605
+ operations: z.array(baseOperationSchema).min(1).max(10)
10606
+ }).strict();
10607
+ const READ_DESCRIPTION$2 = `读取知识库中的飞书多维表格(bitable)。doc 传知识库节点链接或 node_token。第一次读取某个多维表格会在话题里弹出授权卡,由话题发起人确认,确认后本话题内再次读取不再询问;等待期间不要重复调用。不传 table 时只返回数据表列表;传 table(table_id 或表名)时返回字段列表和记录。filter 形如 {"logic":"and","conditions":[["状态","intersects",["进行中"]],["金额",">=",100],["备注","non_empty"]]},运算符有 == != > >= < <= intersects(包含)disjoint(不包含)empty non_empty;日期条件用 "ExactDate(2026-08-07)",日期不支持 >=。sort 形如 [{"field":"更新时间","desc":true}];keyword 为关键词搜索,search_fields 指定搜索的字段(默认主字段);fields 只返回指定字段。分页用 offset/limit(limit 默认 ${READ_DEFAULT_LIMIT},最多 ${READ_MAX_LIMIT}),has_more 为 true 时用 next_offset 继续。writable 说明能做哪些修改:all 表示可增改删记录(本应用创建的表),create 表示只能新增记录,none 表示不能修改。`;
10608
+ const EDIT_DESCRIPTION$2 = `修改知识库中飞书多维表格(bitable)某个数据表的记录。先用 feishu_wiki_base_read 读取字段列表,按字段名写入。operations 按顺序执行(最多 10 个,每个最多 200 条记录):create(records 为字段名→值的对象数组)、update(records 为 {record_id, fields} 数组,只改给出的字段)、delete(record_ids)。本应用创建的多维表格可以增改删;其他多维表格只能新增记录,不能修改或删除已有记录。不会修改数据表和字段结构。单元格取值:文本为字符串;数字为数字;单选/多选为选项名数组(如 ["进行中"],单选也是数组);日期为 "2026-03-24 10:00" 或毫秒时间戳;复选框为布尔;人员为 [{"id":"ou_xxx"}];关联为 [{"id":"rec_xxx"}];null 清空。公式、查找引用、创建时间等字段只读。写入是异步生效的,立刻读取可能还看不到。每次修改都会在话题里弹出授权卡,由话题发起人确认后才执行;等待期间不要重复调用。`;
10609
+ const REF_DESCRIPTION$2 = "知识库节点链接 https://<租户>.feishu.cn/wiki/<node_token> 或 node_token";
10610
+ const TABLE_PROPERTY = {
10611
+ type: "string",
10612
+ description: "数据表 table_id 或表名"
10613
+ };
10614
+ const FIELD_MAP_PROPERTY = {
10615
+ type: "object",
10616
+ description: "字段名(或字段 id)→ 值",
10617
+ additionalProperties: true
10618
+ };
10619
+ const READ_PARAMETERS$2 = {
10620
+ type: "object",
10621
+ properties: {
10622
+ doc: {
10623
+ type: "string",
10624
+ description: REF_DESCRIPTION$2
10625
+ },
10626
+ table: {
10627
+ ...TABLE_PROPERTY,
10628
+ description: "数据表 table_id 或表名;不传只列出数据表"
10629
+ },
10630
+ filter: {
10631
+ type: "object",
10632
+ properties: {
10633
+ logic: {
10634
+ type: "string",
10635
+ enum: ["and", "or"]
10636
+ },
10637
+ conditions: {
10638
+ type: "array",
10639
+ items: {
10640
+ type: "array",
10641
+ description: "[字段, 运算符, 值];empty / non_empty 不带值"
10642
+ }
10643
+ }
10644
+ },
10645
+ required: ["conditions"],
10646
+ additionalProperties: false
10647
+ },
10648
+ sort: {
10649
+ type: "array",
10650
+ items: {
10651
+ type: "object",
10652
+ properties: {
10653
+ field: { type: "string" },
10654
+ desc: { type: "boolean" }
10655
+ },
10656
+ required: ["field"],
10657
+ additionalProperties: false
10658
+ }
10659
+ },
10660
+ keyword: {
10661
+ type: "string",
10662
+ description: "关键词搜索"
10663
+ },
10664
+ search_fields: {
10665
+ type: "array",
10666
+ items: { type: "string" },
10667
+ description: "keyword 搜索的字段,默认主字段"
10668
+ },
10669
+ fields: {
10670
+ type: "array",
10671
+ items: { type: "string" },
10672
+ description: "只返回这些字段"
10673
+ },
10674
+ view_id: {
10675
+ type: "string",
10676
+ description: "按视图读取(应用视图的筛选与排序)"
10677
+ },
10678
+ offset: {
10679
+ type: "integer",
10680
+ minimum: 0
10681
+ },
10682
+ limit: {
10683
+ type: "integer",
10684
+ minimum: 1,
10685
+ maximum: READ_MAX_LIMIT
10686
+ }
10687
+ },
10688
+ required: ["doc"],
10689
+ additionalProperties: false
10690
+ };
10691
+ const EDIT_PARAMETERS$2 = {
10692
+ type: "object",
10693
+ properties: {
10694
+ doc: {
10695
+ type: "string",
10696
+ description: REF_DESCRIPTION$2
10697
+ },
10698
+ table: TABLE_PROPERTY,
10699
+ operations: {
10700
+ type: "array",
10701
+ minItems: 1,
10702
+ maxItems: 10,
10703
+ items: {
10704
+ type: "object",
10705
+ properties: {
10706
+ command: {
10707
+ type: "string",
10708
+ enum: [
10709
+ "create",
10710
+ "update",
10711
+ "delete"
10712
+ ]
10713
+ },
10714
+ records: {
10715
+ type: "array",
10716
+ maxItems: 200,
10717
+ description: "create:字段映射数组;update:{record_id, fields} 数组",
10718
+ items: {
10719
+ type: "object",
10720
+ properties: {
10721
+ record_id: { type: "string" },
10722
+ fields: FIELD_MAP_PROPERTY
10723
+ },
10724
+ additionalProperties: true
10725
+ }
10726
+ },
10727
+ record_ids: {
10728
+ type: "array",
10729
+ maxItems: 200,
10730
+ items: { type: "string" },
10731
+ description: "delete 的记录 id"
10732
+ }
10733
+ },
10734
+ required: ["command"],
10735
+ additionalProperties: false
10736
+ }
10737
+ }
10738
+ },
10739
+ required: [
10740
+ "doc",
10741
+ "table",
10742
+ "operations"
10743
+ ],
10744
+ additionalProperties: false
10745
+ };
10746
+ async function baseTarget(kit, context, doc) {
10747
+ const bases = context.bases;
10748
+ if (bases === void 0) return failure("CHANNEL_UNAVAILABLE");
10749
+ const ref = parseWikiRef(doc);
10750
+ if (ref === void 0) return failure("INVALID_REFERENCE");
10751
+ const node = await kit.containedNode(context, ref);
10752
+ if ("ok" in node) return node;
10753
+ if (node.objType !== "bitable") return failure("UNSUPPORTED_TYPE", `${failure("UNSUPPORTED_TYPE").reason}(该节点类型:${node.objType})`);
10754
+ return {
10755
+ context,
10756
+ bases,
10757
+ node,
10758
+ url: nodeUrl(context.roots, node)
10759
+ };
10760
+ }
10761
+ function writableOf(kit, target) {
10762
+ if (!kit.writeEnabled()) return "none";
10763
+ return ownedByApp(target.node, target.context.botOpenId) ? "all" : "create";
10764
+ }
10765
+ function pickTable(tables, wanted) {
10766
+ const match = tables.find((table) => table.tableId === wanted) ?? tables.find((table) => table.name === wanted);
10767
+ if (match !== void 0) return match;
10768
+ return `找不到数据表「${wanted}」;可用数据表:${tables.map((table) => `${table.name}(${table.tableId})`).join("、") || "无"}`;
10769
+ }
10770
+ function fieldView(field) {
10771
+ return {
10772
+ ...field.properties,
10773
+ field_id: field.fieldId,
10774
+ name: field.name,
10775
+ type: field.type
10776
+ };
10777
+ }
10778
+ async function read$1(kit, sessionId, rawArgs, signal) {
10779
+ const args = parseArgs(WIKI_BASE_READ_TOOL_NAME, wikiBaseReadArgsSchema, rawArgs);
10780
+ const gate = kit.gate(sessionId, WIKI_BASE_READ_TOOL_NAME);
10781
+ if (!gate.ok) return gate;
10782
+ return await guarded$1(kit, WIKI_BASE_READ_TOOL_NAME, async () => {
10783
+ const target = await baseTarget(kit, gate.context, args.doc);
10784
+ if ("ok" in target) return target;
10785
+ const denied = await kit.readConsent(sessionId, target.context, target.node, signal);
10786
+ if (denied !== void 0) return denied;
10787
+ if (signal.aborted) return failure("ABORTED");
10788
+ const token = target.node.objToken;
10789
+ const tables = await target.bases.listTables(token);
10790
+ const base = {
10791
+ ok: true,
10792
+ title: target.node.title,
10793
+ node_token: target.node.nodeToken,
10794
+ url: target.url,
10795
+ tables: tables.map((table) => ({
10796
+ table_id: table.tableId,
10797
+ name: table.name
10798
+ })),
10799
+ writable: writableOf(kit, target)
10800
+ };
10801
+ if (args.table === void 0) return base;
10802
+ const table = pickTable(tables, args.table);
10803
+ if (typeof table === "string") return failure("INVALID_OPERATION", table);
10804
+ const fields = await target.bases.listFields(token, table.tableId);
10805
+ const offset = args.offset ?? 0;
10806
+ const query = {
10807
+ ...args.filter === void 0 ? {} : { filter: args.filter },
10808
+ ...args.sort === void 0 ? {} : { sort: args.sort },
10809
+ ...args.fields === void 0 ? {} : { fields: args.fields },
10810
+ ...args.view_id === void 0 ? {} : { viewId: args.view_id },
10811
+ offset,
10812
+ limit: args.limit ?? READ_DEFAULT_LIMIT
10813
+ };
10814
+ let page;
10815
+ if (args.keyword === void 0) page = await target.bases.listRecords(token, table.tableId, query);
10816
+ else {
10817
+ const primary = fields[0]?.name;
10818
+ const searchFields = args.search_fields ?? (primary === void 0 ? [] : [primary]);
10819
+ if (searchFields.length === 0) return failure("INVALID_OPERATION", "keyword 搜索需要 search_fields");
10820
+ page = await target.bases.searchRecords(token, table.tableId, {
10821
+ ...query,
10822
+ keyword: args.keyword,
10823
+ searchFields
10824
+ });
10825
+ }
10826
+ return {
10827
+ ...base,
10828
+ table_id: table.tableId,
10829
+ fields: fields.map(fieldView),
10830
+ records: page.records.map((record) => ({
10831
+ record_id: record.recordId,
10832
+ fields: record.fields
10833
+ })),
10834
+ has_more: page.hasMore,
10835
+ ...page.hasMore ? { next_offset: offset + page.records.length } : {},
10836
+ ...page.timezone === void 0 ? {} : { timezone: page.timezone },
10837
+ ...page.ignoredFields.length === 0 ? {} : { ignored_fields: page.ignoredFields }
10838
+ };
10839
+ });
10840
+ }
10841
+ function json(value) {
10842
+ return preview(JSON.stringify(value) ?? "null", 120);
10843
+ }
10844
+ function cardLines$1(operations, tableName) {
10845
+ const lines = operations.map((operation, index) => {
10846
+ const n = `${index + 1}.`;
10847
+ switch (operation.command) {
10848
+ case "create": {
10849
+ const sample = operation.records.slice(0, CARD_PREVIEW_RECORDS).map((record) => ` ${json(record)}`);
10850
+ const more = operation.records.length > CARD_PREVIEW_RECORDS ? [` …(共 ${operation.records.length} 条)`] : [];
10851
+ return [
10852
+ `${n} 新增 ${operation.records.length} 条记录`,
10853
+ ...sample,
10854
+ ...more
10855
+ ].join("\n");
10856
+ }
10857
+ case "update": {
10858
+ const sample = operation.records.slice(0, CARD_PREVIEW_RECORDS).map((record) => ` ${preview(record.record_id, 40)}:${json(record.fields)}`);
10859
+ const more = operation.records.length > CARD_PREVIEW_RECORDS ? [` …(共 ${operation.records.length} 条)`] : [];
10860
+ return [
10861
+ `${n} 修改 ${operation.records.length} 条记录`,
10862
+ ...sample,
10863
+ ...more
10864
+ ].join("\n");
10865
+ }
10866
+ case "delete": return `${n} ⚠️ 删除 ${operation.record_ids.length} 条记录:${preview(operation.record_ids.join("、"), 160)}`;
10867
+ }
10868
+ });
10869
+ return [
10870
+ `**数据表**:${preview(tableName, 60)}`,
10871
+ `**将执行 ${operations.length} 个操作**`,
10872
+ ...lines
10873
+ ];
10874
+ }
10875
+ async function edit$1(kit, sessionId, rawArgs, signal) {
10876
+ const args = parseArgs(WIKI_BASE_EDIT_TOOL_NAME, wikiBaseEditArgsSchema, rawArgs);
10877
+ const gate = kit.gate(sessionId, WIKI_BASE_EDIT_TOOL_NAME);
10878
+ if (!gate.ok) return gate;
10879
+ return await guarded$1(kit, WIKI_BASE_EDIT_TOOL_NAME, async () => {
10880
+ const target = await baseTarget(kit, gate.context, args.doc);
10881
+ if ("ok" in target) return target;
10882
+ const { node, bases } = target;
10883
+ if (writableOf(kit, target) !== "all") {
10884
+ const changing = args.operations.find((operation) => operation.command !== "create");
10885
+ if (changing !== void 0) return failure("NOT_OWNED", `这个多维表格不是本应用创建的,只能新增记录,不能${changing.command === "update" ? "修改" : "删除"}已有记录(所有者:${node.owner ?? "未知"})`);
10886
+ }
10887
+ const table = pickTable(await bases.listTables(node.objToken), args.table);
10888
+ if (typeof table === "string") return failure("INVALID_OPERATION", table);
10889
+ const denied = await kit.consent(sessionId, target.context, "edit", {
10890
+ title: node.title,
10891
+ url: target.url,
10892
+ location: await kit.locationOf(target.context.api, node),
10893
+ details: cardLines$1(args.operations, table.name)
10894
+ }, signal);
10895
+ if (denied !== void 0) return denied;
10896
+ const created = [];
10897
+ const ignored = [];
10898
+ let updated = 0;
10899
+ let deleted = 0;
10900
+ for (const [index, operation] of args.operations.entries()) {
10901
+ if (signal.aborted) return {
10902
+ ...failure("ABORTED", `已取消;前 ${index} 个操作已执行`),
10903
+ applied: index
10904
+ };
10905
+ try {
10906
+ switch (operation.command) {
10907
+ case "create":
10908
+ created.push(...await bases.createRecords(node.objToken, table.tableId, operation.records));
10909
+ break;
10910
+ case "update":
10911
+ ignored.push(...await bases.updateRecords(node.objToken, table.tableId, Object.fromEntries(operation.records.map((record) => [record.record_id, record.fields]))));
10912
+ updated += operation.records.length;
10913
+ break;
10914
+ case "delete":
10915
+ await bases.deleteRecords(node.objToken, table.tableId, operation.record_ids);
10916
+ deleted += operation.record_ids.length;
10917
+ break;
10918
+ }
10919
+ } catch (error) {
10920
+ const reason = failureOf(error).reason;
10921
+ return {
10922
+ ...failure("EDIT_FAILED", `第 ${index + 1} 个操作失败:${reason};前 ${index} 个操作已执行`),
10923
+ applied: index
10924
+ };
10925
+ }
10926
+ }
10927
+ return {
10928
+ ok: true,
10929
+ title: node.title,
10930
+ node_token: node.nodeToken,
10931
+ url: target.url,
10932
+ table_id: table.tableId,
10933
+ applied: args.operations.length,
10934
+ created_record_ids: created,
10935
+ updated,
10936
+ deleted,
10937
+ ignored_fields: [...new Set(ignored)]
10938
+ };
10939
+ });
10940
+ }
10941
+ /** The Base tool family. */
10942
+ const BASE_TOOL_SPECS = [{
10943
+ name: WIKI_BASE_READ_TOOL_NAME,
10944
+ access: "read",
10945
+ description: READ_DESCRIPTION$2,
10946
+ parameters: READ_PARAMETERS$2,
10947
+ execute: read$1,
10948
+ presentCall: (args) => `读取知识库多维表格:${display(args.doc)}`
10949
+ }, {
10950
+ name: WIKI_BASE_EDIT_TOOL_NAME,
10951
+ access: "write",
10952
+ description: EDIT_DESCRIPTION$2,
10953
+ parameters: EDIT_PARAMETERS$2,
10954
+ execute: edit$1,
10955
+ presentCall: (args) => `修改知识库多维表格:${display(args.doc)}`
10956
+ }];
10957
+ //#endregion
10958
+ //#region src/feishu/wiki/a1.ts
10959
+ /**
10960
+ * A1 notation helpers for the wiki sheet tools: column letters, single cells,
10961
+ * and rectangle end rows. Ranges never carry a sheet prefix (`Sheet1!`); the
10962
+ * sub-sheet is selected separately.
10963
+ *
10964
+ * @module dsh-channels/feishu/wiki/a1
10965
+ */
10966
+ /** Largest column the helpers accept (`ZZZ`). */
10967
+ const MAX_COLUMN = 18278;
10968
+ /** Largest row the helpers accept. */
10969
+ const MAX_ROW = 1e7;
10970
+ /**
10971
+ * Column letters to a 1-based index (`A` → 1, `AA` → 27).
10972
+ *
10973
+ * @param letters Column letters, any case.
10974
+ * @returns The index, or undefined when invalid or beyond `ZZZ`.
10975
+ */
10976
+ function columnIndex(letters) {
10977
+ if (!/^[A-Za-z]{1,3}$/u.test(letters)) return void 0;
10978
+ let index = 0;
10979
+ for (const char of letters.toUpperCase()) index = index * 26 + (char.charCodeAt(0) - 64);
10980
+ return index <= MAX_COLUMN ? index : void 0;
10981
+ }
10982
+ /**
10983
+ * A 1-based column index to letters (1 → `A`, 27 → `AA`).
10984
+ *
10985
+ * @param index Column index, at least 1.
10986
+ * @returns The letters.
10987
+ */
10988
+ function columnLetters(index) {
10989
+ let letters = "";
10990
+ let rest = index;
10991
+ while (rest > 0) {
10992
+ const digit = (rest - 1) % 26;
10993
+ letters = String.fromCharCode(65 + digit) + letters;
10994
+ rest = Math.floor((rest - 1) / 26);
10995
+ }
10996
+ return letters;
10997
+ }
10998
+ /**
10999
+ * Parse one cell (`B3`).
11000
+ *
11001
+ * @param value Cell reference.
11002
+ * @returns The position, or undefined when it is not a single valid cell.
11003
+ */
11004
+ function parseCell(value) {
11005
+ const match = /^([A-Za-z]{1,3})([1-9]\d{0,7})$/u.exec(value.trim());
11006
+ if (match === null) return void 0;
11007
+ const column = columnIndex(match[1] ?? "");
11008
+ const row = Number(match[2]);
11009
+ return column === void 0 || row > MAX_ROW ? void 0 : {
11010
+ column,
11011
+ row
11012
+ };
11013
+ }
11014
+ /**
11015
+ * The rectangle that starts at `start` and spans `rows` × `columns` cells.
11016
+ *
11017
+ * @param start Top-left cell.
11018
+ * @param rows Row count, at least 1.
11019
+ * @param columns Column count, at least 1.
11020
+ * @returns The range (`B3:D10`).
11021
+ */
11022
+ function rectangle(start, rows, columns) {
11023
+ return `${columnLetters(start.column)}${start.row}:${columnLetters(start.column + columns - 1)}${start.row + rows - 1}`;
11024
+ }
11025
+ /**
11026
+ * Last row of a range (`A1:B5` → 5, `C7` → 7); undefined for whole-column ranges (`A:C`).
11027
+ *
11028
+ * @param range A1 range.
11029
+ * @returns The row, or undefined.
11030
+ */
11031
+ function rangeEndRow(range) {
11032
+ const end = range.split(":").at(-1) ?? "";
11033
+ const match = /(\d+)$/u.exec(end.trim());
11034
+ return match === null ? void 0 : Number(match[1]);
11035
+ }
11036
+ /**
11037
+ * Whether a range is plain A1 (`A1`, `A1:F30`, `A:C`, `3:5`) without a sheet prefix.
11038
+ *
11039
+ * @param range Candidate range.
11040
+ * @returns True when acceptable.
11041
+ */
11042
+ function isA1Range(range) {
11043
+ return /^(?:[A-Za-z]{1,3}\d{0,8}|\d{1,8})(?::(?:[A-Za-z]{1,3}\d{0,8}|\d{1,8}))?$/u.test(range);
11044
+ }
11045
+ //#endregion
11046
+ //#region src/feishu/wiki/sheet-tools.ts
11047
+ /**
11048
+ * Wiki spreadsheet (电子表格) tools: `feishu_wiki_sheet_read` (switch
11049
+ * `feishuTools.wikiRead`) and `feishu_wiki_sheet_edit` (switch
11050
+ * `feishuTools.wikiWrite`).
11051
+ *
11052
+ * Both run through the same gates as the docx tools (whitelist, `wikiRoots`
11053
+ * containment, consent cards). Reading shares the docx read grant per Session
11054
+ * and document. Editing writes cell values and formulas only (no styles,
11055
+ * charts, or row/column structure), only on spreadsheets the app owns, and
11056
+ * only when the workbook revision still equals the one the model read, checked
11057
+ * before and after the consent card; Feishu offers no revision precondition on
11058
+ * the write itself.
11059
+ *
11060
+ * @module dsh-channels/feishu/wiki/sheet-tools
11061
+ */
11062
+ /** Model-facing tool names; stable across releases (session history references them). */
11063
+ const WIKI_SHEET_READ_TOOL_NAME = "feishu_wiki_sheet_read";
11064
+ const WIKI_SHEET_EDIT_TOOL_NAME = "feishu_wiki_sheet_edit";
11065
+ /** Most cells one `feishu_wiki_sheet_edit` call may write in total. */
11066
+ const WIKI_SHEET_EDIT_MAX_CELLS = 5e4;
11067
+ /** Read size bounds (characters of CSV returned to the model). */
11068
+ const READ_DEFAULT_CHARS = 2e4;
11069
+ const READ_MAX_CHARS = 1e5;
11070
+ /** CSV characters requested when only the data extent of a sheet is needed. */
11071
+ const EXTENT_PROBE_CHARS = 2e3;
11072
+ /** CSV characters per page while scanning below the first data region for later rows. */
11073
+ const EXTENT_SCAN_CHARS = 2e5;
11074
+ /** Pages the append row scan reads before giving up. */
11075
+ const EXTENT_SCAN_PAGES = 20;
11076
+ /** Scan bounds when the workbook does not report the grid size. */
11077
+ const MAX_SCAN_COLUMN = 18278;
11078
+ const MAX_SCAN_ROW = 1e7;
11079
+ /** Rows of each written block shown on a consent card. */
11080
+ const CARD_PREVIEW_ROWS = 3;
11081
+ const refSchema$1 = z.string().trim().min(1).max(2048);
11082
+ const sheetSchema = z.string().trim().min(1).max(200);
11083
+ const cellValueSchema = z.union([
11084
+ z.string().max(5e4),
11085
+ z.number().finite(),
11086
+ z.boolean(),
11087
+ z.null()
11088
+ ]);
11089
+ const valuesSchema = z.array(z.array(cellValueSchema).min(1).max(1e3)).min(1).max(1e4);
11090
+ const wikiSheetReadArgsSchema = z.object({
11091
+ doc: refSchema$1,
11092
+ sheet: sheetSchema.optional(),
11093
+ range: z.string().trim().min(1).max(40).refine(isA1Range, "需要不带子表前缀的 A1 范围,例如 A1:F30").optional(),
11094
+ max_chars: z.number().int().min(1e3).max(READ_MAX_CHARS).optional()
11095
+ }).strict();
11096
+ const sheetOperationSchema = z.discriminatedUnion("command", [z.object({
11097
+ command: z.literal("write"),
11098
+ sheet: sheetSchema.optional(),
11099
+ start_cell: z.string().trim().min(2).max(12),
11100
+ values: valuesSchema
11101
+ }).strict(), z.object({
11102
+ command: z.literal("append"),
11103
+ sheet: sheetSchema.optional(),
11104
+ start_column: z.string().trim().min(1).max(3).optional(),
11105
+ values: valuesSchema
11106
+ }).strict()]);
11107
+ const wikiSheetEditArgsSchema = z.object({
11108
+ doc: refSchema$1,
11109
+ revision_id: z.number().int().min(0),
11110
+ operations: z.array(sheetOperationSchema).min(1).max(20)
11111
+ }).strict();
11112
+ const READ_DESCRIPTION$1 = `读取知识库中的飞书电子表格(sheet)。doc 传知识库节点链接或 node_token。第一次读取某个表格会在话题里弹出授权卡,由话题发起人确认,确认后本话题内再次读取不再询问;等待期间不要重复调用。返回 sheets(子表列表);sheet 传子表 sheet_id 或标题选择子表,只有一个可见子表时可省略,有多个而未指定时只返回子表列表。range 为不带子表前缀的 A1 范围(如 A1:F30、A:C),省略时读整张子表。内容为 CSV(显示值),每行前缀 [row=N] 是真实行号,引用行号时以它为准,不要自己数行。max_chars 默认 ${READ_DEFAULT_CHARS};has_more 为 true 时表示被截断,从 actual_range 末行之后接着读。返回的 revision_id 用于 feishu_wiki_sheet_edit;editable 表示能否修改(只有本应用创建的表格可以修改)。`;
11113
+ const EDIT_DESCRIPTION$1 = `修改知识库中由本应用创建的飞书电子表格(sheet)的单元格值。先用 feishu_wiki_sheet_read 读取,把读到的 revision_id 原样传入;表格在此之后被改过会被拒绝,需要重新读取。operations 按顺序执行(最多 20 个,合计不超过 ${WIKI_SHEET_EDIT_MAX_CELLS} 个单元格):write(从 start_cell 如 B3 开始写入二维数组 values)、append(在子表现有数据的最后一行之后追加 values,start_column 默认 A;目标单元格非空时失败,不会覆盖)。返回 formula_errors 时,这些公式没有编译成功、已按文本写入,需要修正后重写。values 每个元素是一行;单元格可以是字符串、数字、布尔或 null(null 表示保持原值不变);以 = 开头的字符串作为公式写入。数字、金额、百分比(0.4 表示 40%)写成数字;编号、前导零、身份证号写成字符串。不会修改样式、图表、行列结构。sheet 传子表 sheet_id 或标题,只有一个可见子表时可省略。每次修改都会在话题里弹出授权卡,由话题发起人确认后才执行;等待期间不要重复调用。`;
11114
+ const REF_DESCRIPTION$1 = "知识库节点链接 https://<租户>.feishu.cn/wiki/<node_token> 或 node_token";
11115
+ const SHEET_PROPERTY = {
11116
+ type: "string",
11117
+ description: "子表 sheet_id 或标题;只有一个可见子表时可省略"
11118
+ };
11119
+ const VALUES_PROPERTY = {
11120
+ type: "array",
11121
+ description: "二维数组,每个元素是一行;单元格为字符串、数字、布尔或 null(保持原值),以 = 开头的字符串是公式",
11122
+ items: {
11123
+ type: "array",
11124
+ items: { type: [
11125
+ "string",
11126
+ "number",
11127
+ "boolean",
11128
+ "null"
11129
+ ] }
11130
+ }
11131
+ };
11132
+ const READ_PARAMETERS$1 = {
11133
+ type: "object",
11134
+ properties: {
11135
+ doc: {
11136
+ type: "string",
11137
+ description: REF_DESCRIPTION$1
11138
+ },
11139
+ sheet: SHEET_PROPERTY,
11140
+ range: {
11141
+ type: "string",
11142
+ description: "不带子表前缀的 A1 范围,如 A1:F30;省略读整张子表"
11143
+ },
11144
+ max_chars: {
11145
+ type: "integer",
11146
+ minimum: 1e3,
11147
+ maximum: READ_MAX_CHARS,
11148
+ description: `返回 CSV 的字符上限,默认 ${READ_DEFAULT_CHARS}`
11149
+ }
11150
+ },
11151
+ required: ["doc"],
11152
+ additionalProperties: false
11153
+ };
11154
+ const EDIT_PARAMETERS$1 = {
11155
+ type: "object",
11156
+ properties: {
11157
+ doc: {
11158
+ type: "string",
11159
+ description: REF_DESCRIPTION$1
11160
+ },
11161
+ revision_id: {
11162
+ type: "integer",
11163
+ description: "feishu_wiki_sheet_read 返回的 revision_id"
11164
+ },
11165
+ operations: {
11166
+ type: "array",
11167
+ minItems: 1,
11168
+ maxItems: 20,
11169
+ items: {
11170
+ type: "object",
11171
+ properties: {
11172
+ command: {
11173
+ type: "string",
11174
+ enum: ["write", "append"]
11175
+ },
11176
+ sheet: SHEET_PROPERTY,
11177
+ start_cell: {
11178
+ type: "string",
11179
+ description: "write 的起始单元格,如 B3"
11180
+ },
11181
+ start_column: {
11182
+ type: "string",
11183
+ description: "append 的起始列,默认 A"
11184
+ },
11185
+ values: VALUES_PROPERTY
11186
+ },
11187
+ required: ["command", "values"],
11188
+ additionalProperties: false
11189
+ }
11190
+ }
11191
+ },
11192
+ required: [
11193
+ "doc",
11194
+ "revision_id",
11195
+ "operations"
11196
+ ],
11197
+ additionalProperties: false
11198
+ };
11199
+ async function sheetTarget(kit, context, doc) {
11200
+ const sheets = context.sheets;
11201
+ if (sheets === void 0) return failure("CHANNEL_UNAVAILABLE");
11202
+ const ref = parseWikiRef(doc);
11203
+ if (ref === void 0) return failure("INVALID_REFERENCE");
11204
+ const node = await kit.containedNode(context, ref);
11205
+ if ("ok" in node) return node;
11206
+ if (node.objType !== "sheet") return failure("UNSUPPORTED_TYPE", `${failure("UNSUPPORTED_TYPE").reason}(该节点类型:${node.objType})`);
11207
+ return {
11208
+ context,
11209
+ sheets,
11210
+ node,
11211
+ url: nodeUrl(context.roots, node)
11212
+ };
11213
+ }
11214
+ function sheetView(sheet) {
11215
+ return {
11216
+ sheet_id: sheet.sheetId,
11217
+ title: sheet.title,
11218
+ index: sheet.index,
11219
+ resource_type: sheet.resourceType,
11220
+ hidden: sheet.hidden,
11221
+ ...sheet.rowCount === void 0 ? {} : { row_count: sheet.rowCount },
11222
+ ...sheet.columnCount === void 0 ? {} : { column_count: sheet.columnCount }
9990
11223
  };
9991
11224
  }
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;
11225
+ /** The named grid sheet, the only visible grid sheet, or why neither applies. */
11226
+ function pickSheet(workbook, wanted) {
11227
+ const grids = workbook.sheets.filter((sheet) => sheet.resourceType === "sheet");
11228
+ if (wanted !== void 0) {
11229
+ const match = workbook.sheets.find((sheet) => sheet.sheetId === wanted) ?? workbook.sheets.find((sheet) => sheet.title === wanted);
11230
+ if (match === void 0) return `找不到子表「${wanted}」;可用子表:${sheetList(workbook)}`;
11231
+ if (match.resourceType !== "sheet") return `子表「${match.title}」是 ${match.resourceType},不是单元格表格,不能按单元格读写`;
11232
+ return match;
11233
+ }
11234
+ const visible = grids.filter((sheet) => !sheet.hidden);
11235
+ if (visible.length === 1 && visible[0] !== void 0) return visible[0];
11236
+ return `表格有多个子表,请用 sheet 指定:${sheetList(workbook)}`;
11237
+ }
11238
+ function sheetList(workbook) {
11239
+ return workbook.sheets.map((sheet) => `${sheet.title}(${sheet.sheetId})`).join("、") || "无";
11240
+ }
11241
+ function wholeSheetRange(sheet) {
11242
+ return sheet.rowCount !== void 0 && sheet.columnCount !== void 0 && sheet.rowCount > 0 && sheet.columnCount > 0 ? `A1:${columnLetters(sheet.columnCount)}${sheet.rowCount}` : "A:ZZZ";
11243
+ }
11244
+ async function read(kit, sessionId, rawArgs, signal) {
11245
+ const args = parseArgs(WIKI_SHEET_READ_TOOL_NAME, wikiSheetReadArgsSchema, rawArgs);
11246
+ const gate = kit.gate(sessionId, WIKI_SHEET_READ_TOOL_NAME);
11247
+ if (!gate.ok) return gate;
11248
+ return await guarded$1(kit, WIKI_SHEET_READ_TOOL_NAME, async () => {
11249
+ const target = await sheetTarget(kit, gate.context, args.doc);
11250
+ if ("ok" in target) return target;
11251
+ const denied = await kit.readConsent(sessionId, target.context, target.node, signal);
11252
+ if (denied !== void 0) return denied;
11253
+ if (signal.aborted) return failure("ABORTED");
11254
+ const workbook = await target.sheets.workbook(target.node.objToken);
11255
+ const base = {
11256
+ ok: true,
11257
+ title: target.node.title,
11258
+ node_token: target.node.nodeToken,
11259
+ url: target.url,
11260
+ ...workbook.revision === void 0 ? {} : { revision_id: workbook.revision },
11261
+ sheets: workbook.sheets.map(sheetView),
11262
+ editable: kit.writeEnabled() && ownedByApp(target.node, target.context.botOpenId)
11263
+ };
11264
+ const sheet = pickSheet(workbook, args.sheet);
11265
+ if (typeof sheet === "string") {
11266
+ if (args.sheet !== void 0) return failure("INVALID_OPERATION", sheet);
11267
+ return {
11268
+ ...base,
11269
+ warning: sheet,
11270
+ content: ""
11271
+ };
11272
+ }
11273
+ const csv = await target.sheets.readCsv(target.node.objToken, {
11274
+ sheetId: sheet.sheetId,
11275
+ range: args.range ?? wholeSheetRange(sheet),
11276
+ maxChars: args.max_chars ?? READ_DEFAULT_CHARS
11277
+ });
11278
+ return {
11279
+ ...base,
11280
+ sheet_id: sheet.sheetId,
11281
+ ...csv.actualRange === void 0 ? {} : { actual_range: csv.actualRange },
11282
+ ...csv.currentRegion === void 0 ? {} : { current_region: csv.currentRegion },
11283
+ has_more: csv.hasMore,
11284
+ ...csv.warning === void 0 ? {} : { warning: csv.warning },
11285
+ content: csv.csv
11286
+ };
10004
11287
  });
10005
11288
  }
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
11289
+ /** Rows padded with `null` (unchanged) to one rectangle. */
11290
+ function rectangular(values) {
11291
+ const columns = Math.max(...values.map((row) => row.length));
11292
+ return {
11293
+ rows: values.map((row) => [...row, ...Array(columns - row.length).fill(null)]),
11294
+ columns
10018
11295
  };
10019
- let url;
10020
- try {
10021
- url = new URL(trimmed);
10022
- } catch {
10023
- return;
11296
+ }
11297
+ function plan(workbook, operations) {
11298
+ const planned = [];
11299
+ let cells = 0;
11300
+ for (const [index, operation] of operations.entries()) {
11301
+ const where = `第 ${index + 1} 个操作(${operation.command})`;
11302
+ const sheet = pickSheet(workbook, operation.sheet);
11303
+ if (typeof sheet === "string") return failure("INVALID_OPERATION", `${where}:${sheet}`);
11304
+ let start;
11305
+ if (operation.command === "write") {
11306
+ start = parseCell(operation.start_cell);
11307
+ if (start === void 0) return failure("INVALID_OPERATION", `${where}:start_cell 需要单个单元格,例如 B3`);
11308
+ } else {
11309
+ const column = parseCell(`${operation.start_column ?? "A"}1`);
11310
+ if (column === void 0) return failure("INVALID_OPERATION", `${where}:start_column 需要列字母,例如 A`);
11311
+ start = column;
11312
+ }
11313
+ const { rows, columns } = rectangular(operation.values);
11314
+ cells += rows.length * columns;
11315
+ if (cells > 5e4) return failure("INVALID_OPERATION", `一次最多写入 ${WIKI_SHEET_EDIT_MAX_CELLS} 个单元格,请拆成多次调用`);
11316
+ planned.push({
11317
+ operation,
11318
+ sheet,
11319
+ start,
11320
+ rows,
11321
+ columns
11322
+ });
10024
11323
  }
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]
11324
+ return planned;
11325
+ }
11326
+ function cardLines(planned) {
11327
+ const lines = planned.map((write, index) => {
11328
+ const size = `${write.rows.length} 行 × ${write.columns} 列`;
11329
+ const where = write.operation.command === "write" ? `写入子表「${preview(write.sheet.title, 40)}」的 ${rectangle(write.start, write.rows.length, write.columns)}(${size})` : `在子表「${preview(write.sheet.title, 40)}」末尾追加 ${size}(从 ${columnLetters(write.start.column)} 列开始)`;
11330
+ const sample = write.rows.slice(0, CARD_PREVIEW_ROWS).map((row) => ` ${preview(row.map((cell) => cell === null ? "" : String(cell)).join(" | "), 80)}`);
11331
+ const more = write.rows.length > CARD_PREVIEW_ROWS ? [` …(共 ${write.rows.length} 行)`] : [];
11332
+ return [
11333
+ `${index + 1}. ${where}`,
11334
+ ...sample,
11335
+ ...more
11336
+ ].join("\n");
11337
+ });
11338
+ return [`**将执行 ${planned.length} 个操作**`, ...lines];
11339
+ }
11340
+ /** First empty row after the sheet's data (1 for an empty sheet), or undefined when it cannot be determined. */
11341
+ async function appendRow(sheets, token, sheet) {
11342
+ const probe = await sheets.readCsv(token, {
11343
+ sheetId: sheet.sheetId,
11344
+ range: wholeSheetRange(sheet),
11345
+ maxChars: EXTENT_PROBE_CHARS
11346
+ });
11347
+ const region = probe.currentRegion ?? (probe.hasMore ? void 0 : probe.actualRange);
11348
+ let last = region === void 0 ? probe.csv.trim() === "" && !probe.hasMore ? 0 : void 0 : rangeEndRow(region);
11349
+ if (last === void 0) return void 0;
11350
+ const lastColumn = columnLetters(sheet.columnCount !== void 0 && sheet.columnCount > 0 ? sheet.columnCount : MAX_SCAN_COLUMN);
11351
+ const lastRow = sheet.rowCount !== void 0 && sheet.rowCount > 0 ? sheet.rowCount : MAX_SCAN_ROW;
11352
+ let from = last + 1;
11353
+ for (let page = 0; from <= lastRow; page++) {
11354
+ if (page === EXTENT_SCAN_PAGES) return void 0;
11355
+ const scan = await sheets.readCsv(token, {
11356
+ sheetId: sheet.sheetId,
11357
+ range: `A${from}:${lastColumn}${lastRow}`,
11358
+ maxChars: EXTENT_SCAN_CHARS
11359
+ });
11360
+ last = Math.max(last, lastFilledRow(scan.csv));
11361
+ if (!scan.hasMore) break;
11362
+ const end = scan.actualRange === void 0 ? void 0 : rangeEndRow(scan.actualRange);
11363
+ if (end === void 0 || end < from) return void 0;
11364
+ from = end + 1;
11365
+ }
11366
+ return last + 1;
11367
+ }
11368
+ /** Largest `[row=N]` whose record has a non-empty cell, or 0. */
11369
+ function lastFilledRow(csv) {
11370
+ let last = 0;
11371
+ for (const match of csv.matchAll(/^\[row=(\d+)\] ?(.*)$/gmu)) if (/[^,\s"]/u.test(match[2] ?? "")) last = Math.max(last, Number(match[1]));
11372
+ return last;
11373
+ }
11374
+ async function edit(kit, sessionId, rawArgs, signal) {
11375
+ const args = parseArgs(WIKI_SHEET_EDIT_TOOL_NAME, wikiSheetEditArgsSchema, rawArgs);
11376
+ const gate = kit.gate(sessionId, WIKI_SHEET_EDIT_TOOL_NAME);
11377
+ if (!gate.ok) return gate;
11378
+ return await guarded$1(kit, WIKI_SHEET_EDIT_TOOL_NAME, async () => {
11379
+ const target = await sheetTarget(kit, gate.context, args.doc);
11380
+ if ("ok" in target) return target;
11381
+ const { node, sheets } = target;
11382
+ if (!ownedByApp(node, target.context.botOpenId)) return failure("NOT_OWNED", `${failure("NOT_OWNED").reason}(该表格所有者:${node.owner ?? "未知"})`);
11383
+ const workbook = await sheets.workbook(node.objToken);
11384
+ if (workbook.revision !== void 0 && workbook.revision !== args.revision_id) return {
11385
+ ...failure("REVISION_CONFLICT", `${failure("REVISION_CONFLICT").reason}(读取时 ${args.revision_id},当前 ${workbook.revision})`),
11386
+ revision_id: workbook.revision
10035
11387
  };
10036
- if (segments[0] === "docx") return {
10037
- kind: "docx",
10038
- token: segments[1]
11388
+ const planned = plan(workbook, args.operations);
11389
+ if (!Array.isArray(planned)) return planned;
11390
+ const denied = await kit.consent(sessionId, target.context, "edit", {
11391
+ title: node.title,
11392
+ url: target.url,
11393
+ location: await kit.locationOf(target.context.api, node),
11394
+ details: cardLines(planned)
11395
+ }, signal);
11396
+ if (denied !== void 0) return denied;
11397
+ const after = await sheets.workbook(node.objToken);
11398
+ if (after.revision !== void 0 && after.revision !== args.revision_id) return {
11399
+ ...failure("REVISION_CONFLICT", `${failure("REVISION_CONFLICT").reason}(等待授权期间表格被修改)`),
11400
+ revision_id: after.revision
10039
11401
  };
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);
11402
+ const written = [];
11403
+ const formulaErrors = [];
11404
+ for (const [index, write] of planned.entries()) {
11405
+ if (signal.aborted) return {
11406
+ ...failure("ABORTED", `已取消;前 ${index} 个操作已执行`),
11407
+ applied: index
11408
+ };
11409
+ try {
11410
+ let start = write.start;
11411
+ if (write.operation.command === "append") {
11412
+ const row = await appendRow(sheets, node.objToken, write.sheet);
11413
+ if (row === void 0) return {
11414
+ ...failure("EDIT_FAILED", `第 ${index + 1} 个操作失败:无法确定子表「${write.sheet.title}」的数据末行,请改用 write 指定 start_cell;前 ${index} 个操作已执行`),
11415
+ applied: index
11416
+ };
11417
+ start = {
11418
+ column: write.start.column,
11419
+ row
11420
+ };
11421
+ }
11422
+ const range = rectangle(start, write.rows.length, write.columns);
11423
+ const result = await sheets.writeCells(node.objToken, {
11424
+ sheetId: write.sheet.sheetId,
11425
+ range,
11426
+ rows: write.rows,
11427
+ ...write.operation.command === "append" ? { allowOverwrite: false } : {}
11428
+ });
11429
+ written.push({
11430
+ sheet_id: write.sheet.sheetId,
11431
+ range
11432
+ });
11433
+ for (const error of result.formulaErrors) formulaErrors.push({
11434
+ sheet_id: write.sheet.sheetId,
11435
+ cell: error.cell,
11436
+ ...error.formula === void 0 ? {} : { formula: error.formula },
11437
+ ...error.errorType === void 0 ? {} : { error_type: error.errorType },
11438
+ ...error.detail === void 0 ? {} : { detail: error.detail }
11439
+ });
11440
+ } catch (error) {
11441
+ const reason = failureOf(error).reason;
11442
+ return {
11443
+ ...failure("EDIT_FAILED", `第 ${index + 1} 个操作失败:${reason};前 ${index} 个操作已执行`),
11444
+ applied: index
11445
+ };
11446
+ }
11447
+ }
11448
+ const revision = await sheets.workbook(node.objToken).then((final) => final.revision, (error) => {
11449
+ kit.warn("dsh-channels: reading the revision after %s failed: %s", WIKI_SHEET_EDIT_TOOL_NAME, describeError(error));
11450
+ });
11451
+ return {
11452
+ ok: true,
11453
+ title: node.title,
11454
+ node_token: node.nodeToken,
11455
+ url: target.url,
11456
+ applied: planned.length,
11457
+ written,
11458
+ ...revision === void 0 ? {} : { revision_id: revision },
11459
+ ...formulaErrors.length === 0 ? {} : { formula_errors: formulaErrors }
11460
+ };
11461
+ });
10080
11462
  }
11463
+ /** The spreadsheet tool family. */
11464
+ const SHEET_TOOL_SPECS = [{
11465
+ name: WIKI_SHEET_READ_TOOL_NAME,
11466
+ access: "read",
11467
+ description: READ_DESCRIPTION$1,
11468
+ parameters: READ_PARAMETERS$1,
11469
+ execute: read,
11470
+ presentCall: (args) => `读取知识库表格:${display(args.doc)}`
11471
+ }, {
11472
+ name: WIKI_SHEET_EDIT_TOOL_NAME,
11473
+ access: "write",
11474
+ description: EDIT_DESCRIPTION$1,
11475
+ parameters: EDIT_PARAMETERS$1,
11476
+ execute: edit,
11477
+ presentCall: (args) => `修改知识库表格:${display(args.doc)}`
11478
+ }];
10081
11479
  //#endregion
10082
11480
  //#region src/feishu/wiki/tools.ts
10083
11481
  /**
@@ -10110,8 +11508,6 @@ const WIKI_EDIT_TOOL_NAME = "feishu_wiki_edit";
10110
11508
  const MAX_TITLE_CHARS = 200;
10111
11509
  /** Longest content one create/edit operation may carry (characters). */
10112
11510
  const MAX_CONTENT_CHARS = 2e5;
10113
- /** Characters of each content shown on a consent card. */
10114
- const PREVIEW_CHARS = 300;
10115
11511
  const refSchema = z.string().trim().min(1).max(2048);
10116
11512
  const blockIdSchema = z.string().trim().min(1).max(128);
10117
11513
  const contentSchema = z.string().max(MAX_CONTENT_CHARS);
@@ -10138,6 +11534,11 @@ const wikiReadArgsSchema = z.object({
10138
11534
  const wikiCreateArgsSchema = z.object({
10139
11535
  parent: refSchema,
10140
11536
  title: z.string().trim().min(1).max(MAX_TITLE_CHARS),
11537
+ type: z.enum([
11538
+ "docx",
11539
+ "sheet",
11540
+ "bitable"
11541
+ ]).optional(),
10141
11542
  content: contentSchema.optional(),
10142
11543
  format: formatSchema.optional()
10143
11544
  }).strict();
@@ -10181,29 +11582,6 @@ const wikiEditArgsSchema = z.object({
10181
11582
  format: formatSchema.optional(),
10182
11583
  operations: z.array(wikiEditOperationSchema).min(1).max(20)
10183
11584
  }).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
11585
  const OUTPUT_SCHEMA = {
10208
11586
  type: "object",
10209
11587
  properties: {
@@ -10214,9 +11592,16 @@ const OUTPUT_SCHEMA = {
10214
11592
  required: ["ok"]
10215
11593
  };
10216
11594
  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 读取。每次新建都会在话题里弹出授权卡,由话题发起人确认后才执行;等待期间不要重复调用。";
11595
+ const READ_DESCRIPTION = "读取知识库中的一篇飞书文档(docx;电子表格用 feishu_wiki_sheet_read,多维表格用 feishu_wiki_base_read)。doc 传知识库节点链接或 node_token。第一次读取某篇文档会在话题里弹出授权卡,由话题发起人确认,确认后本话题内再次读取同一篇不再询问;等待期间不要重复调用。scope 默认 full(整篇);大文档先用 outline 看目录,再用 section(start_block_id 为标题块 id)、range(start/end_block_id)或 keyword(关键词,支持 a|b)读取局部;max_depth 在 outline 中表示标题层级上限。format 默认 markdown;xml 为飞书文档 XML(类 HTML 标签:<p> <h1>-<h6> <ul>/<ol><li> <pre><code> <table> <callout> 等),只有 xml 读取会给每个块带上 id 属性(block id),需要按块修改时用 xml 读取。返回的 revision_id 用于 feishu_wiki_edit;editable 表示这篇文档能否被修改(只有本应用创建的文档可以修改)。";
11596
+ const CREATE_DESCRIPTION = "在知识库中新建一篇飞书文档(docx,默认)、电子表格(type=sheet)或多维表格(type=bitable)。parent 传父节点(知识库节点链接或 node_token)或知识空间链接(建在空间顶层),必须位于插件配置的知识库范围内;新节点本身也可以作为目录,之后把它的 node_token 作为 parent 在下面继续新建子节点。title 为标题;content 为文档正文(仅 docx,可选,不要在正文里重复标题),电子表格和多维表格建好后是空白的,再用 feishu_wiki_sheet_edit / feishu_wiki_base_edit 写入。format 默认 markdown;xml 为飞书文档 XML(类 HTML 标签:<p> <h1>-<h6> <ul>/<ol><li> <pre><code> <table> <callout> 等),只有 xml 读取会给每个块带上 id 属性(block id),需要按块修改时用 xml 读取。每次新建都会在话题里弹出授权卡,由话题发起人确认后才执行;等待期间不要重复调用。";
10219
11597
  const EDIT_DESCRIPTION = `修改知识库中由本应用创建的飞书文档(docx)。先用 feishu_wiki_read 读取(按块修改时用 format=xml 拿到 block id),把读到的 revision_id 原样传入;文档在此之后被他人改过会被拒绝,需要重新读取。operations 按顺序执行(最多 20 个):append(文末追加 content)、insert_after(在 block_id 之后插入 content)、replace(用 content 替换 block_id,或同一父块下 start_block_id 到 end_block_id 的连续块)、delete(删除 block_id,或 start_block_id 到 end_block_id)、str_replace(把文本 pattern 替换为 content,content 为空表示删除)、overwrite(清空后用 content 重写整篇,会丢失评论与不支持的内容,尽量少用)。替换和删除会使涉及的旧 block id 失效,同一个块的多处修改请合并成一次 replace。format 默认 markdown;xml 为飞书文档 XML(类 HTML 标签:<p> <h1>-<h6> <ul>/<ol><li> <pre><code> <table> <callout> 等),只有 xml 读取会给每个块带上 id 属性(block id),需要按块修改时用 xml 读取。每次修改都会在话题里弹出授权卡,由话题发起人确认后才执行;等待期间不要重复调用。`;
11598
+ const CREATE_TYPE_LABELS = {
11599
+ docx: "飞书文档",
11600
+ sheet: "电子表格",
11601
+ bitable: "多维表格"
11602
+ };
11603
+ /** The spreadsheet and Base tool families, mounted with the docx tools under the same switches. */
11604
+ const FAMILY_SPECS = [...SHEET_TOOL_SPECS, ...BASE_TOOL_SPECS];
10220
11605
  /**
10221
11606
  * The wiki tool runtime: mounts the enabled tools into Agent scopes and runs
10222
11607
  * each call through the gates in the module contract.
@@ -10234,7 +11619,16 @@ var WikiToolRuntime = class {
10234
11619
  enabledTools() {
10235
11620
  const config = this.deps.config();
10236
11621
  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] : []];
11622
+ const family = (access) => FAMILY_SPECS.filter((spec) => spec.access === access).map((spec) => spec.name);
11623
+ return [...config.feishuTools.wikiRead ? [
11624
+ WIKI_LIST_TOOL_NAME,
11625
+ WIKI_READ_TOOL_NAME,
11626
+ ...family("read")
11627
+ ] : [], ...config.feishuTools.wikiWrite ? [
11628
+ WIKI_CREATE_TOOL_NAME,
11629
+ WIKI_EDIT_TOOL_NAME,
11630
+ ...family("write")
11631
+ ] : []];
10238
11632
  }
10239
11633
  /**
10240
11634
  * Register the enabled tools inside one Agent scope; same mount policy as
@@ -10291,6 +11685,16 @@ var WikiToolRuntime = class {
10291
11685
  },
10292
11686
  presentResult: (_args, value) => presentWikiResult(value)
10293
11687
  };
11688
+ const spec = FAMILY_SPECS.find((candidate) => candidate.name === name);
11689
+ if (spec !== void 0) return {
11690
+ ...base,
11691
+ name,
11692
+ description: spec.description,
11693
+ parameters: spec.parameters,
11694
+ execute: async (rawArgs, exec) => await spec.execute(this, sessionId, rawArgs, exec.signal),
11695
+ presentCall: (args) => spec.presentCall(args),
11696
+ isConcurrencySafe: () => false
11697
+ };
10294
11698
  switch (name) {
10295
11699
  case WIKI_LIST_TOOL_NAME: return {
10296
11700
  ...base,
@@ -10343,6 +11747,8 @@ var WikiToolRuntime = class {
10343
11747
  context: {
10344
11748
  route,
10345
11749
  api,
11750
+ ...channel.sheets === void 0 ? {} : { sheets: channel.sheets },
11751
+ ...channel.bases === void 0 ? {} : { bases: channel.bases },
10346
11752
  roots: parseWikiRoots(this.deps.config().wikiRoots),
10347
11753
  botOpenId: channel.botOpenId()
10348
11754
  }
@@ -10372,6 +11778,26 @@ var WikiToolRuntime = class {
10372
11778
  return label;
10373
11779
  }
10374
11780
  /** `空间 / 父节点` for a node (one parent lookup). */
11781
+ async readConsent(sessionId, context, node, signal) {
11782
+ if (this.deps.consent.hasReadGrant(sessionId, node.objToken)) return void 0;
11783
+ const denied = await this.consent(sessionId, context, "read", {
11784
+ title: node.title,
11785
+ url: nodeUrl(context.roots, node),
11786
+ location: await this.locationOf(context.api, node),
11787
+ details: []
11788
+ }, signal);
11789
+ if (denied === void 0) this.deps.consent.grantRead(sessionId, node.objToken);
11790
+ return denied;
11791
+ }
11792
+ grantRead(sessionId, documentId) {
11793
+ this.deps.consent.grantRead(sessionId, documentId);
11794
+ }
11795
+ writeEnabled() {
11796
+ return this.deps.config().feishuTools.wikiWrite;
11797
+ }
11798
+ warn(message, ...args) {
11799
+ this.deps.warn(message, ...args);
11800
+ }
10375
11801
  async locationOf(api, node) {
10376
11802
  const space = await this.spaceName(api, node.spaceId);
10377
11803
  if (node.parentNodeToken === void 0) return space;
@@ -10466,16 +11892,8 @@ var WikiToolRuntime = class {
10466
11892
  if ("ok" in node) return node;
10467
11893
  if (node.objType !== "docx") return failure("UNSUPPORTED_TYPE", `${FAILURE_REASONS.UNSUPPORTED_TYPE}(该节点类型:${node.objType})`);
10468
11894
  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
- }
11895
+ const denied = await this.readConsent(sessionId, context, node, exec.signal);
11896
+ if (denied !== void 0) return denied;
10479
11897
  if (exec.signal.aborted) return failure("ABORTED");
10480
11898
  const format = args.format ?? "markdown";
10481
11899
  const document = await context.api.fetchDocument(node.objToken, {
@@ -10523,9 +11941,11 @@ var WikiToolRuntime = class {
10523
11941
  parentUrl = nodeUrl(context.roots, parent);
10524
11942
  location = `${await this.spaceName(context.api, spaceId)} / ${parent.title === "" ? parent.nodeToken : parent.title}`;
10525
11943
  }
11944
+ const type = args.type ?? "docx";
10526
11945
  const format = args.format ?? "markdown";
10527
11946
  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)}`];
11947
+ if (type !== "docx" && content !== void 0) return failure("INVALID_OPERATION", "content 只用于飞书文档;新建电子表格或多维表格后,用 feishu_wiki_sheet_edit / feishu_wiki_base_edit 写入数据");
11948
+ const details = type !== "docx" ? [`**类型**:${CREATE_TYPE_LABELS[type]}(空白)`] : content === void 0 ? ["**内容**:空文档"] : [`**内容预览**(${format === "xml" ? "XML" : "Markdown"},共 ${content.length} 字)\n${previewBlock(content)}`];
10529
11949
  const denied = await this.consent(sessionId, context, "create", {
10530
11950
  title: args.title,
10531
11951
  ...parentUrl === void 0 ? {} : { url: parentUrl },
@@ -10534,9 +11954,9 @@ var WikiToolRuntime = class {
10534
11954
  }, exec.signal);
10535
11955
  if (denied !== void 0) return denied;
10536
11956
  if (exec.signal.aborted) return failure("ABORTED");
10537
- const node = await context.api.createDocxNode(spaceId, parentToken, args.title);
11957
+ const node = await context.api.createNode(spaceId, parentToken, args.title, type);
10538
11958
  const url = nodeUrl(context.roots, node);
10539
- this.deps.consent.grantRead(sessionId, node.objToken);
11959
+ this.grantRead(sessionId, node.objToken);
10540
11960
  if (content === void 0) return {
10541
11961
  ok: true,
10542
11962
  title: node.title || args.title,
@@ -10656,72 +12076,6 @@ var WikiToolRuntime = class {
10656
12076
  });
10657
12077
  }
10658
12078
  };
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
12079
  /** Map one validated operation to a `docs_ai` update, or the reason it is incomplete. */
10726
12080
  function updateRequestOf(operation, format) {
10727
12081
  switch (operation.command) {
@@ -10796,23 +12150,6 @@ function operationLines(operations) {
10796
12150
  function blockRangeLabel(operation) {
10797
12151
  return operation.block_id !== void 0 ? `块 \`${operation.block_id}\`` : `块 \`${operation.start_block_id ?? "?"}\` 到 \`${operation.end_block_id ?? "?"}\``;
10798
12152
  }
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
12153
  function isValue(value) {
10817
12154
  return typeof value === "object" && value !== null && typeof value.ok === "boolean";
10818
12155
  }
@@ -10850,6 +12187,8 @@ function presentWikiResult(value) {
10850
12187
  if (!isValue(value)) return "知识库工具结果不可用";
10851
12188
  if (!value.ok) return `失败(${value.code}):${value.reason}`;
10852
12189
  if ("items" in value) return `列出 ${value.items.length} 项`;
12190
+ if ("records" in value && value.records !== void 0) return `已读取《${value.title}》(${value.records.length} 条记录)`;
12191
+ if ("tables" in value) return `已读取《${value.title}》(${value.tables.length} 个数据表)`;
10853
12192
  if ("content" in value) return `已读取《${value.title}》(${value.content.length} 字)`;
10854
12193
  if ("applied" in value) return `已修改《${value.title}》(${value.applied} 个操作)`;
10855
12194
  return `已新建《${value.title}》`;
@@ -10926,11 +12265,20 @@ const CREATE_PARAMETERS = {
10926
12265
  },
10927
12266
  title: {
10928
12267
  type: "string",
10929
- description: "文档标题"
12268
+ description: "标题"
12269
+ },
12270
+ type: {
12271
+ type: "string",
12272
+ enum: [
12273
+ "docx",
12274
+ "sheet",
12275
+ "bitable"
12276
+ ],
12277
+ description: "节点类型:docx 飞书文档(默认)、sheet 电子表格、bitable 多维表格"
10930
12278
  },
10931
12279
  content: {
10932
12280
  type: "string",
10933
- description: "正文(可选)"
12281
+ description: "文档正文(仅 docx,可选)"
10934
12282
  },
10935
12283
  format: FORMAT_PROPERTY
10936
12284
  },
@@ -13514,6 +14862,8 @@ const ATTACHMENT_STORE_MODES = ["native", "archive"];
13514
14862
  const DEFAULT_ATTACHMENT_FETCH_MAX_BYTES = 100 * 1024 * 1024;
13515
14863
  /** Default whole-call timeout of `feishu_attachment_fetch` (120 s). */
13516
14864
  const DEFAULT_ATTACHMENT_FETCH_TIMEOUT_MS = 12e4;
14865
+ /** Largest delay Node's `setTimeout` honors (2^31 − 1 ms, about 24.8 days); longer delays fire at once. */
14866
+ const MAX_TIMER_DELAY_MS = 2147483647;
13517
14867
  /** Default per-image byte cap for trigger-message prefetch (20 MiB). */
13518
14868
  const DEFAULT_PREFETCH_IMAGE_MAX_BYTES = 20 * 1024 * 1024;
13519
14869
  /** Default total byte budget for one trigger-message image prefetch (50 MiB). */
@@ -13712,10 +15062,12 @@ const configSchema = z.object({
13712
15062
  * fetch (1–50). A message with more attachments is refused with a readable
13713
15063
  * reason so the model can retry per `file_key`.
13714
15064
  * - `attachmentFetchMaxBytes`: total download budget of one tool call
13715
- * (default 100 MiB). Files stream to the native attachment library, so the
13716
- * limit is enforced while streaming rather than buffered first.
13717
- * - `attachmentFetchTimeoutMs`: whole-call timeout (default 120 s). On expiry
13718
- * the in-flight download is cancelled and reported per attachment.
15065
+ * (default 100 MiB, no upper bound). Files stream to the native attachment
15066
+ * library, so the limit is enforced while streaming rather than buffered
15067
+ * first, and multi-GiB files need only disk space.
15068
+ * - `attachmentFetchTimeoutMs`: whole-call timeout (default 120 s, at most
15069
+ * {@link MAX_TIMER_DELAY_MS}). On expiry the in-flight download is
15070
+ * cancelled and reported per attachment.
13719
15071
  * - `topicMessages` (default on): register `feishu_topic_messages` in
13720
15072
  * channel sessions so the model can read the quoted message / earlier
13721
15073
  * topic messages on demand and authorize their attachments for fetch.
@@ -13735,14 +15087,21 @@ const configSchema = z.object({
13735
15087
  * `im:resource` (or `im:resource:upload`) scope; without it a call returns
13736
15088
  * a readable failure and the turn continues.
13737
15089
  *
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`.
15090
+ * - `wikiRead` (default off): register `feishu_wiki_list`,
15091
+ * `feishu_wiki_read`, `feishu_wiki_sheet_read`, and
15092
+ * `feishu_wiki_base_read` so the model can browse the wiki nodes under
15093
+ * `wikiRoots` and read docx documents, spreadsheets, and Bases there.
15094
+ * Needs `wiki:wiki` (or `wiki:wiki:readonly`), `docx:document` (or
15095
+ * `docx:document:readonly`), `sheets:spreadsheet` (or a read-only
15096
+ * spreadsheet scope), and `bitable:app` (or a read-only Base scope).
15097
+ * - `wikiWrite` (default off): register `feishu_wiki_create`,
15098
+ * `feishu_wiki_edit`, `feishu_wiki_sheet_edit`, and
15099
+ * `feishu_wiki_base_edit` so the model can create docx documents,
15100
+ * spreadsheets, and Bases under `wikiRoots`, edit documents and
15101
+ * spreadsheets this app owns, and change records of Bases this app owns
15102
+ * (other Bases accept new records only). Needs `wiki:wiki`,
15103
+ * `docx:document`, `sheets:spreadsheet`, and `bitable:app` (or the
15104
+ * matching write-only scopes).
13746
15105
  *
13747
15106
  * The wiki tools act with the app identity and are mounted only while
13748
15107
  * `wikiRoots` is non-empty. Each call is limited to topics whose owner is
@@ -13755,8 +15114,8 @@ const configSchema = z.object({
13755
15114
  feishuTools: z.object({
13756
15115
  attachmentFetch: z.boolean().default(true),
13757
15116
  attachmentFetchMaxResources: z.number().int().min(1).max(50).default(10),
13758
- attachmentFetchMaxBytes: z.number().int().min(1).max(1024 * 1024 * 1024).default(DEFAULT_ATTACHMENT_FETCH_MAX_BYTES),
13759
- attachmentFetchTimeoutMs: z.number().int().min(1e3).max(6e5).default(DEFAULT_ATTACHMENT_FETCH_TIMEOUT_MS),
15117
+ attachmentFetchMaxBytes: z.number().int().min(1).default(DEFAULT_ATTACHMENT_FETCH_MAX_BYTES),
15118
+ attachmentFetchTimeoutMs: z.number().int().min(1e3).max(MAX_TIMER_DELAY_MS).default(DEFAULT_ATTACHMENT_FETCH_TIMEOUT_MS),
13760
15119
  topicMessages: z.boolean().default(true),
13761
15120
  topicMessagesTimeoutMs: z.number().int().min(1e3).max(12e4).default(DEFAULT_TOPIC_MESSAGES_TIMEOUT_MS),
13762
15121
  sendFile: z.boolean().default(true),
@@ -17467,16 +18826,15 @@ const FIELD_META = {
17467
18826
  },
17468
18827
  "feishuTools.attachmentFetchMaxBytes": {
17469
18828
  label: "单次取回字节预算(字节)",
17470
- help: "一次工具调用的总下载字节预算(默认 104857600 = 100 MiB);文件流式写入原生库,不整文件缓冲。",
18829
+ help: "一次工具调用的总下载字节预算(默认 104857600 = 100 MiB,不设上限);文件流式写入原生库,不整文件缓冲,几 GiB 的文件只占磁盘。",
17471
18830
  min: 1,
17472
- max: 1024 * 1024 * 1024,
17473
18831
  step: 1
17474
18832
  },
17475
18833
  "feishuTools.attachmentFetchTimeoutMs": {
17476
18834
  label: "单次取回超时(ms)",
17477
- help: "一次 feishu_attachment_fetch 的整段超时(默认 120000 ms,1000–600000);到期取消下载并逐项报告可读原因。",
18835
+ help: "一次 feishu_attachment_fetch 的整段超时(默认 120000 ms,最小 1000,最大 2147483647 ≈ 24.8 天);到期取消下载并逐项报告可读原因。大文件请按带宽同步调大。",
17478
18836
  min: 1e3,
17479
- max: 6e5,
18837
+ max: MAX_TIMER_DELAY_MS,
17480
18838
  step: 1e3
17481
18839
  },
17482
18840
  "feishuTools.topicMessages": {
@@ -17496,11 +18854,11 @@ const FIELD_META = {
17496
18854
  },
17497
18855
  "feishuTools.wikiRead": {
17498
18856
  label: "启用知识库读取工具",
17499
- help: "让模型浏览「知识库范围」内的知识库节点并读取 docx 文档(feishu_wiki_list / feishu_wiki_read),以应用身份访问。需开通 wiki:wiki(或 wiki:wiki:readonly)与 docx:document(或 docx:document:readonly)权限。只有话题所有者是管理员或在知识库白名单(/auth wiki add @成员)中才能使用;打开文档前会在授权卡上征得话题所有者同意,每个话题一次。知识库范围为空时不挂载。对新会话生效。"
18857
+ help: "让模型浏览「知识库范围」内的知识库节点,并读取 docx 文档、电子表格和多维表格(feishu_wiki_list / feishu_wiki_read / feishu_wiki_sheet_read / feishu_wiki_base_read),以应用身份访问。需开通 wiki:wiki、docx:document、sheets:spreadsheet、bitable:app 权限(或对应的只读权限)。只有话题所有者是管理员或在知识库白名单(/auth wiki add @成员)中才能使用;打开文档前会在授权卡上征得话题所有者同意,每个话题一次。知识库范围为空时不挂载。对新会话生效。"
17500
18858
  },
17501
18859
  "feishuTools.wikiWrite": {
17502
18860
  label: "启用知识库写入工具",
17503
- help: "让模型在「知识库范围」内新建 docx 文档,并编辑本应用拥有的文档(feishu_wiki_create / feishu_wiki_edit),以应用身份访问;每次新建或编辑都需话题所有者在审批卡上同意。需开通 wiki:wiki 与 docx:document 权限;使用条件同读取工具(管理员或知识库白名单)。知识库范围为空时不挂载。对新会话生效。"
18861
+ help: "让模型在「知识库范围」内新建 docx 文档、电子表格和多维表格,编辑本应用拥有的文档和电子表格,增改删本应用拥有的多维表格记录、向其他多维表格新增记录(feishu_wiki_create / feishu_wiki_edit / feishu_wiki_sheet_edit / feishu_wiki_base_edit),以应用身份访问;每次新建或编辑都需话题所有者在审批卡上同意。需开通 wiki:wiki、docx:document、sheets:spreadsheet、bitable:app 权限;使用条件同读取工具(管理员或知识库白名单)。知识库范围为空时不挂载。对新会话生效。"
17504
18862
  },
17505
18863
  wikiRoots: {
17506
18864
  label: "知识库范围(wikiRoots)",