@modesto_ma/dsh-channels 0.4.0 → 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 +83 -57
- package/docs/access-control-design.md +8 -5
- package/docs/attachment-pipeline-v2.md +33 -41
- package/docs/feishu-onboarding.md +36 -33
- package/docs/persistence.md +6 -4
- package/docs/refactor-plan.md +1 -1
- package/docs/workspace-isolation-design.md +7 -8
- package/lib/client.js +444 -288
- package/lib/client.js.map +1 -1
- package/lib/index.js +9017 -6794
- package/lib/index.js.map +1 -1
- package/package.json +1 -1
- package/src/access/cards.ts +9 -9
- package/src/access/controller.ts +10 -10
- package/src/access/index.ts +1 -0
- package/src/access/policy.ts +10 -10
- package/src/access/requests.ts +32 -31
- package/src/access/store.ts +43 -13
- package/src/access/targets.ts +56 -0
- package/src/cards/approval.ts +96 -26
- package/src/cards/index.ts +1 -0
- package/src/cards/onboarding.ts +1 -0
- package/src/cards/picker.ts +108 -0
- package/src/cards/streaming.ts +56 -7
- package/src/cards/wiki.ts +124 -0
- package/src/client/api.ts +5 -25
- package/src/client/panel/access-card.tsx +24 -15
- package/src/client/panel/access-tab.tsx +3 -3
- package/src/client/panel/connection-tab.tsx +70 -1
- package/src/client/panel/index.tsx +11 -62
- package/src/client/panel/shared.tsx +96 -24
- package/src/client/panel/workspace-tab.tsx +2 -18
- package/src/client/string-list.ts +139 -0
- package/src/client/styles.ts +5 -0
- package/src/commands/registry.ts +8 -5
- package/src/config.ts +55 -54
- package/src/domain.ts +36 -123
- package/src/feishu/delivery.ts +34 -1
- package/src/feishu/inbound-attachments.ts +1 -1
- package/src/feishu/manifest.ts +8 -31
- package/src/feishu/message-read.ts +97 -24
- package/src/feishu/scopes.ts +194 -0
- package/src/feishu/send-file.ts +543 -0
- package/src/feishu/slash-commands.ts +134 -0
- package/src/feishu/tools.ts +72 -112
- 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 +280 -9
- package/src/index.ts +5 -4
- package/src/interactions/approvals.ts +187 -7
- package/src/interactions/pending.ts +3 -3
- package/src/routing/inbound.ts +73 -106
- package/src/service.ts +545 -440
- package/src/session/manager.ts +1 -1
- package/src/session/routes.ts +2 -3
- package/src/settings/contract.ts +110 -89
- package/src/settings/routes.ts +3 -17
- package/src/settings/surface.ts +144 -94
- package/src/streamer.ts +94 -5
- package/src/workspaces.ts +21 -29
- package/src/cleanup.ts +0 -546
- package/src/client/panel/cleanup-card.tsx +0 -106
- package/src/feishu/pending-attachments.ts +0 -562
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Topic-owner consent for wiki tool calls.
|
|
3
|
+
*
|
|
4
|
+
* A wiki tool that opens, creates, or edits a document asks the topic owner
|
|
5
|
+
* on a consent card in the topic and waits for the answer. Only the topic
|
|
6
|
+
* owner (`route.senderUserId`) can decide; a click by anyone else adds the
|
|
7
|
+
* owner-only notice once. An unanswered card is denied after the configured
|
|
8
|
+
* window (`approvalTimeoutMs`). A read consent is remembered per Session and
|
|
9
|
+
* document, in memory only: it ends with the Session (`/renew`) or the
|
|
10
|
+
* process. Create and edit consents are never remembered.
|
|
11
|
+
*
|
|
12
|
+
* @module dsh-channels/feishu/wiki/consent
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { randomUUID } from 'node:crypto'
|
|
16
|
+
import { buildWikiConsentCard, buildWikiConsentSettledCard } from '../../cards/wiki.ts'
|
|
17
|
+
import type { WikiConsentAction, WikiConsentCardState, WikiConsentQuestion } from '../../cards/wiki.ts'
|
|
18
|
+
import type { CardActionEvent, FeishuChannel } from '../../feishu-channel.ts'
|
|
19
|
+
import { PendingCardTable } from '../../interactions/pending.ts'
|
|
20
|
+
import type { InteractionRoute, PendingCardEntry } from '../../interactions/pending.ts'
|
|
21
|
+
|
|
22
|
+
/** How one consent request ended. */
|
|
23
|
+
export type WikiConsentOutcome = 'allowed' | 'denied' | 'expired' | 'cancelled' | 'unavailable'
|
|
24
|
+
|
|
25
|
+
/** One consent to ask for. */
|
|
26
|
+
export interface WikiConsentRequest {
|
|
27
|
+
readonly sessionId: string
|
|
28
|
+
readonly route: InteractionRoute
|
|
29
|
+
readonly action: WikiConsentAction
|
|
30
|
+
readonly title: string
|
|
31
|
+
readonly url?: string
|
|
32
|
+
readonly location: string
|
|
33
|
+
readonly details: readonly string[]
|
|
34
|
+
/** The tool call's abort signal; aborting withdraws the card. */
|
|
35
|
+
readonly signal: AbortSignal
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
interface Pending extends PendingCardEntry {
|
|
39
|
+
readonly question: WikiConsentQuestion
|
|
40
|
+
resolve(outcome: WikiConsentOutcome): void
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export interface WikiConsentDeps {
|
|
44
|
+
/** Live Feishu channel; undefined while disconnected. */
|
|
45
|
+
channel(): FeishuChannel | undefined
|
|
46
|
+
/** Decision window per card, read per request (`approvalTimeoutMs`). */
|
|
47
|
+
timeoutMs(): number
|
|
48
|
+
/** Log a non-fatal delivery/update failure. */
|
|
49
|
+
onError(sessionId: string, error: unknown): void
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** Consent cards for the wiki tools. */
|
|
53
|
+
export class WikiConsent {
|
|
54
|
+
/** One table per channel instance (cards are patched through their channel). */
|
|
55
|
+
private table: { channel: FeishuChannel; entries: PendingCardTable<Pending> } | undefined
|
|
56
|
+
/** sessionId → document ids with a standing read consent. */
|
|
57
|
+
private readonly readGrants = new Map<string, Set<string>>()
|
|
58
|
+
|
|
59
|
+
constructor(private readonly deps: WikiConsentDeps) {}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Whether the Session already holds a read consent for one document.
|
|
63
|
+
* @param sessionId The Session.
|
|
64
|
+
* @param documentId Docx document id.
|
|
65
|
+
* @returns True when reading needs no new card.
|
|
66
|
+
*/
|
|
67
|
+
hasReadGrant(sessionId: string, documentId: string): boolean {
|
|
68
|
+
return this.readGrants.get(sessionId)?.has(documentId) === true
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Record a standing read consent (after an allowed read, or for a document
|
|
73
|
+
* the topic owner just approved creating).
|
|
74
|
+
* @param sessionId The Session.
|
|
75
|
+
* @param documentId Docx document id.
|
|
76
|
+
*/
|
|
77
|
+
grantRead(sessionId: string, documentId: string): void {
|
|
78
|
+
let grants = this.readGrants.get(sessionId)
|
|
79
|
+
if (grants === undefined) {
|
|
80
|
+
grants = new Set()
|
|
81
|
+
this.readGrants.set(sessionId, grants)
|
|
82
|
+
}
|
|
83
|
+
grants.add(documentId)
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Ask the topic owner and wait for the decision. Never rejects.
|
|
88
|
+
* @param request What to ask.
|
|
89
|
+
* @returns The outcome; `unavailable` when no card could be delivered.
|
|
90
|
+
*/
|
|
91
|
+
request(request: WikiConsentRequest): Promise<WikiConsentOutcome> {
|
|
92
|
+
if (request.signal.aborted) return Promise.resolve('cancelled')
|
|
93
|
+
const channel = this.deps.channel()
|
|
94
|
+
if (channel === undefined) return Promise.resolve('unavailable')
|
|
95
|
+
const table = this.tableFor(channel)
|
|
96
|
+
const timeoutMs = this.deps.timeoutMs()
|
|
97
|
+
const question: WikiConsentQuestion = {
|
|
98
|
+
token: randomUUID(),
|
|
99
|
+
action: request.action,
|
|
100
|
+
title: request.title,
|
|
101
|
+
...(request.url === undefined ? {} : { url: request.url }),
|
|
102
|
+
location: request.location,
|
|
103
|
+
details: request.details,
|
|
104
|
+
timeoutMs,
|
|
105
|
+
}
|
|
106
|
+
return new Promise<WikiConsentOutcome>((resolve) => {
|
|
107
|
+
const entry: Pending = {
|
|
108
|
+
token: question.token,
|
|
109
|
+
sessionId: request.sessionId,
|
|
110
|
+
route: request.route,
|
|
111
|
+
messageId: undefined,
|
|
112
|
+
settled: false,
|
|
113
|
+
timer: undefined,
|
|
114
|
+
signal: request.signal,
|
|
115
|
+
onAbort: undefined,
|
|
116
|
+
unauthorizedNoted: false,
|
|
117
|
+
question,
|
|
118
|
+
resolve,
|
|
119
|
+
}
|
|
120
|
+
const onAbort = (): void => this.settle(table, entry, 'cancelled', 'cancelled')
|
|
121
|
+
entry.onAbort = onAbort
|
|
122
|
+
request.signal.addEventListener('abort', onAbort, { once: true })
|
|
123
|
+
table.add(entry)
|
|
124
|
+
void table.deliver(entry, {
|
|
125
|
+
card: buildWikiConsentCard(question),
|
|
126
|
+
onWithdrawn: e => table.patch(e, buildWikiConsentSettledCard(e.question, 'cancelled')),
|
|
127
|
+
onDelivered: e => table.armTimeout(e, timeoutMs, () => this.settle(table, e, 'expired', 'expired')),
|
|
128
|
+
onFailure: e => this.settle(table, e, 'unavailable'),
|
|
129
|
+
})
|
|
130
|
+
})
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Route one card button callback to its pending consent; unknown cards are
|
|
135
|
+
* ignored.
|
|
136
|
+
* @param event The card action.
|
|
137
|
+
*/
|
|
138
|
+
onCardAction(event: CardActionEvent): void {
|
|
139
|
+
const table = this.table?.entries
|
|
140
|
+
if (table === undefined) return
|
|
141
|
+
const entry = table.resolveAction(event, (candidate) => {
|
|
142
|
+
if (candidate.unauthorizedNoted) return
|
|
143
|
+
candidate.unauthorizedNoted = true
|
|
144
|
+
table.patch(candidate, buildWikiConsentCard(candidate.question, { unauthorizedNote: true }))
|
|
145
|
+
})
|
|
146
|
+
if (entry === undefined) return
|
|
147
|
+
const decision = (event.action.value as { decision?: unknown } | undefined)?.decision
|
|
148
|
+
if (decision === 'allow') this.settle(table, entry, 'allowed', 'allowed')
|
|
149
|
+
else if (decision === 'deny') this.settle(table, entry, 'denied', 'denied')
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/** Withdraw every pending card and forget every read consent (shutdown). */
|
|
153
|
+
dispose(): void {
|
|
154
|
+
const table = this.table?.entries
|
|
155
|
+
table?.withdrawAll(entry => this.settle(table, entry, 'cancelled', 'cancelled'))
|
|
156
|
+
this.table = undefined
|
|
157
|
+
this.readGrants.clear()
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/** Pending consent count (diagnostics/tests). */
|
|
161
|
+
pendingCount(): number {
|
|
162
|
+
return this.table?.entries.count() ?? 0
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
private tableFor(channel: FeishuChannel): PendingCardTable<Pending> {
|
|
166
|
+
if (this.table?.channel !== channel) {
|
|
167
|
+
this.table = { channel, entries: new PendingCardTable<Pending>(channel, this.deps.onError) }
|
|
168
|
+
}
|
|
169
|
+
return this.table.entries
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
private settle(
|
|
173
|
+
table: PendingCardTable<Pending>,
|
|
174
|
+
entry: Pending,
|
|
175
|
+
outcome: WikiConsentOutcome,
|
|
176
|
+
cardState?: WikiConsentCardState,
|
|
177
|
+
): void {
|
|
178
|
+
const closed = table.close(entry, () => {
|
|
179
|
+
if (cardState !== undefined) table.patch(entry, buildWikiConsentSettledCard(entry.question, cardState))
|
|
180
|
+
})
|
|
181
|
+
if (closed) entry.resolve(outcome)
|
|
182
|
+
}
|
|
183
|
+
}
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Feishu wiki (知识库) link parsing: the `wikiRoots` allow-list entries and
|
|
3
|
+
* the document references the model passes to the wiki tools.
|
|
4
|
+
*
|
|
5
|
+
* Pure module: no I/O. Hosts are not checked because tenants use their own
|
|
6
|
+
* subdomains (`<tenant>.feishu.cn`) and the API never uses the link host.
|
|
7
|
+
*
|
|
8
|
+
* @module dsh-channels/feishu/wiki/links
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
/** Feishu wiki space id: decimal digits. */
|
|
12
|
+
const SPACE_ID_PATTERN = /^\d{1,32}$/u
|
|
13
|
+
|
|
14
|
+
/** Feishu wiki node / docx token: letters and digits. */
|
|
15
|
+
const TOKEN_PATTERN = /^[A-Za-z0-9]{8,64}$/u
|
|
16
|
+
|
|
17
|
+
/** One configured `wikiRoots` entry. */
|
|
18
|
+
export type WikiRoot =
|
|
19
|
+
| {
|
|
20
|
+
/** A whole wiki space (`/wiki/space/<space_id>`). */
|
|
21
|
+
readonly kind: 'space'
|
|
22
|
+
readonly spaceId: string
|
|
23
|
+
/** The link as configured. */
|
|
24
|
+
readonly link: string
|
|
25
|
+
}
|
|
26
|
+
| {
|
|
27
|
+
/** One wiki node and every node below it (`/wiki/<node_token>`). */
|
|
28
|
+
readonly kind: 'node'
|
|
29
|
+
readonly nodeToken: string
|
|
30
|
+
/** The link as configured. */
|
|
31
|
+
readonly link: string
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** Why a `wikiRoots` entry was rejected. */
|
|
35
|
+
export type WikiRootLinkError =
|
|
36
|
+
| 'not-a-url'
|
|
37
|
+
| 'not-https'
|
|
38
|
+
| 'not-a-wiki-link'
|
|
39
|
+
|
|
40
|
+
/** Readable reasons for {@link WikiRootLinkError}. */
|
|
41
|
+
export const WIKI_ROOT_LINK_ERRORS: Readonly<Record<WikiRootLinkError, string>> = {
|
|
42
|
+
'not-a-url': '不是有效的链接',
|
|
43
|
+
'not-https': '必须是 https 链接',
|
|
44
|
+
'not-a-wiki-link': '只支持知识空间链接 https://<租户>.feishu.cn/wiki/space/<space_id> 或节点链接 https://<租户>.feishu.cn/wiki/<node_token>',
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Parse one `wikiRoots` entry.
|
|
49
|
+
*
|
|
50
|
+
* @param link Link as configured (surrounding whitespace is ignored).
|
|
51
|
+
* @returns The root, or the reason it is invalid.
|
|
52
|
+
*/
|
|
53
|
+
export function parseWikiRootLink(link: string): { ok: true; root: WikiRoot } | { ok: false; error: WikiRootLinkError } {
|
|
54
|
+
const trimmed = link.trim()
|
|
55
|
+
let url: URL
|
|
56
|
+
try {
|
|
57
|
+
url = new URL(trimmed)
|
|
58
|
+
} catch {
|
|
59
|
+
// Unparseable input: reported as a configuration error below.
|
|
60
|
+
return { ok: false, error: 'not-a-url' }
|
|
61
|
+
}
|
|
62
|
+
if (url.protocol !== 'https:') return { ok: false, error: 'not-https' }
|
|
63
|
+
const segments = url.pathname.split('/').filter(segment => segment !== '')
|
|
64
|
+
if (segments[0] !== 'wiki') return { ok: false, error: 'not-a-wiki-link' }
|
|
65
|
+
if (segments.length === 3 && segments[1] === 'space' && SPACE_ID_PATTERN.test(segments[2]!)) {
|
|
66
|
+
return { ok: true, root: { kind: 'space', spaceId: segments[2]!, link: trimmed } }
|
|
67
|
+
}
|
|
68
|
+
if (segments.length === 2 && segments[1] !== 'space' && TOKEN_PATTERN.test(segments[1]!)) {
|
|
69
|
+
return { ok: true, root: { kind: 'node', nodeToken: segments[1]!, link: trimmed } }
|
|
70
|
+
}
|
|
71
|
+
return { ok: false, error: 'not-a-wiki-link' }
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Parse every configured root. Callers validate the config first, so an
|
|
76
|
+
* invalid entry here is a programming error.
|
|
77
|
+
*
|
|
78
|
+
* @param links Validated `wikiRoots` entries.
|
|
79
|
+
* @returns The parsed roots, in configuration order.
|
|
80
|
+
*/
|
|
81
|
+
export function parseWikiRoots(links: readonly string[]): WikiRoot[] {
|
|
82
|
+
return links.map((link) => {
|
|
83
|
+
const parsed = parseWikiRootLink(link)
|
|
84
|
+
if (!parsed.ok) throw new Error(`dsh-channels: invalid wikiRoots entry ${link}: ${WIKI_ROOT_LINK_ERRORS[parsed.error]}`)
|
|
85
|
+
return parsed.root
|
|
86
|
+
})
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** A document or node the model names: a wiki node, a docx, or a whole space. */
|
|
90
|
+
export type WikiRef =
|
|
91
|
+
| { readonly kind: 'node'; readonly token: string }
|
|
92
|
+
| { readonly kind: 'docx'; readonly token: string }
|
|
93
|
+
| { readonly kind: 'space'; readonly spaceId: string }
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Parse a model-supplied reference: a wiki node link, a docx link, a space
|
|
97
|
+
* link, or a bare node token.
|
|
98
|
+
*
|
|
99
|
+
* @param input Raw tool argument.
|
|
100
|
+
* @returns The reference, or `undefined` when it names nothing recognizable.
|
|
101
|
+
*/
|
|
102
|
+
export function parseWikiRef(input: string): WikiRef | undefined {
|
|
103
|
+
const trimmed = input.trim()
|
|
104
|
+
if (TOKEN_PATTERN.test(trimmed)) return { kind: 'node', token: trimmed }
|
|
105
|
+
let url: URL
|
|
106
|
+
try {
|
|
107
|
+
url = new URL(trimmed)
|
|
108
|
+
} catch {
|
|
109
|
+
// Neither a token nor a URL.
|
|
110
|
+
return undefined
|
|
111
|
+
}
|
|
112
|
+
if (url.protocol !== 'https:') return undefined
|
|
113
|
+
const segments = url.pathname.split('/').filter(segment => segment !== '')
|
|
114
|
+
if (segments[0] === 'wiki' && segments.length === 3 && segments[1] === 'space' && SPACE_ID_PATTERN.test(segments[2]!)) {
|
|
115
|
+
return { kind: 'space', spaceId: segments[2]! }
|
|
116
|
+
}
|
|
117
|
+
if (segments.length === 2 && TOKEN_PATTERN.test(segments[1]!)) {
|
|
118
|
+
if (segments[0] === 'wiki' && segments[1] !== 'space') return { kind: 'node', token: segments[1]! }
|
|
119
|
+
if (segments[0] === 'docx') return { kind: 'docx', token: segments[1]! }
|
|
120
|
+
}
|
|
121
|
+
return undefined
|
|
122
|
+
}
|