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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -28,8 +28,27 @@ export interface CardDeliveryDeps {
28
28
  }
29
29
 
30
30
  export class CardDelivery {
31
+ /** Open `replacingCard` scopes per message id; overlapping clicks nest. */
32
+ private readonly inPlace = new Map<string, number>()
33
+
31
34
  constructor(private readonly deps: CardDeliveryDeps) {}
32
35
 
36
+ /**
37
+ * Run `operation` with every `standaloneReply` anchored to `messageId`
38
+ * patched into that (bot-sent card) message instead of replying to it.
39
+ * Used for picker clicks, whose synthetic message id is the picker card.
40
+ */
41
+ async replacingCard(messageId: string, operation: () => Promise<void>): Promise<void> {
42
+ this.inPlace.set(messageId, (this.inPlace.get(messageId) ?? 0) + 1)
43
+ try {
44
+ await operation()
45
+ } finally {
46
+ const remaining = (this.inPlace.get(messageId) ?? 1) - 1
47
+ if (remaining === 0) this.inPlace.delete(messageId)
48
+ else this.inPlace.set(messageId, remaining)
49
+ }
50
+ }
51
+
33
52
  /** The live channel; throws instead of silently dropping a delivery. */
34
53
  requireChannel(): FeishuChannel {
35
54
  const channel = this.deps.channel()
@@ -73,6 +92,9 @@ export class CardDelivery {
73
92
  /**
74
93
  * Shared delivery policy for command-plane cards: reply in a new topic by
75
94
  * default, then degrade to a plain reply when Feishu refuses the topic.
95
+ * `createThread: false` replies in the chat timeline, or inside the topic
96
+ * the message already belongs to. Inside `replacingCard` the card patches
97
+ * the anchored card instead, and falls back to a reply if the patch fails.
76
98
  */
77
99
  async standaloneReply(
78
100
  msg: NormalizedMessage,
@@ -81,9 +103,20 @@ export class CardDelivery {
81
103
  ): Promise<void> {
82
104
  const channel = this.deps.channel()
83
105
  if (!this.deps.started() || channel === undefined) return
106
+ if (this.inPlace.has(msg.messageId)) {
107
+ try {
108
+ await channel.updateCard(msg.messageId, card)
109
+ return
110
+ } catch (error) {
111
+ this.deps.warn(
112
+ 'dsh-channels: in-place update of card %s failed, replying instead: %s',
113
+ msg.messageId, String(error),
114
+ )
115
+ }
116
+ }
84
117
  const createThread = options.createThread ?? true
85
118
  try {
86
- await channel.send(msg.chatId, { card }, createThread
119
+ await channel.send(msg.chatId, { card }, createThread || msg.threadId !== undefined
87
120
  ? { replyTo: msg.messageId, replyInThread: true }
88
121
  : { replyTo: msg.messageId })
89
122
  } catch (error) {
@@ -548,8 +548,10 @@ export function messageTextOf(item: RawMessageItem): string {
548
548
  }
549
549
  case 'merge_forward':
550
550
  return '[合并转发]'
551
- case 'interactive':
552
- return '[卡片消息]'
551
+ case 'interactive': {
552
+ const text = content === undefined ? '' : cardText(content)
553
+ return text === '' ? '[卡片消息]' : `[卡片] ${text}`
554
+ }
553
555
  case 'audio':
554
556
  return '[语音]'
555
557
  case 'video':
@@ -565,6 +567,80 @@ export function messageTextOf(item: RawMessageItem): string {
565
567
  }
566
568
  }
567
569
 
570
+ /** Deepest card node visited by {@link cardText}; real cards nest far less. */
571
+ const MAX_CARD_DEPTH = 12
572
+
573
+ /**
574
+ * Card keys whose values are never reader-visible text: callback payloads,
575
+ * link targets, and layout/style settings.
576
+ */
577
+ const CARD_SKIPPED_KEYS = new Set([
578
+ 'value', 'behaviors', 'url', 'href', 'multi_url', 'image_key', 'img_key',
579
+ 'icon', 'style', 'config', 'confirm', 'i18n_elements', 'i18n_header', 'user_id', 'open_id',
580
+ ])
581
+
582
+ /**
583
+ * Flatten one card body into text. Handles both the simplified structure the
584
+ * message read APIs return by default (`{ title, elements: [[...]] }`) and a
585
+ * sent card's JSON 1.0/2.0 (`header.title`, `elements` or `body.elements`):
586
+ * every `text`/`content` string and `plain_text`/`lark_md` object in reading
587
+ * order, images as `[图片]`. A Card 1.0 localized only through
588
+ * `i18n_header`/`i18n_elements` renders one locale. Returns `''` when no text
589
+ * is found.
590
+ */
591
+ function cardText(content: Record<string, unknown>): string {
592
+ const parts: string[] = []
593
+ const title = stringField(content, 'title')
594
+ if (title !== undefined) parts.push(title)
595
+ collectCardText(asRecord(content.header) ?? localizedBranch(content.i18n_header), parts, 0)
596
+ collectCardText(
597
+ content.elements ?? asRecord(content.body)?.elements ?? localizedBranch(content.i18n_elements),
598
+ parts,
599
+ 0,
600
+ )
601
+ return parts.map(part => part.trim()).filter(part => part !== '').join(' ')
602
+ }
603
+
604
+ /**
605
+ * One locale branch of a Card 1.0 `i18n_*` map: `zh_cn` when present,
606
+ * otherwise the first branch, so a card localized only through `i18n_*`
607
+ * still renders once instead of once per locale.
608
+ */
609
+ function localizedBranch(value: unknown): unknown {
610
+ const branches = asRecord(value)
611
+ if (branches === undefined) return undefined
612
+ return branches.zh_cn ?? Object.values(branches)[0]
613
+ }
614
+
615
+ /** Append the reader-visible text of one card node to `parts`. */
616
+ function collectCardText(node: unknown, parts: string[], depth: number): void {
617
+ if (depth > MAX_CARD_DEPTH) return
618
+ if (Array.isArray(node)) {
619
+ for (const child of node) collectCardText(child, parts, depth + 1)
620
+ return
621
+ }
622
+ const record = asRecord(node)
623
+ if (record === undefined) return
624
+ const tag = stringField(record, 'tag')
625
+ if (tag === 'img' || tag === 'image') {
626
+ parts.push('[图片]')
627
+ return
628
+ }
629
+ if (tag === 'at') {
630
+ const name = stringField(record, 'user_name')
631
+ parts.push(name === undefined ? '@' : `@${name}`)
632
+ return
633
+ }
634
+ for (const [key, value] of Object.entries(record)) {
635
+ if (CARD_SKIPPED_KEYS.has(key)) continue
636
+ if (typeof value === 'string') {
637
+ if (key === 'text' || key === 'content') parts.push(value)
638
+ continue
639
+ }
640
+ collectCardText(value, parts, depth + 1)
641
+ }
642
+ }
643
+
568
644
  /** Flatten one post body into text; inline images/files become placeholders. */
569
645
  function postText(content: Record<string, unknown>): string {
570
646
  const lines: string[] = []
@@ -44,6 +44,10 @@ export interface ScopeCheckContext {
44
44
  readonly slashCommandPanel: boolean
45
45
  /** The `feishu_send_file` tool is enabled (`feishuTools.sendFile`). */
46
46
  readonly sendFile: boolean
47
+ /** The wiki read tools are mounted (`feishuTools.wikiRead` with non-empty `wikiRoots`). */
48
+ readonly wikiRead: boolean
49
+ /** The wiki write tools are mounted (`feishuTools.wikiWrite` with non-empty `wikiRoots`). */
50
+ readonly wikiWrite: boolean
47
51
  }
48
52
 
49
53
  /** A requirement none of whose scopes is granted. */
@@ -87,6 +91,18 @@ const SEND_FILE_REQUIREMENT: ScopeRequirement = {
87
91
  anyOf: ['im:resource', 'im:resource:upload'], level: 'optional', purpose: '上传图片/文件,让模型用 feishu_send_file 把生成的文件发给用户',
88
92
  }
89
93
 
94
+ /** Wiki read needs (`feishu_wiki_list` / `feishu_wiki_read`). */
95
+ const WIKI_READ_REQUIREMENTS: readonly ScopeRequirement[] = [
96
+ { anyOf: ['wiki:wiki', 'wiki:wiki:readonly'], level: 'optional', purpose: '浏览知识库节点(feishu_wiki_list / feishu_wiki_read)' },
97
+ { anyOf: ['docx:document', 'docx:document:readonly'], level: 'optional', purpose: '读取知识库中的文档内容(feishu_wiki_read)' },
98
+ ]
99
+
100
+ /** Wiki write needs (`feishu_wiki_create` / `feishu_wiki_edit`). */
101
+ const WIKI_WRITE_REQUIREMENTS: readonly ScopeRequirement[] = [
102
+ { anyOf: ['wiki:wiki'], level: 'optional', purpose: '在知识库中新建节点(feishu_wiki_create)' },
103
+ { anyOf: ['docx:document'], level: 'optional', purpose: '写入与修改知识库文档内容(feishu_wiki_create / feishu_wiki_edit)' },
104
+ ]
105
+
90
106
  /** Group receive needs while ordinary group messages require an @ mention. */
91
107
  const MENTION_GROUP_REQUIREMENTS: readonly ScopeRequirement[] = [
92
108
  { anyOf: ['im:message.group_at_msg:readonly', 'im:message.group_msg'], level: 'required', purpose: '接收群聊中 @ 机器人的消息' },
@@ -139,10 +155,17 @@ export function scopeRequirements(context: ScopeCheckContext): readonly ScopeReq
139
155
  ...(context.requireMention ? MENTION_GROUP_REQUIREMENTS : [ALL_GROUP_REQUIREMENT]),
140
156
  ...BASE_REQUIREMENTS,
141
157
  ...(context.sendFile ? [SEND_FILE_REQUIREMENT] : []),
158
+ ...wikiRequirements(context),
142
159
  ...(context.slashCommandPanel ? SLASH_COMMAND_REQUIREMENTS : []),
143
160
  ]
144
161
  }
145
162
 
163
+ /** Wiki needs; the write set subsumes the read set's preferred scopes. */
164
+ function wikiRequirements(context: ScopeCheckContext): readonly ScopeRequirement[] {
165
+ if (context.wikiWrite) return WIKI_WRITE_REQUIREMENTS
166
+ return context.wikiRead ? WIKI_READ_REQUIREMENTS : []
167
+ }
168
+
146
169
  /**
147
170
  * Compare `grants` with the applicable requirements. Only tenant (or untyped)
148
171
  * grants count: the plugin always calls with the app identity.
@@ -0,0 +1,346 @@
1
+ /**
2
+ * Feishu wiki (知识库) and docx OpenAPI calls used by the wiki tools, made
3
+ * with the app identity (tenant access token).
4
+ *
5
+ * Structure calls use `wiki/v2` (`spaces/get_node`, `spaces/:id`,
6
+ * `spaces/:id/nodes`); document content uses the `docs_ai/v1` document
7
+ * endpoints, which read and write Markdown or Feishu document XML directly
8
+ * (block ids are exported only in XML); the revision check uses
9
+ * `docx/v1/documents/:id`. Every call maps a thrown transport error or a
10
+ * non-zero Feishu `code` to {@link FeishuApiError}.
11
+ *
12
+ * @module dsh-channels/feishu/wiki/api
13
+ */
14
+
15
+ import { FeishuApiError } from '../../feishu-channel.ts'
16
+
17
+ /** Object kinds a wiki node can hold. */
18
+ export type WikiObjType = 'doc' | 'docx' | 'sheet' | 'mindnote' | 'bitable' | 'file' | 'slides'
19
+
20
+ /** One wiki node as the tools use it. */
21
+ export interface WikiNode {
22
+ readonly spaceId: string
23
+ readonly nodeToken: string
24
+ /** Token of the document the node holds (the docx `document_id` for docx). */
25
+ readonly objToken: string
26
+ readonly objType: WikiObjType
27
+ /** Parent node; absent for a top-level node of its space. */
28
+ readonly parentNodeToken?: string
29
+ readonly nodeType: 'origin' | 'shortcut'
30
+ readonly title: string
31
+ readonly hasChild: boolean
32
+ /** Owner id as Feishu reports it (an open_id; the app's bot open_id when the app owns it). */
33
+ readonly owner?: string
34
+ /** Web link, when Feishu returned one. */
35
+ readonly url?: string
36
+ }
37
+
38
+ /** One page of child nodes. */
39
+ export interface WikiNodePage {
40
+ readonly items: readonly WikiNode[]
41
+ /** Cursor of the next page; absent on the last page. */
42
+ readonly pageToken?: string
43
+ }
44
+
45
+ /** Read scopes of `docs_ai` fetch. */
46
+ export type WikiReadScope = 'full' | 'outline' | 'section' | 'range' | 'keyword'
47
+
48
+ /** Content format of `docs_ai` reads and writes. */
49
+ export type WikiDocFormat = 'markdown' | 'xml'
50
+
51
+ /** One `docs_ai` fetch request. */
52
+ export interface WikiFetchRequest {
53
+ readonly format: WikiDocFormat
54
+ /** Export block ids (XML only; Feishu ignores it for Markdown). */
55
+ readonly withBlockIds: boolean
56
+ readonly scope: WikiReadScope
57
+ readonly startBlockId?: string
58
+ readonly endBlockId?: string
59
+ readonly keyword?: string
60
+ /** `outline`: heading level cap; other scopes: subtree depth. */
61
+ readonly maxDepth?: number
62
+ }
63
+
64
+ /** One fetched document. */
65
+ export interface WikiDocument {
66
+ readonly revisionId: number
67
+ readonly content: string
68
+ }
69
+
70
+ /** `docs_ai` update commands. */
71
+ export type WikiUpdateCommand =
72
+ | 'str_replace'
73
+ | 'block_insert_after'
74
+ | 'block_replace'
75
+ | 'block_delete'
76
+ | 'overwrite'
77
+
78
+ /** One `docs_ai` update request. */
79
+ export interface WikiUpdateRequest {
80
+ readonly command: WikiUpdateCommand
81
+ readonly format: WikiDocFormat
82
+ readonly content?: string
83
+ readonly pattern?: string
84
+ /** Anchor or target block; `-1` = document end for `block_insert_after`. */
85
+ readonly blockId?: string
86
+ readonly startBlockId?: string
87
+ readonly endBlockId?: string
88
+ /** Base revision; Feishu rejects the update when the document moved past it. */
89
+ readonly revisionId?: number
90
+ }
91
+
92
+ /** Outcome of one update. */
93
+ export interface WikiUpdateResult {
94
+ /** `success`, `partial_success`, or `failed` as Feishu reports it. */
95
+ readonly result: string
96
+ readonly revisionId?: number
97
+ readonly updatedBlocks?: number
98
+ readonly warnings: readonly string[]
99
+ }
100
+
101
+ /** The wiki/docx endpoints the wiki tools consume. */
102
+ export interface WikiApi {
103
+ /**
104
+ * Resolve a node token (or, with `objType: 'docx'`, a docx token mounted in
105
+ * the wiki) to its node.
106
+ * @param token Node token or docx token.
107
+ * @param objType `wiki` for a node token, `docx` for a document token.
108
+ * @returns The node.
109
+ */
110
+ getNode(token: string, objType: 'wiki' | 'docx'): Promise<WikiNode>
111
+ /**
112
+ * Name of one space.
113
+ * @param spaceId Wiki space id.
114
+ * @returns The space name.
115
+ */
116
+ spaceName(spaceId: string): Promise<string>
117
+ /**
118
+ * One page of children; the space's top-level nodes without a parent.
119
+ * @param spaceId Wiki space id.
120
+ * @param parentNodeToken Parent node, or undefined for the space top level.
121
+ * @param pageToken Cursor from the previous page.
122
+ * @returns The page.
123
+ */
124
+ listChildren(spaceId: string, parentNodeToken: string | undefined, pageToken?: string): Promise<WikiNodePage>
125
+ /**
126
+ * Create one empty docx node.
127
+ * @param spaceId Wiki space id.
128
+ * @param parentNodeToken Parent node, or undefined for the space top level.
129
+ * @param title Node title.
130
+ * @returns The new node.
131
+ */
132
+ createDocxNode(spaceId: string, parentNodeToken: string | undefined, title: string): Promise<WikiNode>
133
+ /**
134
+ * Current revision of one docx document.
135
+ * @param documentId Docx document id (the node's `objToken`).
136
+ * @returns The revision id.
137
+ */
138
+ documentRevision(documentId: string): Promise<number>
139
+ /**
140
+ * Read one docx document.
141
+ * @param documentId Docx document id.
142
+ * @param request Format and scope.
143
+ * @returns Content and revision.
144
+ */
145
+ fetchDocument(documentId: string, request: WikiFetchRequest): Promise<WikiDocument>
146
+ /**
147
+ * Apply one update command to a docx document.
148
+ * @param documentId Docx document id.
149
+ * @param request The command.
150
+ * @returns Feishu's outcome.
151
+ */
152
+ updateDocument(documentId: string, request: WikiUpdateRequest): Promise<WikiUpdateResult>
153
+ }
154
+
155
+ /** The SDK's generic authenticated request (`Client.request`). */
156
+ export type WikiRawRequest = <T>(input: {
157
+ method: 'GET' | 'POST' | 'PUT'
158
+ url: string
159
+ params?: Record<string, string | number>
160
+ data?: object
161
+ }) => Promise<T>
162
+
163
+ interface FeishuEnvelope<T> {
164
+ code?: number
165
+ msg?: string
166
+ data?: T
167
+ }
168
+
169
+ interface RawNode {
170
+ space_id?: string
171
+ node_token?: string
172
+ obj_token?: string
173
+ obj_type?: string
174
+ parent_node_token?: string
175
+ node_type?: string
176
+ title?: string
177
+ has_child?: boolean
178
+ owner?: string
179
+ url?: string
180
+ }
181
+
182
+ const OBJ_TYPES: ReadonlySet<string> = new Set(['doc', 'docx', 'sheet', 'mindnote', 'bitable', 'file', 'slides'])
183
+
184
+ /** Feishu's per-page maximum for `spaces/:id/nodes`. */
185
+ const LIST_PAGE_SIZE = 50
186
+
187
+ function thrownError(error: unknown): FeishuApiError {
188
+ if (error instanceof FeishuApiError) return error
189
+ const data = (error as { response?: { data?: { code?: unknown; msg?: unknown } } } | undefined)?.response?.data
190
+ const code = typeof data?.code === 'number' ? data.code : undefined
191
+ const msg = typeof data?.msg === 'string' ? data.msg : undefined
192
+ if (code === undefined && msg === undefined) return new FeishuApiError(error instanceof Error ? error.message : String(error))
193
+ return new FeishuApiError([code === undefined ? undefined : `code=${code}`, msg].filter(Boolean).join(' '), code)
194
+ }
195
+
196
+ async function call<T>(request: WikiRawRequest, input: Parameters<WikiRawRequest>[0]): Promise<T> {
197
+ let response: FeishuEnvelope<T>
198
+ try {
199
+ response = await request<FeishuEnvelope<T>>(input)
200
+ } catch (error) {
201
+ throw thrownError(error)
202
+ }
203
+ if (response.code !== undefined && response.code !== 0) {
204
+ throw new FeishuApiError(`code=${response.code}${response.msg === undefined ? '' : ` ${response.msg}`}`, response.code)
205
+ }
206
+ if (response.data === undefined) throw new FeishuApiError(`${input.url}: response has no data`)
207
+ return response.data
208
+ }
209
+
210
+ function wikiNode(raw: RawNode | undefined, where: string): WikiNode {
211
+ if (raw?.space_id === undefined || raw.node_token === undefined || raw.obj_token === undefined
212
+ || raw.obj_type === undefined || !OBJ_TYPES.has(raw.obj_type)) {
213
+ throw new FeishuApiError(`${where}: response node is incomplete`)
214
+ }
215
+ return {
216
+ spaceId: raw.space_id,
217
+ nodeToken: raw.node_token,
218
+ objToken: raw.obj_token,
219
+ objType: raw.obj_type as WikiObjType,
220
+ ...(raw.parent_node_token === undefined || raw.parent_node_token === '' ? {} : { parentNodeToken: raw.parent_node_token }),
221
+ nodeType: raw.node_type === 'shortcut' ? 'shortcut' : 'origin',
222
+ title: raw.title ?? '',
223
+ hasChild: raw.has_child === true,
224
+ ...(raw.owner === undefined || raw.owner === '' ? {} : { owner: raw.owner }),
225
+ ...(raw.url === undefined || raw.url === '' ? {} : { url: raw.url }),
226
+ }
227
+ }
228
+
229
+ function segment(value: string): string {
230
+ return encodeURIComponent(value)
231
+ }
232
+
233
+ /**
234
+ * {@link WikiApi} over the SDK's generic authenticated request.
235
+ *
236
+ * @param request `Client.request` of the connected SDK client.
237
+ * @returns The API.
238
+ */
239
+ export function createWikiApi(request: WikiRawRequest): WikiApi {
240
+ return {
241
+ async getNode(token, objType) {
242
+ const url = '/open-apis/wiki/v2/spaces/get_node'
243
+ const data = await call<{ node?: RawNode }>(request, { method: 'GET', url, params: { token, obj_type: objType } })
244
+ return wikiNode(data.node, url)
245
+ },
246
+ async spaceName(spaceId) {
247
+ const data = await call<{ space?: { name?: string } }>(request, {
248
+ method: 'GET', url: `/open-apis/wiki/v2/spaces/${segment(spaceId)}`,
249
+ })
250
+ return data.space?.name ?? ''
251
+ },
252
+ async listChildren(spaceId, parentNodeToken, pageToken) {
253
+ const url = `/open-apis/wiki/v2/spaces/${segment(spaceId)}/nodes`
254
+ const data = await call<{ items?: RawNode[]; page_token?: string; has_more?: boolean }>(request, {
255
+ method: 'GET',
256
+ url,
257
+ params: {
258
+ page_size: LIST_PAGE_SIZE,
259
+ ...(parentNodeToken === undefined ? {} : { parent_node_token: parentNodeToken }),
260
+ ...(pageToken === undefined ? {} : { page_token: pageToken }),
261
+ },
262
+ })
263
+ return {
264
+ items: (data.items ?? []).map(item => wikiNode(item, url)),
265
+ ...(data.has_more === true && data.page_token !== undefined && data.page_token !== '' ? { pageToken: data.page_token } : {}),
266
+ }
267
+ },
268
+ async createDocxNode(spaceId, parentNodeToken, title) {
269
+ const url = `/open-apis/wiki/v2/spaces/${segment(spaceId)}/nodes`
270
+ const data = await call<{ node?: RawNode }>(request, {
271
+ method: 'POST',
272
+ url,
273
+ data: {
274
+ obj_type: 'docx',
275
+ node_type: 'origin',
276
+ title,
277
+ ...(parentNodeToken === undefined ? {} : { parent_node_token: parentNodeToken }),
278
+ },
279
+ })
280
+ return wikiNode(data.node, url)
281
+ },
282
+ async documentRevision(documentId) {
283
+ const url = `/open-apis/docx/v1/documents/${segment(documentId)}`
284
+ const data = await call<{ document?: { revision_id?: number } }>(request, { method: 'GET', url })
285
+ const revision = data.document?.revision_id
286
+ if (typeof revision !== 'number') throw new FeishuApiError(`${url}: response has no revision_id`)
287
+ return revision
288
+ },
289
+ async fetchDocument(documentId, fetch) {
290
+ const url = `/open-apis/docs_ai/v1/documents/${segment(documentId)}/fetch`
291
+ const ids = fetch.format === 'xml' && fetch.withBlockIds
292
+ const readOption = fetch.scope === 'full' ? undefined : {
293
+ read_mode: fetch.scope,
294
+ ...(fetch.startBlockId === undefined ? {} : { start_block_id: fetch.startBlockId }),
295
+ ...(fetch.endBlockId === undefined ? {} : { end_block_id: fetch.endBlockId }),
296
+ ...(fetch.keyword === undefined ? {} : { keyword: fetch.keyword }),
297
+ ...(fetch.maxDepth === undefined ? {} : { max_depth: String(fetch.maxDepth) }),
298
+ }
299
+ const data = await call<{ document?: { revision_id?: number; content?: string } }>(request, {
300
+ method: 'POST',
301
+ url,
302
+ data: {
303
+ format: fetch.format,
304
+ export_option: { export_block_id: ids, export_style_attrs: false, export_cite_extra_data: false },
305
+ ...(readOption === undefined ? {} : { read_option: readOption }),
306
+ },
307
+ })
308
+ const document = data.document
309
+ if (document === undefined || typeof document.content !== 'string') {
310
+ throw new FeishuApiError(`${url}: response has no document content`)
311
+ }
312
+ return { revisionId: document.revision_id ?? 0, content: document.content }
313
+ },
314
+ async updateDocument(documentId, update) {
315
+ const url = `/open-apis/docs_ai/v1/documents/${segment(documentId)}`
316
+ const data = await call<{
317
+ result?: string
318
+ updated_blocks_count?: number
319
+ warnings?: unknown
320
+ document?: { revision_id?: number }
321
+ }>(request, {
322
+ method: 'PUT',
323
+ url,
324
+ data: {
325
+ format: update.format,
326
+ command: update.command,
327
+ ...(update.content === undefined ? {} : { content: update.content }),
328
+ ...(update.pattern === undefined ? {} : { pattern: update.pattern }),
329
+ ...(update.blockId === undefined ? {} : { block_id: update.blockId }),
330
+ ...(update.startBlockId === undefined ? {} : { start_block_id: update.startBlockId }),
331
+ ...(update.endBlockId === undefined ? {} : { end_block_id: update.endBlockId }),
332
+ ...(update.revisionId === undefined ? {} : { revision_id: update.revisionId }),
333
+ },
334
+ })
335
+ const warnings = Array.isArray(data.warnings)
336
+ ? data.warnings.filter((item): item is string => typeof item === 'string' && item !== '')
337
+ : []
338
+ return {
339
+ result: data.result ?? 'success',
340
+ ...(data.document?.revision_id === undefined ? {} : { revisionId: data.document.revision_id }),
341
+ ...(data.updated_blocks_count === undefined ? {} : { updatedBlocks: data.updated_blocks_count }),
342
+ warnings,
343
+ }
344
+ },
345
+ }
346
+ }