@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.
- package/README.md +15 -7
- package/docs/access-control-design.md +1 -0
- package/docs/feishu-onboarding.md +3 -0
- package/lib/client.js +271 -1
- package/lib/client.js.map +1 -1
- package/lib/index.js +2061 -212
- package/lib/index.js.map +1 -1
- package/package.json +1 -1
- package/src/access/store.ts +31 -1
- package/src/cards/picker.ts +9 -16
- package/src/cards/wiki.ts +124 -0
- package/src/client/panel/shared.tsx +96 -1
- package/src/client/string-list.ts +139 -0
- package/src/client/styles.ts +3 -0
- package/src/commands/registry.ts +6 -3
- package/src/config.ts +32 -0
- package/src/domain.ts +10 -0
- package/src/feishu/delivery.ts +34 -1
- package/src/feishu/message-read.ts +78 -2
- package/src/feishu/scopes.ts +23 -0
- package/src/feishu/wiki/api.ts +346 -0
- package/src/feishu/wiki/consent.ts +183 -0
- package/src/feishu/wiki/links.ts +122 -0
- package/src/feishu/wiki/scope.ts +66 -0
- package/src/feishu/wiki/tools.ts +984 -0
- package/src/feishu-channel.ts +10 -0
- package/src/service.ts +87 -7
- package/src/settings/contract.ts +42 -4
- package/src/settings/surface.ts +107 -6
package/src/feishu/delivery.ts
CHANGED
|
@@ -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
|
-
|
|
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[] = []
|
package/src/feishu/scopes.ts
CHANGED
|
@@ -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
|
+
}
|