@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.
Files changed (66) hide show
  1. package/README.md +83 -57
  2. package/docs/access-control-design.md +8 -5
  3. package/docs/attachment-pipeline-v2.md +33 -41
  4. package/docs/feishu-onboarding.md +36 -33
  5. package/docs/persistence.md +6 -4
  6. package/docs/refactor-plan.md +1 -1
  7. package/docs/workspace-isolation-design.md +7 -8
  8. package/lib/client.js +444 -288
  9. package/lib/client.js.map +1 -1
  10. package/lib/index.js +9017 -6794
  11. package/lib/index.js.map +1 -1
  12. package/package.json +1 -1
  13. package/src/access/cards.ts +9 -9
  14. package/src/access/controller.ts +10 -10
  15. package/src/access/index.ts +1 -0
  16. package/src/access/policy.ts +10 -10
  17. package/src/access/requests.ts +32 -31
  18. package/src/access/store.ts +43 -13
  19. package/src/access/targets.ts +56 -0
  20. package/src/cards/approval.ts +96 -26
  21. package/src/cards/index.ts +1 -0
  22. package/src/cards/onboarding.ts +1 -0
  23. package/src/cards/picker.ts +108 -0
  24. package/src/cards/streaming.ts +56 -7
  25. package/src/cards/wiki.ts +124 -0
  26. package/src/client/api.ts +5 -25
  27. package/src/client/panel/access-card.tsx +24 -15
  28. package/src/client/panel/access-tab.tsx +3 -3
  29. package/src/client/panel/connection-tab.tsx +70 -1
  30. package/src/client/panel/index.tsx +11 -62
  31. package/src/client/panel/shared.tsx +96 -24
  32. package/src/client/panel/workspace-tab.tsx +2 -18
  33. package/src/client/string-list.ts +139 -0
  34. package/src/client/styles.ts +5 -0
  35. package/src/commands/registry.ts +8 -5
  36. package/src/config.ts +55 -54
  37. package/src/domain.ts +36 -123
  38. package/src/feishu/delivery.ts +34 -1
  39. package/src/feishu/inbound-attachments.ts +1 -1
  40. package/src/feishu/manifest.ts +8 -31
  41. package/src/feishu/message-read.ts +97 -24
  42. package/src/feishu/scopes.ts +194 -0
  43. package/src/feishu/send-file.ts +543 -0
  44. package/src/feishu/slash-commands.ts +134 -0
  45. package/src/feishu/tools.ts +72 -112
  46. package/src/feishu/wiki/api.ts +346 -0
  47. package/src/feishu/wiki/consent.ts +183 -0
  48. package/src/feishu/wiki/links.ts +122 -0
  49. package/src/feishu/wiki/scope.ts +66 -0
  50. package/src/feishu/wiki/tools.ts +984 -0
  51. package/src/feishu-channel.ts +280 -9
  52. package/src/index.ts +5 -4
  53. package/src/interactions/approvals.ts +187 -7
  54. package/src/interactions/pending.ts +3 -3
  55. package/src/routing/inbound.ts +73 -106
  56. package/src/service.ts +545 -440
  57. package/src/session/manager.ts +1 -1
  58. package/src/session/routes.ts +2 -3
  59. package/src/settings/contract.ts +110 -89
  60. package/src/settings/routes.ts +3 -17
  61. package/src/settings/surface.ts +144 -94
  62. package/src/streamer.ts +94 -5
  63. package/src/workspaces.ts +21 -29
  64. package/src/cleanup.ts +0 -546
  65. package/src/client/panel/cleanup-card.tsx +0 -106
  66. 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
+ }