@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/README.md +6 -6
- package/docs/attachment-pipeline-v2.md +2 -2
- package/docs/feishu-onboarding.md +4 -3
- package/lib/index.js +1606 -248
- package/lib/index.js.map +1 -1
- package/package.json +1 -1
- package/src/config.ts +26 -14
- package/src/feishu/scopes.ts +13 -1
- package/src/feishu/wiki/a1.ts +97 -0
- package/src/feishu/wiki/api.ts +21 -6
- package/src/feishu/wiki/base-api.ts +267 -0
- package/src/feishu/wiki/base-tools.ts +423 -0
- package/src/feishu/wiki/shared.ts +284 -0
- package/src/feishu/wiki/sheet-api.ts +250 -0
- package/src/feishu/wiki/sheet-tools.ts +493 -0
- package/src/feishu/wiki/tools.ts +102 -189
- package/src/feishu-channel.ts +14 -1
- package/src/settings/surface.ts +6 -6
|
@@ -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
|
+
]
|