@modesto_ma/dsh-channels 0.5.0-beta.2 → 0.5.0-beta.4
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 +72 -15
- package/docs/attachment-pipeline-v2.md +2 -2
- package/docs/feishu-onboarding.md +4 -3
- package/lib/client.js +369 -94
- package/lib/client.js.map +1 -1
- package/lib/index.js +7847 -5293
- package/lib/index.js.map +1 -1
- package/package.json +1 -1
- package/src/access/controller.ts +32 -1
- package/src/analyze/contract.ts +121 -0
- package/src/analyze/launcher.ts +353 -0
- package/src/analyze/server.ts +201 -0
- package/src/cards/analyze.ts +116 -0
- package/src/cards/index.ts +1 -0
- package/src/cards/streaming.ts +58 -0
- package/src/client/api.ts +15 -0
- package/src/client/panel/access-card.tsx +96 -70
- package/src/client/panel/analyze-token-card.tsx +70 -0
- package/src/client/panel/index.tsx +72 -10
- package/src/client/panel/sessions-tab.tsx +7 -4
- package/src/client/panel/shared.tsx +12 -0
- package/src/client/panel/wiki-tab.tsx +62 -0
- package/src/config.ts +52 -14
- package/src/domain.ts +55 -0
- package/src/feishu/scopes.ts +19 -1
- package/src/feishu/tools.ts +3 -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/health.ts +12 -0
- package/src/host/agent.ts +5 -1
- package/src/index.ts +1 -1
- package/src/interactions/pending.ts +11 -2
- package/src/routing/inbound.ts +57 -14
- package/src/service.ts +161 -28
- package/src/settings/contract.ts +37 -6
- package/src/settings/routes.ts +66 -14
- package/src/settings/surface.ts +69 -20
- package/src/streamer.ts +156 -7
- package/src/workspaces.ts +31 -0
|
@@ -0,0 +1,284 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Failure values and helpers shared by the wiki tool families (docx, sheet,
|
|
3
|
+
* base): stable failure codes with readable reasons, argument parsing,
|
|
4
|
+
* Feishu error mapping, ownership, links, and consent-card previews.
|
|
5
|
+
*
|
|
6
|
+
* @module dsh-channels/feishu/wiki/shared
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import type { z } from 'zod'
|
|
10
|
+
import { FeishuApiError } from '../../feishu-channel.ts'
|
|
11
|
+
import type { WikiConsentAction } from '../../cards/wiki.ts'
|
|
12
|
+
import type { InteractionRoute } from '../../interactions/pending.ts'
|
|
13
|
+
import type { WikiApi, WikiNode } from './api.ts'
|
|
14
|
+
import type { BaseApi } from './base-api.ts'
|
|
15
|
+
import type { WikiConsentOutcome } from './consent.ts'
|
|
16
|
+
import type { WikiRef, WikiRoot } from './links.ts'
|
|
17
|
+
import type { SheetApi } from './sheet-api.ts'
|
|
18
|
+
|
|
19
|
+
/** What every gated call works with. */
|
|
20
|
+
export interface WikiCallContext {
|
|
21
|
+
/** Topic and owner the consent cards go to. */
|
|
22
|
+
readonly route: InteractionRoute
|
|
23
|
+
readonly api: WikiApi
|
|
24
|
+
/** Absent on channel stubs without spreadsheet support. */
|
|
25
|
+
readonly sheets?: SheetApi
|
|
26
|
+
/** Absent on channel stubs without base support. */
|
|
27
|
+
readonly bases?: BaseApi
|
|
28
|
+
readonly roots: readonly WikiRoot[]
|
|
29
|
+
readonly botOpenId: string | undefined
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Outcome of the per-call gates. */
|
|
33
|
+
export type WikiGate = { ok: true; context: WikiCallContext } | WikiFailure
|
|
34
|
+
|
|
35
|
+
/** Consent card summary fields. */
|
|
36
|
+
export interface WikiConsentFields {
|
|
37
|
+
readonly title: string
|
|
38
|
+
readonly url?: string
|
|
39
|
+
readonly location: string
|
|
40
|
+
readonly details: readonly string[]
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** The gates and consent steps every wiki tool family runs through (implemented by the wiki tool runtime). */
|
|
44
|
+
export interface WikiToolKit {
|
|
45
|
+
/**
|
|
46
|
+
* Switch, route, whitelist, and channel gates of one call.
|
|
47
|
+
* @param sessionId The calling Session.
|
|
48
|
+
* @param tool Tool name (its switch decides).
|
|
49
|
+
* @returns The call context, or the failure.
|
|
50
|
+
*/
|
|
51
|
+
gate(sessionId: string, tool: string): WikiGate
|
|
52
|
+
/**
|
|
53
|
+
* Resolve a node reference and check `wikiRoots` containment.
|
|
54
|
+
* @param context The call context.
|
|
55
|
+
* @param ref Parsed reference (a space is rejected).
|
|
56
|
+
* @returns The node, or the failure.
|
|
57
|
+
*/
|
|
58
|
+
containedNode(context: WikiCallContext, ref: WikiRef): Promise<WikiNode | WikiFailure>
|
|
59
|
+
/**
|
|
60
|
+
* Ask the topic owner on a consent card and wait.
|
|
61
|
+
* @param sessionId The calling Session.
|
|
62
|
+
* @param context The call context.
|
|
63
|
+
* @param action What is asked for.
|
|
64
|
+
* @param fields Card summary.
|
|
65
|
+
* @param signal The tool call's abort signal.
|
|
66
|
+
* @returns Undefined when allowed, else the failure.
|
|
67
|
+
*/
|
|
68
|
+
consent(
|
|
69
|
+
sessionId: string,
|
|
70
|
+
context: WikiCallContext,
|
|
71
|
+
action: WikiConsentAction,
|
|
72
|
+
fields: WikiConsentFields,
|
|
73
|
+
signal: AbortSignal,
|
|
74
|
+
): Promise<WikiFailure | undefined>
|
|
75
|
+
/**
|
|
76
|
+
* Read consent for one node: reuses the Session's standing grant, otherwise asks and records it.
|
|
77
|
+
* @param sessionId The calling Session.
|
|
78
|
+
* @param context The call context.
|
|
79
|
+
* @param node The node to open.
|
|
80
|
+
* @param signal The tool call's abort signal.
|
|
81
|
+
* @returns Undefined when reading may proceed, else the failure.
|
|
82
|
+
*/
|
|
83
|
+
readConsent(sessionId: string, context: WikiCallContext, node: WikiNode, signal: AbortSignal): Promise<WikiFailure | undefined>
|
|
84
|
+
/**
|
|
85
|
+
* Record a standing read grant (for a document the owner just approved creating).
|
|
86
|
+
* @param sessionId The calling Session.
|
|
87
|
+
* @param documentId The node's `objToken`.
|
|
88
|
+
*/
|
|
89
|
+
grantRead(sessionId: string, documentId: string): void
|
|
90
|
+
/**
|
|
91
|
+
* `空间 / 父节点` label of a node.
|
|
92
|
+
* @param api Wiki endpoints.
|
|
93
|
+
* @param node The node.
|
|
94
|
+
* @returns The label.
|
|
95
|
+
*/
|
|
96
|
+
locationOf(api: WikiApi, node: WikiNode): Promise<string>
|
|
97
|
+
/** Whether writes are switched on (`feishuTools.wikiWrite`). */
|
|
98
|
+
writeEnabled(): boolean
|
|
99
|
+
/**
|
|
100
|
+
* Structured warning sink.
|
|
101
|
+
* @param message Format string.
|
|
102
|
+
* @param args Format arguments.
|
|
103
|
+
*/
|
|
104
|
+
warn(message: string, ...args: unknown[]): void
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** One model-facing tool of a wiki tool family (the runtime adds output rendering and mounting). */
|
|
108
|
+
export interface WikiToolSpec {
|
|
109
|
+
readonly name: string
|
|
110
|
+
/** Switch that mounts it: `wikiRead` or `wikiWrite`. */
|
|
111
|
+
readonly access: 'read' | 'write'
|
|
112
|
+
readonly description: string
|
|
113
|
+
readonly parameters: Record<string, unknown>
|
|
114
|
+
/**
|
|
115
|
+
* Run one call; never rejects for Feishu failures (they become failure values).
|
|
116
|
+
* @param kit Gates and consent.
|
|
117
|
+
* @param sessionId The calling Session.
|
|
118
|
+
* @param rawArgs Model arguments (validated here).
|
|
119
|
+
* @param signal The tool call's abort signal.
|
|
120
|
+
* @returns The tool value.
|
|
121
|
+
*/
|
|
122
|
+
execute(kit: WikiToolKit, sessionId: string, rawArgs: Record<string, unknown>, signal: AbortSignal): Promise<object>
|
|
123
|
+
/**
|
|
124
|
+
* One-line call presentation.
|
|
125
|
+
* @param args Model arguments.
|
|
126
|
+
* @returns Display text.
|
|
127
|
+
*/
|
|
128
|
+
presentCall(args: Record<string, unknown>): string
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/** Characters of each content shown on a consent card. */
|
|
132
|
+
export const PREVIEW_CHARS = 300
|
|
133
|
+
|
|
134
|
+
// ---- Results ------------------------------------------------------------
|
|
135
|
+
|
|
136
|
+
/** Stable failure codes; the readable `reason` carries the details. */
|
|
137
|
+
export type WikiFailureCode =
|
|
138
|
+
| 'TOOL_DISABLED'
|
|
139
|
+
| 'ROUTE_NOT_FOUND'
|
|
140
|
+
| 'NOT_AUTHORIZED'
|
|
141
|
+
| 'CHANNEL_UNAVAILABLE'
|
|
142
|
+
| 'INVALID_REFERENCE'
|
|
143
|
+
| 'INVALID_OPERATION'
|
|
144
|
+
| 'OUTSIDE_ROOTS'
|
|
145
|
+
| 'NOT_FOUND'
|
|
146
|
+
| 'NO_ACCESS'
|
|
147
|
+
| 'UNSUPPORTED_TYPE'
|
|
148
|
+
| 'NOT_OWNED'
|
|
149
|
+
| 'REVISION_CONFLICT'
|
|
150
|
+
| 'CONSENT_DENIED'
|
|
151
|
+
| 'CONSENT_EXPIRED'
|
|
152
|
+
| 'CONSENT_UNAVAILABLE'
|
|
153
|
+
| 'PERMISSION_DENIED'
|
|
154
|
+
| 'CONTENT_FAILED'
|
|
155
|
+
| 'EDIT_FAILED'
|
|
156
|
+
| 'EDIT_PARTIAL'
|
|
157
|
+
| 'API_FAILED'
|
|
158
|
+
| 'ABORTED'
|
|
159
|
+
|
|
160
|
+
export const FAILURE_REASONS: Readonly<Record<WikiFailureCode, string>> = {
|
|
161
|
+
TOOL_DISABLED: '知识库工具已在插件配置中关闭',
|
|
162
|
+
ROUTE_NOT_FOUND: '当前会话没有绑定飞书话题,无法确定授权人',
|
|
163
|
+
NOT_AUTHORIZED: '当前话题的发起人不在知识库白名单中;请管理员发送 /auth wiki add @该用户 后重试',
|
|
164
|
+
CHANNEL_UNAVAILABLE: '飞书连接当前不可用,请稍后重试',
|
|
165
|
+
INVALID_REFERENCE: '无法识别的知识库引用:请传知识库节点链接 https://<租户>.feishu.cn/wiki/<node_token>、node_token,或知识空间链接 https://<租户>.feishu.cn/wiki/space/<space_id>',
|
|
166
|
+
INVALID_OPERATION: '操作参数不正确',
|
|
167
|
+
OUTSIDE_ROOTS: '该位置不在插件配置的知识库范围(wikiRoots)内,不能访问',
|
|
168
|
+
NOT_FOUND: '知识库节点不存在或已被删除',
|
|
169
|
+
NO_ACCESS: '机器人对该知识库节点没有访问权限;请知识库管理员把应用(或包含机器人的群)加为该空间成员',
|
|
170
|
+
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;其他类型暂不支持',
|
|
171
|
+
NOT_OWNED: '只能修改由本应用创建(所有者是本应用)的文档',
|
|
172
|
+
REVISION_CONFLICT: '文档在你读取之后被修改过;请重新用 feishu_wiki_read 读取最新内容,基于新的 revision_id 再修改',
|
|
173
|
+
CONSENT_DENIED: '话题发起人拒绝了这次知识库操作',
|
|
174
|
+
CONSENT_EXPIRED: '话题发起人没有在授权卡有效期内确认,已自动拒绝',
|
|
175
|
+
CONSENT_UNAVAILABLE: '授权卡发送失败,无法征得话题发起人同意,已取消操作',
|
|
176
|
+
PERMISSION_DENIED: '飞书拒绝调用:应用缺少知识库或文档权限(wiki:wiki、docx:document),请管理员在开发者后台开通并发布版本',
|
|
177
|
+
CONTENT_FAILED: '文档已创建,但写入内容失败',
|
|
178
|
+
EDIT_FAILED: '修改失败',
|
|
179
|
+
EDIT_PARTIAL: '飞书报告操作只部分生效;已停止后续操作,请用 feishu_wiki_read 读取最新内容核验后再继续',
|
|
180
|
+
API_FAILED: '飞书接口调用失败',
|
|
181
|
+
ABORTED: '已取消',
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/** One failed call. */
|
|
185
|
+
export interface WikiFailure {
|
|
186
|
+
readonly ok: false
|
|
187
|
+
readonly code: WikiFailureCode
|
|
188
|
+
readonly reason: string
|
|
189
|
+
/** Set by `CONTENT_FAILED`: the document that was created anyway. */
|
|
190
|
+
readonly node_token?: string
|
|
191
|
+
readonly url?: string
|
|
192
|
+
/** Set by `EDIT_FAILED`/`EDIT_PARTIAL`: operations fully applied before the stop. */
|
|
193
|
+
readonly applied?: number
|
|
194
|
+
/** Set by `REVISION_CONFLICT`: the current revision. */
|
|
195
|
+
readonly revision_id?: number
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
export function failure(code: WikiFailureCode, reason?: string): WikiFailure {
|
|
199
|
+
return { ok: false, code, reason: reason ?? FAILURE_REASONS[code] }
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
export function parseArgs<T>(tool: string, schema: z.ZodType<T>, rawArgs: Record<string, unknown>): T {
|
|
203
|
+
const parsed = schema.safeParse(rawArgs)
|
|
204
|
+
if (parsed.success) return parsed.data
|
|
205
|
+
const issue = parsed.error.issues[0]
|
|
206
|
+
const where = issue === undefined || issue.path.length === 0 ? 'arguments' : issue.path.join('.')
|
|
207
|
+
throw new Error(`${tool}: invalid ${where}: ${issue?.message ?? 'invalid arguments'}`)
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/** Run one gated call body, mapping thrown Feishu errors to failures. */
|
|
211
|
+
export async function guarded<T>(
|
|
212
|
+
deps: { warn(message: string, ...args: unknown[]): void },
|
|
213
|
+
tool: string,
|
|
214
|
+
body: () => Promise<T | WikiFailure>,
|
|
215
|
+
): Promise<T | WikiFailure> {
|
|
216
|
+
try {
|
|
217
|
+
return await body()
|
|
218
|
+
} catch (error) {
|
|
219
|
+
const mapped = failureOf(error)
|
|
220
|
+
if (mapped.code === 'API_FAILED') deps.warn('dsh-channels: %s failed: %s', tool, mapped.reason)
|
|
221
|
+
return mapped
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/** Feishu codes: node missing/deleted, no resource access, missing app scope. */
|
|
226
|
+
const NOT_FOUND_CODES: ReadonlySet<number> = new Set([131005, 131012, 131013, 131014, 131016])
|
|
227
|
+
const NO_ACCESS_CODES: ReadonlySet<number> = new Set([131006, 1770032, 1770002])
|
|
228
|
+
const PERMISSION_CODES: ReadonlySet<number> = new Set([99991672, 99991679])
|
|
229
|
+
|
|
230
|
+
export function failureOf(error: unknown): WikiFailure {
|
|
231
|
+
const detail = describeError(error)
|
|
232
|
+
if (error instanceof FeishuApiError && error.code !== undefined) {
|
|
233
|
+
if (NOT_FOUND_CODES.has(error.code)) return failure('NOT_FOUND', `${FAILURE_REASONS.NOT_FOUND}(${detail})`)
|
|
234
|
+
if (NO_ACCESS_CODES.has(error.code)) return failure('NO_ACCESS', `${FAILURE_REASONS.NO_ACCESS}(${detail})`)
|
|
235
|
+
if (PERMISSION_CODES.has(error.code)) return failure('PERMISSION_DENIED', `${FAILURE_REASONS.PERMISSION_DENIED}(${detail})`)
|
|
236
|
+
}
|
|
237
|
+
return failure('API_FAILED', `${FAILURE_REASONS.API_FAILED}:${detail}`)
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
export function consentFailure(outcome: WikiConsentOutcome): WikiFailure | undefined {
|
|
241
|
+
switch (outcome) {
|
|
242
|
+
case 'allowed': return undefined
|
|
243
|
+
case 'denied': return failure('CONSENT_DENIED')
|
|
244
|
+
case 'expired': return failure('CONSENT_EXPIRED')
|
|
245
|
+
case 'cancelled': return failure('ABORTED')
|
|
246
|
+
case 'unavailable': return failure('CONSENT_UNAVAILABLE')
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
/** Whether the app owns a node: Feishu reports the bot open_id as the owner. */
|
|
251
|
+
export function ownedByApp(node: WikiNode, botOpenId: string | undefined): boolean {
|
|
252
|
+
return botOpenId !== undefined && botOpenId !== '' && node.owner === botOpenId
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
/** A node's web link; falls back to the first root's origin. */
|
|
256
|
+
export function nodeUrl(roots: readonly WikiRoot[], node: WikiNode): string {
|
|
257
|
+
if (node.url !== undefined) return node.url
|
|
258
|
+
const first = roots[0]
|
|
259
|
+
const origin = first === undefined ? 'https://feishu.cn' : new URL(first.link).origin
|
|
260
|
+
return `${origin}/wiki/${node.nodeToken}`
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
/** One-line clipped preview with Markdown control characters neutralized. */
|
|
264
|
+
export function preview(value: string, max = 80): string {
|
|
265
|
+
const flat = value.replace(/\s+/gu, ' ').trim().replace(/([[\]()*_`~<>])/gu, '\\$1')
|
|
266
|
+
return flat.length <= max ? flat : `${flat.slice(0, max)}…`
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
/** A fenced preview of the first characters of a content. */
|
|
270
|
+
export function previewBlock(content: string): string {
|
|
271
|
+
const head = content.length <= PREVIEW_CHARS ? content : `${content.slice(0, PREVIEW_CHARS)}\n…`
|
|
272
|
+
return `\`\`\`\n${head.replace(/```/gu, '```')}\n\`\`\``
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
export function display(value: unknown): string {
|
|
276
|
+
return typeof value === 'string' ? preview(value, 120) : '?'
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
/** Readable, bounded text for a thrown value. */
|
|
280
|
+
export function describeError(error: unknown): string {
|
|
281
|
+
const text = error instanceof FeishuApiError ? error.detail : error instanceof Error ? error.message : String(error)
|
|
282
|
+
const cleaned = text.replace(/[\u0000-\u001f\u007f]+/gu, ' ').trim()
|
|
283
|
+
return cleaned.length <= 300 ? cleaned : `${cleaned.slice(0, 300)}…`
|
|
284
|
+
}
|
|
@@ -0,0 +1,250 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Feishu spreadsheet (电子表格) calls used by the wiki sheet tools, made with
|
|
3
|
+
* the app identity (tenant access token).
|
|
4
|
+
*
|
|
5
|
+
* Everything goes through the `sheet_ai/v2` tool endpoints: `POST
|
|
6
|
+
* spreadsheets/:token/tools/invoke_read` (or `invoke_write`) with body
|
|
7
|
+
* `{tool_name, input}`, where `input` is a JSON string that also carries
|
|
8
|
+
* `excel_id`, and the result is the JSON string `data.output`. Read tools sent
|
|
9
|
+
* to `invoke_write` (or the reverse) are rejected by the gateway. Writes carry
|
|
10
|
+
* no base revision and no idempotency key; callers check the workbook
|
|
11
|
+
* revision before writing.
|
|
12
|
+
*
|
|
13
|
+
* @module dsh-channels/feishu/wiki/sheet-api
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { FeishuApiError } from '../../feishu-channel.ts'
|
|
17
|
+
import { call, segment } from './api.ts'
|
|
18
|
+
import type { WikiRawRequest } from './api.ts'
|
|
19
|
+
|
|
20
|
+
/** One sub-sheet of a workbook. */
|
|
21
|
+
export interface SheetInfo {
|
|
22
|
+
readonly sheetId: string
|
|
23
|
+
readonly title: string
|
|
24
|
+
readonly index: number
|
|
25
|
+
/** `sheet` for a cell grid; other kinds (an embedded `bitable`) cannot be read as cells. */
|
|
26
|
+
readonly resourceType: string
|
|
27
|
+
readonly hidden: boolean
|
|
28
|
+
/** Physical grid size (not the data extent); absent for non-grid sheets. */
|
|
29
|
+
readonly rowCount?: number
|
|
30
|
+
readonly columnCount?: number
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** Workbook structure and its current revision. */
|
|
34
|
+
export interface SheetWorkbook {
|
|
35
|
+
/** Monotonic workbook revision; absent when Feishu omits it. */
|
|
36
|
+
readonly revision?: number
|
|
37
|
+
readonly sheets: readonly SheetInfo[]
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** One `get_range_as_csv` request. */
|
|
41
|
+
export interface SheetCsvRequest {
|
|
42
|
+
readonly sheetId: string
|
|
43
|
+
/** Bare A1 range (`A1:F30`, `A:C`), no sheet prefix. */
|
|
44
|
+
readonly range: string
|
|
45
|
+
readonly maxChars: number
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** One range read as CSV. */
|
|
49
|
+
export interface SheetCsv {
|
|
50
|
+
/** RFC 4180 CSV; every record starts with `[row=N] ` (N = sheet row). */
|
|
51
|
+
readonly csv: string
|
|
52
|
+
/** What was actually read (clipped to the grid or by `maxChars`). */
|
|
53
|
+
readonly actualRange?: string
|
|
54
|
+
/** Contiguous non-empty block around the request; stops at a fully empty row or column. */
|
|
55
|
+
readonly currentRegion?: string
|
|
56
|
+
/** True when `maxChars` cut the range; continue after `actualRange`. */
|
|
57
|
+
readonly hasMore: boolean
|
|
58
|
+
readonly warning?: string
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* One cell value to write: a string starting with `=` is a formula, `null`
|
|
63
|
+
* leaves the cell unchanged.
|
|
64
|
+
*/
|
|
65
|
+
export type SheetCellValue = string | number | boolean | null
|
|
66
|
+
|
|
67
|
+
/** One `set_cell_range` request. */
|
|
68
|
+
export interface SheetWriteRequest {
|
|
69
|
+
readonly sheetId: string
|
|
70
|
+
/** Rectangle whose size equals `rows` exactly. */
|
|
71
|
+
readonly range: string
|
|
72
|
+
readonly rows: readonly (readonly SheetCellValue[])[]
|
|
73
|
+
/** False makes the write fail when any target cell is non-empty; the server default is true. */
|
|
74
|
+
readonly allowOverwrite?: boolean
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** One formula `set_cell_range` stored as text because it did not compile. */
|
|
78
|
+
export interface SheetFormulaError {
|
|
79
|
+
readonly cell: string
|
|
80
|
+
readonly formula?: string
|
|
81
|
+
/** `compile_failed` or `non_formula`. */
|
|
82
|
+
readonly errorType?: string
|
|
83
|
+
readonly detail?: string
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** Outcome of one `set_cell_range`; the cells are written even when formulas fail to compile. */
|
|
87
|
+
export interface SheetWriteResult {
|
|
88
|
+
readonly formulaErrors: readonly SheetFormulaError[]
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** The `sheet_ai` spreadsheet tools the wiki sheet tools consume. */
|
|
92
|
+
export interface SheetApi {
|
|
93
|
+
/**
|
|
94
|
+
* Sub-sheets and revision of one workbook.
|
|
95
|
+
* @param token Spreadsheet token (the wiki node's `objToken`).
|
|
96
|
+
* @returns The workbook structure.
|
|
97
|
+
*/
|
|
98
|
+
workbook(token: string): Promise<SheetWorkbook>
|
|
99
|
+
/**
|
|
100
|
+
* Read one range as CSV (formatted display values).
|
|
101
|
+
* @param token Spreadsheet token.
|
|
102
|
+
* @param request Sheet, range, and size cap.
|
|
103
|
+
* @returns The CSV and range metadata.
|
|
104
|
+
*/
|
|
105
|
+
readCsv(token: string, request: SheetCsvRequest): Promise<SheetCsv>
|
|
106
|
+
/**
|
|
107
|
+
* Write one rectangle of values and formulas.
|
|
108
|
+
* @param token Spreadsheet token.
|
|
109
|
+
* @param request Sheet, rectangle, and values.
|
|
110
|
+
* @returns Formulas that failed to compile.
|
|
111
|
+
*/
|
|
112
|
+
writeCells(token: string, request: SheetWriteRequest): Promise<SheetWriteResult>
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
interface RawSheet {
|
|
116
|
+
sheet_id?: unknown
|
|
117
|
+
title?: unknown
|
|
118
|
+
sheet_name?: unknown
|
|
119
|
+
index?: unknown
|
|
120
|
+
resource_type?: unknown
|
|
121
|
+
hidden?: unknown
|
|
122
|
+
is_hidden?: unknown
|
|
123
|
+
row_count?: unknown
|
|
124
|
+
column_count?: unknown
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function invokePath(token: string, kind: 'read' | 'write'): string {
|
|
128
|
+
return `/open-apis/sheet_ai/v2/spreadsheets/${segment(token)}/tools/invoke_${kind}`
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
async function invoke(
|
|
132
|
+
request: WikiRawRequest,
|
|
133
|
+
token: string,
|
|
134
|
+
kind: 'read' | 'write',
|
|
135
|
+
toolName: string,
|
|
136
|
+
input: Record<string, unknown>,
|
|
137
|
+
): Promise<Record<string, unknown>> {
|
|
138
|
+
const url = invokePath(token, kind)
|
|
139
|
+
const data = await call<{ output?: unknown }>(request, {
|
|
140
|
+
method: 'POST',
|
|
141
|
+
url,
|
|
142
|
+
data: { tool_name: toolName, input: JSON.stringify({ excel_id: token, ...input }) },
|
|
143
|
+
})
|
|
144
|
+
if (typeof data.output !== 'string' || data.output === '') return {}
|
|
145
|
+
let output: unknown
|
|
146
|
+
try {
|
|
147
|
+
output = JSON.parse(data.output)
|
|
148
|
+
} catch (error) {
|
|
149
|
+
throw new FeishuApiError(`${toolName}: output is not JSON: ${error instanceof Error ? error.message : String(error)}`)
|
|
150
|
+
}
|
|
151
|
+
if (Array.isArray(output)) return { sheets: output }
|
|
152
|
+
if (typeof output !== 'object' || output === null) throw new FeishuApiError(`${toolName}: output is not an object`)
|
|
153
|
+
return output as Record<string, unknown>
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function optionalString(value: unknown): string | undefined {
|
|
157
|
+
return typeof value === 'string' && value !== '' ? value : undefined
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
function optionalNumber(value: unknown): number | undefined {
|
|
161
|
+
return typeof value === 'number' && Number.isFinite(value) ? value : undefined
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
function sheetInfo(raw: RawSheet, position: number): SheetInfo | undefined {
|
|
165
|
+
const sheetId = optionalString(raw.sheet_id)
|
|
166
|
+
if (sheetId === undefined) return undefined
|
|
167
|
+
const rowCount = optionalNumber(raw.row_count)
|
|
168
|
+
const columnCount = optionalNumber(raw.column_count)
|
|
169
|
+
return {
|
|
170
|
+
sheetId,
|
|
171
|
+
title: optionalString(raw.title) ?? optionalString(raw.sheet_name) ?? '',
|
|
172
|
+
index: optionalNumber(raw.index) ?? position,
|
|
173
|
+
resourceType: optionalString(raw.resource_type) ?? 'sheet',
|
|
174
|
+
hidden: raw.hidden === true || raw.is_hidden === true,
|
|
175
|
+
...(rowCount === undefined ? {} : { rowCount }),
|
|
176
|
+
...(columnCount === undefined ? {} : { columnCount }),
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
function cellOf(value: SheetCellValue): object {
|
|
181
|
+
if (value === null) return {}
|
|
182
|
+
if (typeof value === 'string' && value.startsWith('=')) return { formula: value }
|
|
183
|
+
return { value }
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* {@link SheetApi} over the SDK's generic authenticated request.
|
|
188
|
+
*
|
|
189
|
+
* @param request `Client.request` of the connected SDK client.
|
|
190
|
+
* @returns The API.
|
|
191
|
+
*/
|
|
192
|
+
function formulaError(raw: unknown): SheetFormulaError[] {
|
|
193
|
+
if (typeof raw !== 'object' || raw === null) return []
|
|
194
|
+
const entry = raw as Record<string, unknown>
|
|
195
|
+
const cell = optionalString(entry.cell)
|
|
196
|
+
if (cell === undefined) return []
|
|
197
|
+
const formula = optionalString(entry.formula)
|
|
198
|
+
const errorType = optionalString(entry.error_type)
|
|
199
|
+
const detail = optionalString(entry.detail)
|
|
200
|
+
return [{
|
|
201
|
+
cell,
|
|
202
|
+
...(formula === undefined ? {} : { formula }),
|
|
203
|
+
...(errorType === undefined ? {} : { errorType }),
|
|
204
|
+
...(detail === undefined ? {} : { detail }),
|
|
205
|
+
}]
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
export function createSheetApi(request: WikiRawRequest): SheetApi {
|
|
209
|
+
return {
|
|
210
|
+
async workbook(token) {
|
|
211
|
+
const output = await invoke(request, token, 'read', 'get_workbook_structure', {})
|
|
212
|
+
const rawSheets = Array.isArray(output.sheets) ? output.sheets as RawSheet[] : []
|
|
213
|
+
const sheets = rawSheets.flatMap((raw, position) => {
|
|
214
|
+
const info = typeof raw === 'object' && raw !== null ? sheetInfo(raw, position) : undefined
|
|
215
|
+
return info === undefined ? [] : [info]
|
|
216
|
+
})
|
|
217
|
+
const revision = optionalNumber(output.revision)
|
|
218
|
+
return { ...(revision === undefined ? {} : { revision }), sheets }
|
|
219
|
+
},
|
|
220
|
+
async readCsv(token, read) {
|
|
221
|
+
const output = await invoke(request, token, 'read', 'get_range_as_csv', {
|
|
222
|
+
sheet_id: read.sheetId,
|
|
223
|
+
range: read.range,
|
|
224
|
+
max_chars: read.maxChars,
|
|
225
|
+
max_rows: 1_000_000_000,
|
|
226
|
+
skip_hidden: false,
|
|
227
|
+
})
|
|
228
|
+
const actualRange = optionalString(output.actual_range)
|
|
229
|
+
const currentRegion = optionalString(output.current_region)
|
|
230
|
+
const warning = optionalString(output.warning_message)
|
|
231
|
+
return {
|
|
232
|
+
csv: typeof output.annotated_csv === 'string' ? output.annotated_csv : '',
|
|
233
|
+
...(actualRange === undefined ? {} : { actualRange }),
|
|
234
|
+
...(currentRegion === undefined ? {} : { currentRegion }),
|
|
235
|
+
hasMore: output.has_more === true,
|
|
236
|
+
...(warning === undefined ? {} : { warning }),
|
|
237
|
+
}
|
|
238
|
+
},
|
|
239
|
+
async writeCells(token, write) {
|
|
240
|
+
const output = await invoke(request, token, 'write', 'set_cell_range', {
|
|
241
|
+
sheet_id: write.sheetId,
|
|
242
|
+
range: write.range,
|
|
243
|
+
cells: write.rows.map(row => row.map(cellOf)),
|
|
244
|
+
...(write.allowOverwrite === false ? { allow_overwrite: false } : {}),
|
|
245
|
+
})
|
|
246
|
+
const raw: readonly unknown[] = Array.isArray(output.formula_errors) ? output.formula_errors : []
|
|
247
|
+
return { formulaErrors: raw.flatMap(formulaError) }
|
|
248
|
+
},
|
|
249
|
+
}
|
|
250
|
+
}
|