@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.
@@ -0,0 +1,493 @@
1
+ /**
2
+ * Wiki spreadsheet (电子表格) tools: `feishu_wiki_sheet_read` (switch
3
+ * `feishuTools.wikiRead`) and `feishu_wiki_sheet_edit` (switch
4
+ * `feishuTools.wikiWrite`).
5
+ *
6
+ * Both run through the same gates as the docx tools (whitelist, `wikiRoots`
7
+ * containment, consent cards). Reading shares the docx read grant per Session
8
+ * and document. Editing writes cell values and formulas only (no styles,
9
+ * charts, or row/column structure), only on spreadsheets the app owns, and
10
+ * only when the workbook revision still equals the one the model read, checked
11
+ * before and after the consent card; Feishu offers no revision precondition on
12
+ * the write itself.
13
+ *
14
+ * @module dsh-channels/feishu/wiki/sheet-tools
15
+ */
16
+
17
+ import { z } from 'zod'
18
+ import { columnLetters, isA1Range, parseCell, rangeEndRow, rectangle } from './a1.ts'
19
+ import type { A1Cell } from './a1.ts'
20
+ import type { WikiNode } from './api.ts'
21
+ import { parseWikiRef } from './links.ts'
22
+ import type { SheetApi, SheetCellValue, SheetInfo, SheetWorkbook } from './sheet-api.ts'
23
+ import { describeError, display, failure, failureOf, guarded, nodeUrl, ownedByApp, parseArgs, preview } from './shared.ts'
24
+ import type { WikiCallContext, WikiFailure, WikiToolKit, WikiToolSpec } from './shared.ts'
25
+
26
+ /** Model-facing tool names; stable across releases (session history references them). */
27
+ export const WIKI_SHEET_READ_TOOL_NAME = 'feishu_wiki_sheet_read'
28
+ export const WIKI_SHEET_EDIT_TOOL_NAME = 'feishu_wiki_sheet_edit'
29
+
30
+ /** Most operations one `feishu_wiki_sheet_edit` call may apply. */
31
+ export const WIKI_SHEET_EDIT_MAX_OPERATIONS = 20
32
+
33
+ /** Most cells one `feishu_wiki_sheet_edit` call may write in total. */
34
+ export const WIKI_SHEET_EDIT_MAX_CELLS = 50_000
35
+
36
+ /** Read size bounds (characters of CSV returned to the model). */
37
+ const READ_DEFAULT_CHARS = 20_000
38
+ const READ_MAX_CHARS = 100_000
39
+
40
+ /** CSV characters requested when only the data extent of a sheet is needed. */
41
+ const EXTENT_PROBE_CHARS = 2_000
42
+
43
+ /** CSV characters per page while scanning below the first data region for later rows. */
44
+ const EXTENT_SCAN_CHARS = 200_000
45
+
46
+ /** Pages the append row scan reads before giving up. */
47
+ const EXTENT_SCAN_PAGES = 20
48
+
49
+ /** Scan bounds when the workbook does not report the grid size. */
50
+ const MAX_SCAN_COLUMN = 18_278
51
+ const MAX_SCAN_ROW = 10_000_000
52
+
53
+ /** Rows of each written block shown on a consent card. */
54
+ const CARD_PREVIEW_ROWS = 3
55
+
56
+ // ---- Arguments ----------------------------------------------------------
57
+
58
+ const refSchema = z.string().trim().min(1).max(2048)
59
+ const sheetSchema = z.string().trim().min(1).max(200)
60
+ const cellValueSchema = z.union([z.string().max(50_000), z.number().finite(), z.boolean(), z.null()])
61
+ const valuesSchema = z.array(z.array(cellValueSchema).min(1).max(1_000)).min(1).max(10_000)
62
+
63
+ export const wikiSheetReadArgsSchema = z.object({
64
+ doc: refSchema,
65
+ sheet: sheetSchema.optional(),
66
+ range: z.string().trim().min(1).max(40).refine(isA1Range, '需要不带子表前缀的 A1 范围,例如 A1:F30').optional(),
67
+ max_chars: z.number().int().min(1_000).max(READ_MAX_CHARS).optional(),
68
+ }).strict()
69
+
70
+ const sheetOperationSchema = z.discriminatedUnion('command', [
71
+ z.object({ command: z.literal('write'), sheet: sheetSchema.optional(), start_cell: z.string().trim().min(2).max(12), values: valuesSchema }).strict(),
72
+ z.object({ command: z.literal('append'), sheet: sheetSchema.optional(), start_column: z.string().trim().min(1).max(3).optional(), values: valuesSchema }).strict(),
73
+ ])
74
+
75
+ export type WikiSheetOperation = z.infer<typeof sheetOperationSchema>
76
+
77
+ export const wikiSheetEditArgsSchema = z.object({
78
+ doc: refSchema,
79
+ revision_id: z.number().int().min(0),
80
+ operations: z.array(sheetOperationSchema).min(1).max(WIKI_SHEET_EDIT_MAX_OPERATIONS),
81
+ }).strict()
82
+
83
+ // ---- Results ------------------------------------------------------------
84
+
85
+ /** One sub-sheet as the tools report it. */
86
+ export interface WikiSheetView {
87
+ readonly sheet_id: string
88
+ readonly title: string
89
+ readonly index: number
90
+ readonly resource_type: string
91
+ readonly hidden: boolean
92
+ readonly row_count?: number
93
+ readonly column_count?: number
94
+ }
95
+
96
+ /** Successful `feishu_wiki_sheet_read`. */
97
+ export interface WikiSheetReadValue {
98
+ readonly ok: true
99
+ readonly title: string
100
+ readonly node_token: string
101
+ readonly url: string
102
+ /** Workbook revision; pass it to `feishu_wiki_sheet_edit`. */
103
+ readonly revision_id?: number
104
+ readonly sheets: readonly WikiSheetView[]
105
+ /** The sub-sheet read; absent when the call only listed the sheets. */
106
+ readonly sheet_id?: string
107
+ readonly actual_range?: string
108
+ readonly current_region?: string
109
+ readonly has_more?: boolean
110
+ readonly warning?: string
111
+ readonly editable: boolean
112
+ /** CSV with `[row=N] ` record prefixes; empty when only listing. */
113
+ readonly content: string
114
+ }
115
+
116
+ /** Successful `feishu_wiki_sheet_edit`. */
117
+ export interface WikiSheetEditValue {
118
+ readonly ok: true
119
+ readonly title: string
120
+ readonly node_token: string
121
+ readonly url: string
122
+ readonly applied: number
123
+ /** Written rectangles, in operation order. */
124
+ readonly written: ReadonlyArray<{ readonly sheet_id: string; readonly range: string }>
125
+ readonly revision_id?: number
126
+ /** Formulas stored as text because they did not compile; present only when some failed. */
127
+ readonly formula_errors?: ReadonlyArray<{
128
+ readonly sheet_id: string
129
+ readonly cell: string
130
+ readonly formula?: string
131
+ readonly error_type?: string
132
+ readonly detail?: string
133
+ }>
134
+ }
135
+
136
+ // ---- Descriptions -------------------------------------------------------
137
+
138
+ const READ_DESCRIPTION =
139
+ '读取知识库中的飞书电子表格(sheet)。doc 传知识库节点链接或 node_token。'
140
+ + '第一次读取某个表格会在话题里弹出授权卡,由话题发起人确认,确认后本话题内再次读取不再询问;等待期间不要重复调用。'
141
+ + '返回 sheets(子表列表);sheet 传子表 sheet_id 或标题选择子表,只有一个可见子表时可省略,有多个而未指定时只返回子表列表。'
142
+ + 'range 为不带子表前缀的 A1 范围(如 A1:F30、A:C),省略时读整张子表。内容为 CSV(显示值),每行前缀 [row=N] 是真实行号,引用行号时以它为准,不要自己数行。'
143
+ + `max_chars 默认 ${READ_DEFAULT_CHARS};has_more 为 true 时表示被截断,从 actual_range 末行之后接着读。`
144
+ + '返回的 revision_id 用于 feishu_wiki_sheet_edit;editable 表示能否修改(只有本应用创建的表格可以修改)。'
145
+
146
+ const EDIT_DESCRIPTION =
147
+ '修改知识库中由本应用创建的飞书电子表格(sheet)的单元格值。先用 feishu_wiki_sheet_read 读取,把读到的 revision_id 原样传入;表格在此之后被改过会被拒绝,需要重新读取。'
148
+ + `operations 按顺序执行(最多 ${WIKI_SHEET_EDIT_MAX_OPERATIONS} 个,合计不超过 ${WIKI_SHEET_EDIT_MAX_CELLS} 个单元格):`
149
+ + 'write(从 start_cell 如 B3 开始写入二维数组 values)、append(在子表现有数据的最后一行之后追加 values,start_column 默认 A;目标单元格非空时失败,不会覆盖)。返回 formula_errors 时,这些公式没有编译成功、已按文本写入,需要修正后重写。'
150
+ + 'values 每个元素是一行;单元格可以是字符串、数字、布尔或 null(null 表示保持原值不变);以 = 开头的字符串作为公式写入。'
151
+ + '数字、金额、百分比(0.4 表示 40%)写成数字;编号、前导零、身份证号写成字符串。不会修改样式、图表、行列结构。'
152
+ + 'sheet 传子表 sheet_id 或标题,只有一个可见子表时可省略。每次修改都会在话题里弹出授权卡,由话题发起人确认后才执行;等待期间不要重复调用。'
153
+
154
+ // ---- Parameters ---------------------------------------------------------
155
+
156
+ const REF_DESCRIPTION = '知识库节点链接 https://<租户>.feishu.cn/wiki/<node_token> 或 node_token'
157
+ const SHEET_PROPERTY = { type: 'string', description: '子表 sheet_id 或标题;只有一个可见子表时可省略' }
158
+ const VALUES_PROPERTY = {
159
+ type: 'array',
160
+ description: '二维数组,每个元素是一行;单元格为字符串、数字、布尔或 null(保持原值),以 = 开头的字符串是公式',
161
+ items: { type: 'array', items: { type: ['string', 'number', 'boolean', 'null'] } },
162
+ }
163
+
164
+ const READ_PARAMETERS: Record<string, unknown> = {
165
+ type: 'object',
166
+ properties: {
167
+ doc: { type: 'string', description: REF_DESCRIPTION },
168
+ sheet: SHEET_PROPERTY,
169
+ range: { type: 'string', description: '不带子表前缀的 A1 范围,如 A1:F30;省略读整张子表' },
170
+ max_chars: { type: 'integer', minimum: 1_000, maximum: READ_MAX_CHARS, description: `返回 CSV 的字符上限,默认 ${READ_DEFAULT_CHARS}` },
171
+ },
172
+ required: ['doc'],
173
+ additionalProperties: false,
174
+ }
175
+
176
+ const EDIT_PARAMETERS: Record<string, unknown> = {
177
+ type: 'object',
178
+ properties: {
179
+ doc: { type: 'string', description: REF_DESCRIPTION },
180
+ revision_id: { type: 'integer', description: 'feishu_wiki_sheet_read 返回的 revision_id' },
181
+ operations: {
182
+ type: 'array',
183
+ minItems: 1,
184
+ maxItems: WIKI_SHEET_EDIT_MAX_OPERATIONS,
185
+ items: {
186
+ type: 'object',
187
+ properties: {
188
+ command: { type: 'string', enum: ['write', 'append'] },
189
+ sheet: SHEET_PROPERTY,
190
+ start_cell: { type: 'string', description: 'write 的起始单元格,如 B3' },
191
+ start_column: { type: 'string', description: 'append 的起始列,默认 A' },
192
+ values: VALUES_PROPERTY,
193
+ },
194
+ required: ['command', 'values'],
195
+ additionalProperties: false,
196
+ },
197
+ },
198
+ },
199
+ required: ['doc', 'revision_id', 'operations'],
200
+ additionalProperties: false,
201
+ }
202
+
203
+ // ---- Shared steps -------------------------------------------------------
204
+
205
+ interface SheetTarget {
206
+ readonly context: WikiCallContext
207
+ readonly sheets: SheetApi
208
+ readonly node: WikiNode
209
+ readonly url: string
210
+ }
211
+
212
+ async function sheetTarget(kit: WikiToolKit, context: WikiCallContext, doc: string): Promise<SheetTarget | WikiFailure> {
213
+ const sheets = context.sheets
214
+ if (sheets === undefined) return failure('CHANNEL_UNAVAILABLE')
215
+ const ref = parseWikiRef(doc)
216
+ if (ref === undefined) return failure('INVALID_REFERENCE')
217
+ const node = await kit.containedNode(context, ref)
218
+ if ('ok' in node) return node
219
+ if (node.objType !== 'sheet') return failure('UNSUPPORTED_TYPE', `${failure('UNSUPPORTED_TYPE').reason}(该节点类型:${node.objType})`)
220
+ return { context, sheets, node, url: nodeUrl(context.roots, node) }
221
+ }
222
+
223
+ function sheetView(sheet: SheetInfo): WikiSheetView {
224
+ return {
225
+ sheet_id: sheet.sheetId,
226
+ title: sheet.title,
227
+ index: sheet.index,
228
+ resource_type: sheet.resourceType,
229
+ hidden: sheet.hidden,
230
+ ...(sheet.rowCount === undefined ? {} : { row_count: sheet.rowCount }),
231
+ ...(sheet.columnCount === undefined ? {} : { column_count: sheet.columnCount }),
232
+ }
233
+ }
234
+
235
+ /** The named grid sheet, the only visible grid sheet, or why neither applies. */
236
+ function pickSheet(workbook: SheetWorkbook, wanted: string | undefined): SheetInfo | string {
237
+ const grids = workbook.sheets.filter(sheet => sheet.resourceType === 'sheet')
238
+ if (wanted !== undefined) {
239
+ const match = workbook.sheets.find(sheet => sheet.sheetId === wanted)
240
+ ?? workbook.sheets.find(sheet => sheet.title === wanted)
241
+ if (match === undefined) return `找不到子表「${wanted}」;可用子表:${sheetList(workbook)}`
242
+ if (match.resourceType !== 'sheet') return `子表「${match.title}」是 ${match.resourceType},不是单元格表格,不能按单元格读写`
243
+ return match
244
+ }
245
+ const visible = grids.filter(sheet => !sheet.hidden)
246
+ if (visible.length === 1 && visible[0] !== undefined) return visible[0]
247
+ return `表格有多个子表,请用 sheet 指定:${sheetList(workbook)}`
248
+ }
249
+
250
+ function sheetList(workbook: SheetWorkbook): string {
251
+ return workbook.sheets.map(sheet => `${sheet.title}(${sheet.sheetId})`).join('、') || '无'
252
+ }
253
+
254
+ function wholeSheetRange(sheet: SheetInfo): string {
255
+ return sheet.rowCount !== undefined && sheet.columnCount !== undefined && sheet.rowCount > 0 && sheet.columnCount > 0
256
+ ? `A1:${columnLetters(sheet.columnCount)}${sheet.rowCount}`
257
+ : 'A:ZZZ'
258
+ }
259
+
260
+ // ---- feishu_wiki_sheet_read --------------------------------------------
261
+
262
+ async function read(kit: WikiToolKit, sessionId: string, rawArgs: Record<string, unknown>, signal: AbortSignal): Promise<object> {
263
+ const args = parseArgs(WIKI_SHEET_READ_TOOL_NAME, wikiSheetReadArgsSchema, rawArgs)
264
+ const gate = kit.gate(sessionId, WIKI_SHEET_READ_TOOL_NAME)
265
+ if (!gate.ok) return gate
266
+ return await guarded<WikiSheetReadValue>(kit, WIKI_SHEET_READ_TOOL_NAME, async () => {
267
+ const target = await sheetTarget(kit, gate.context, args.doc)
268
+ if ('ok' in target) return target
269
+ const denied = await kit.readConsent(sessionId, target.context, target.node, signal)
270
+ if (denied !== undefined) return denied
271
+ if (signal.aborted) return failure('ABORTED')
272
+ const workbook = await target.sheets.workbook(target.node.objToken)
273
+ const base = {
274
+ ok: true as const,
275
+ title: target.node.title,
276
+ node_token: target.node.nodeToken,
277
+ url: target.url,
278
+ ...(workbook.revision === undefined ? {} : { revision_id: workbook.revision }),
279
+ sheets: workbook.sheets.map(sheetView),
280
+ editable: kit.writeEnabled() && ownedByApp(target.node, target.context.botOpenId),
281
+ }
282
+ const sheet = pickSheet(workbook, args.sheet)
283
+ if (typeof sheet === 'string') {
284
+ if (args.sheet !== undefined) return failure('INVALID_OPERATION', sheet)
285
+ return { ...base, warning: sheet, content: '' }
286
+ }
287
+ const csv = await target.sheets.readCsv(target.node.objToken, {
288
+ sheetId: sheet.sheetId,
289
+ range: args.range ?? wholeSheetRange(sheet),
290
+ maxChars: args.max_chars ?? READ_DEFAULT_CHARS,
291
+ })
292
+ return {
293
+ ...base,
294
+ sheet_id: sheet.sheetId,
295
+ ...(csv.actualRange === undefined ? {} : { actual_range: csv.actualRange }),
296
+ ...(csv.currentRegion === undefined ? {} : { current_region: csv.currentRegion }),
297
+ has_more: csv.hasMore,
298
+ ...(csv.warning === undefined ? {} : { warning: csv.warning }),
299
+ content: csv.csv,
300
+ }
301
+ })
302
+ }
303
+
304
+ // ---- feishu_wiki_sheet_edit --------------------------------------------
305
+
306
+ interface PlannedWrite {
307
+ readonly operation: WikiSheetOperation
308
+ readonly sheet: SheetInfo
309
+ /** Fixed top-left for `write`; for `append` only the column is fixed. */
310
+ readonly start: A1Cell
311
+ readonly rows: SheetCellValue[][]
312
+ readonly columns: number
313
+ }
314
+
315
+ /** Rows padded with `null` (unchanged) to one rectangle. */
316
+ function rectangular(values: readonly (readonly SheetCellValue[])[]): { rows: SheetCellValue[][]; columns: number } {
317
+ const columns = Math.max(...values.map(row => row.length))
318
+ return { rows: values.map(row => [...row, ...Array<SheetCellValue>(columns - row.length).fill(null)]), columns }
319
+ }
320
+
321
+ function plan(workbook: SheetWorkbook, operations: readonly WikiSheetOperation[]): PlannedWrite[] | WikiFailure {
322
+ const planned: PlannedWrite[] = []
323
+ let cells = 0
324
+ for (const [index, operation] of operations.entries()) {
325
+ const where = `第 ${index + 1} 个操作(${operation.command})`
326
+ const sheet = pickSheet(workbook, operation.sheet)
327
+ if (typeof sheet === 'string') return failure('INVALID_OPERATION', `${where}:${sheet}`)
328
+ let start: A1Cell | undefined
329
+ if (operation.command === 'write') {
330
+ start = parseCell(operation.start_cell)
331
+ if (start === undefined) return failure('INVALID_OPERATION', `${where}:start_cell 需要单个单元格,例如 B3`)
332
+ } else {
333
+ const column = parseCell(`${operation.start_column ?? 'A'}1`)
334
+ if (column === undefined) return failure('INVALID_OPERATION', `${where}:start_column 需要列字母,例如 A`)
335
+ start = column
336
+ }
337
+ const { rows, columns } = rectangular(operation.values)
338
+ cells += rows.length * columns
339
+ if (cells > WIKI_SHEET_EDIT_MAX_CELLS) {
340
+ return failure('INVALID_OPERATION', `一次最多写入 ${WIKI_SHEET_EDIT_MAX_CELLS} 个单元格,请拆成多次调用`)
341
+ }
342
+ planned.push({ operation, sheet, start, rows, columns })
343
+ }
344
+ return planned
345
+ }
346
+
347
+ function cardLines(planned: readonly PlannedWrite[]): string[] {
348
+ const lines = planned.map((write, index) => {
349
+ const size = `${write.rows.length} 行 × ${write.columns} 列`
350
+ const where = write.operation.command === 'write'
351
+ ? `写入子表「${preview(write.sheet.title, 40)}」的 ${rectangle(write.start, write.rows.length, write.columns)}(${size})`
352
+ : `在子表「${preview(write.sheet.title, 40)}」末尾追加 ${size}(从 ${columnLetters(write.start.column)} 列开始)`
353
+ const sample = write.rows.slice(0, CARD_PREVIEW_ROWS)
354
+ .map(row => ` ${preview(row.map(cell => cell === null ? '' : String(cell)).join(' | '), 80)}`)
355
+ const more = write.rows.length > CARD_PREVIEW_ROWS ? [` …(共 ${write.rows.length} 行)`] : []
356
+ return [`${index + 1}. ${where}`, ...sample, ...more].join('\n')
357
+ })
358
+ return [`**将执行 ${planned.length} 个操作**`, ...lines]
359
+ }
360
+
361
+ /** First empty row after the sheet's data (1 for an empty sheet), or undefined when it cannot be determined. */
362
+ async function appendRow(sheets: SheetApi, token: string, sheet: SheetInfo): Promise<number | undefined> {
363
+ const probe = await sheets.readCsv(token, { sheetId: sheet.sheetId, range: wholeSheetRange(sheet), maxChars: EXTENT_PROBE_CHARS })
364
+ const region = probe.currentRegion ?? (probe.hasMore ? undefined : probe.actualRange)
365
+ let last = region === undefined ? (probe.csv.trim() === '' && !probe.hasMore ? 0 : undefined) : rangeEndRow(region)
366
+ if (last === undefined) return undefined
367
+ // `current_region` stops at a fully blank row, so later data regions are found by scanning the rows below it.
368
+ const lastColumn = columnLetters(sheet.columnCount !== undefined && sheet.columnCount > 0 ? sheet.columnCount : MAX_SCAN_COLUMN)
369
+ const lastRow = sheet.rowCount !== undefined && sheet.rowCount > 0 ? sheet.rowCount : MAX_SCAN_ROW
370
+ let from = last + 1
371
+ for (let page = 0; from <= lastRow; page++) {
372
+ if (page === EXTENT_SCAN_PAGES) return undefined
373
+ const scan = await sheets.readCsv(token, { sheetId: sheet.sheetId, range: `A${from}:${lastColumn}${lastRow}`, maxChars: EXTENT_SCAN_CHARS })
374
+ last = Math.max(last, lastFilledRow(scan.csv))
375
+ if (!scan.hasMore) break
376
+ const end = scan.actualRange === undefined ? undefined : rangeEndRow(scan.actualRange)
377
+ if (end === undefined || end < from) return undefined
378
+ from = end + 1
379
+ }
380
+ return last + 1
381
+ }
382
+
383
+ /** Largest `[row=N]` whose record has a non-empty cell, or 0. */
384
+ function lastFilledRow(csv: string): number {
385
+ let last = 0
386
+ for (const match of csv.matchAll(/^\[row=(\d+)\] ?(.*)$/gmu)) {
387
+ if (/[^,\s"]/u.test(match[2] ?? '')) last = Math.max(last, Number(match[1]))
388
+ }
389
+ return last
390
+ }
391
+
392
+ async function edit(kit: WikiToolKit, sessionId: string, rawArgs: Record<string, unknown>, signal: AbortSignal): Promise<object> {
393
+ const args = parseArgs(WIKI_SHEET_EDIT_TOOL_NAME, wikiSheetEditArgsSchema, rawArgs)
394
+ const gate = kit.gate(sessionId, WIKI_SHEET_EDIT_TOOL_NAME)
395
+ if (!gate.ok) return gate
396
+ return await guarded<WikiSheetEditValue>(kit, WIKI_SHEET_EDIT_TOOL_NAME, async () => {
397
+ const target = await sheetTarget(kit, gate.context, args.doc)
398
+ if ('ok' in target) return target
399
+ const { node, sheets } = target
400
+ if (!ownedByApp(node, target.context.botOpenId)) {
401
+ return failure('NOT_OWNED', `${failure('NOT_OWNED').reason}(该表格所有者:${node.owner ?? '未知'})`)
402
+ }
403
+ const workbook = await sheets.workbook(node.objToken)
404
+ if (workbook.revision !== undefined && workbook.revision !== args.revision_id) {
405
+ return { ...failure('REVISION_CONFLICT', `${failure('REVISION_CONFLICT').reason}(读取时 ${args.revision_id},当前 ${workbook.revision})`), revision_id: workbook.revision }
406
+ }
407
+ const planned = plan(workbook, args.operations)
408
+ if (!Array.isArray(planned)) return planned
409
+ const denied = await kit.consent(sessionId, target.context, 'edit', {
410
+ title: node.title,
411
+ url: target.url,
412
+ location: await kit.locationOf(target.context.api, node),
413
+ details: cardLines(planned),
414
+ }, signal)
415
+ if (denied !== undefined) return denied
416
+ // The workbook may have changed while the card was open.
417
+ const after = await sheets.workbook(node.objToken)
418
+ if (after.revision !== undefined && after.revision !== args.revision_id) {
419
+ return { ...failure('REVISION_CONFLICT', `${failure('REVISION_CONFLICT').reason}(等待授权期间表格被修改)`), revision_id: after.revision }
420
+ }
421
+ const written: Array<{ sheet_id: string; range: string }> = []
422
+ const formulaErrors: Array<NonNullable<WikiSheetEditValue['formula_errors']>[number]> = []
423
+ for (const [index, write] of planned.entries()) {
424
+ if (signal.aborted) return { ...failure('ABORTED', `已取消;前 ${index} 个操作已执行`), applied: index }
425
+ try {
426
+ let start = write.start
427
+ if (write.operation.command === 'append') {
428
+ const row = await appendRow(sheets, node.objToken, write.sheet)
429
+ if (row === undefined) {
430
+ return { ...failure('EDIT_FAILED', `第 ${index + 1} 个操作失败:无法确定子表「${write.sheet.title}」的数据末行,请改用 write 指定 start_cell;前 ${index} 个操作已执行`), applied: index }
431
+ }
432
+ start = { column: write.start.column, row }
433
+ }
434
+ const range = rectangle(start, write.rows.length, write.columns)
435
+ const result = await sheets.writeCells(node.objToken, {
436
+ sheetId: write.sheet.sheetId,
437
+ range,
438
+ rows: write.rows,
439
+ ...(write.operation.command === 'append' ? { allowOverwrite: false } : {}),
440
+ })
441
+ written.push({ sheet_id: write.sheet.sheetId, range })
442
+ for (const error of result.formulaErrors) {
443
+ formulaErrors.push({
444
+ sheet_id: write.sheet.sheetId,
445
+ cell: error.cell,
446
+ ...(error.formula === undefined ? {} : { formula: error.formula }),
447
+ ...(error.errorType === undefined ? {} : { error_type: error.errorType }),
448
+ ...(error.detail === undefined ? {} : { detail: error.detail }),
449
+ })
450
+ }
451
+ } catch (error) {
452
+ const reason = failureOf(error).reason
453
+ return { ...failure('EDIT_FAILED', `第 ${index + 1} 个操作失败:${reason};前 ${index} 个操作已执行`), applied: index }
454
+ }
455
+ }
456
+ const revision = await sheets.workbook(node.objToken).then(final => final.revision, (error: unknown) => {
457
+ kit.warn('dsh-channels: reading the revision after %s failed: %s', WIKI_SHEET_EDIT_TOOL_NAME, describeError(error))
458
+ return undefined
459
+ })
460
+ return {
461
+ ok: true,
462
+ title: node.title,
463
+ node_token: node.nodeToken,
464
+ url: target.url,
465
+ applied: planned.length,
466
+ written,
467
+ ...(revision === undefined ? {} : { revision_id: revision }),
468
+ ...(formulaErrors.length === 0 ? {} : { formula_errors: formulaErrors }),
469
+ }
470
+ })
471
+ }
472
+
473
+ // ---- Specs --------------------------------------------------------------
474
+
475
+ /** The spreadsheet tool family. */
476
+ export const SHEET_TOOL_SPECS: readonly WikiToolSpec[] = [
477
+ {
478
+ name: WIKI_SHEET_READ_TOOL_NAME,
479
+ access: 'read',
480
+ description: READ_DESCRIPTION,
481
+ parameters: READ_PARAMETERS,
482
+ execute: read,
483
+ presentCall: args => `读取知识库表格:${display(args.doc)}`,
484
+ },
485
+ {
486
+ name: WIKI_SHEET_EDIT_TOOL_NAME,
487
+ access: 'write',
488
+ description: EDIT_DESCRIPTION,
489
+ parameters: EDIT_PARAMETERS,
490
+ execute: edit,
491
+ presentCall: args => `修改知识库表格:${display(args.doc)}`,
492
+ },
493
+ ]