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

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.
@@ -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
+ }
@@ -0,0 +1,66 @@
1
+ /**
2
+ * `wikiRoots` containment: decides whether a wiki node lies inside one of the
3
+ * configured roots before any wiki tool touches it.
4
+ *
5
+ * A space root contains every node of that space. A node root contains
6
+ * itself and its descendants; descent is checked by walking the node's
7
+ * parent chain with `spaces/get_node`, bounded by {@link MAX_PARENT_DEPTH}.
8
+ * A node whose chain cannot be read is treated as outside (fail closed).
9
+ *
10
+ * @module dsh-channels/feishu/wiki/scope
11
+ */
12
+
13
+ import type { WikiApi, WikiNode } from './api.ts'
14
+ import type { WikiRoot } from './links.ts'
15
+
16
+ /**
17
+ * Longest parent chain walked for a node root. Feishu limits wiki depth well
18
+ * below this; a longer chain is treated as outside.
19
+ */
20
+ const MAX_PARENT_DEPTH = 64
21
+
22
+ /** The root a node was found under. */
23
+ export interface WikiContainment {
24
+ readonly root: WikiRoot
25
+ }
26
+
27
+ /**
28
+ * Find the configured root containing `node`.
29
+ *
30
+ * @param api Wiki endpoints (for parent lookups).
31
+ * @param roots Configured roots.
32
+ * @param node The node to check.
33
+ * @returns The containing root, or `undefined` when none contains it.
34
+ */
35
+ export async function containingRoot(
36
+ api: WikiApi,
37
+ roots: readonly WikiRoot[],
38
+ node: WikiNode,
39
+ ): Promise<WikiContainment | undefined> {
40
+ for (const root of roots) {
41
+ if (root.kind === 'space' && root.spaceId === node.spaceId) return { root }
42
+ }
43
+ const nodeRoots = roots.filter((root): root is Extract<WikiRoot, { kind: 'node' }> => root.kind === 'node')
44
+ if (nodeRoots.length === 0) return undefined
45
+ const byToken = new Map(nodeRoots.map(root => [root.nodeToken, root]))
46
+ let current: WikiNode = node
47
+ for (let depth = 0; depth <= MAX_PARENT_DEPTH; depth += 1) {
48
+ const match = byToken.get(current.nodeToken)
49
+ if (match !== undefined) return { root: match }
50
+ if (current.parentNodeToken === undefined) return undefined
51
+ current = await api.getNode(current.parentNodeToken, 'wiki')
52
+ }
53
+ return undefined
54
+ }
55
+
56
+ /**
57
+ * Whether a whole space (its top level) is inside the roots: only a space
58
+ * root covers a space's top level.
59
+ *
60
+ * @param roots Configured roots.
61
+ * @param spaceId The space.
62
+ * @returns True when a space root names it.
63
+ */
64
+ export function spaceIsRoot(roots: readonly WikiRoot[], spaceId: string): boolean {
65
+ return roots.some(root => root.kind === 'space' && root.spaceId === spaceId)
66
+ }
@@ -0,0 +1,284 @@
1
+ /**
2
+ * Failure values and helpers shared by the wiki tool families (docx, sheet,
3
+ * base): stable failure codes with readable reasons, argument parsing,
4
+ * Feishu error mapping, ownership, links, and consent-card previews.
5
+ *
6
+ * @module dsh-channels/feishu/wiki/shared
7
+ */
8
+
9
+ import type { z } from 'zod'
10
+ import { FeishuApiError } from '../../feishu-channel.ts'
11
+ import type { WikiConsentAction } from '../../cards/wiki.ts'
12
+ import type { InteractionRoute } from '../../interactions/pending.ts'
13
+ import type { WikiApi, WikiNode } from './api.ts'
14
+ import type { BaseApi } from './base-api.ts'
15
+ import type { WikiConsentOutcome } from './consent.ts'
16
+ import type { WikiRef, WikiRoot } from './links.ts'
17
+ import type { SheetApi } from './sheet-api.ts'
18
+
19
+ /** What every gated call works with. */
20
+ export interface WikiCallContext {
21
+ /** Topic and owner the consent cards go to. */
22
+ readonly route: InteractionRoute
23
+ readonly api: WikiApi
24
+ /** Absent on channel stubs without spreadsheet support. */
25
+ readonly sheets?: SheetApi
26
+ /** Absent on channel stubs without base support. */
27
+ readonly bases?: BaseApi
28
+ readonly roots: readonly WikiRoot[]
29
+ readonly botOpenId: string | undefined
30
+ }
31
+
32
+ /** Outcome of the per-call gates. */
33
+ export type WikiGate = { ok: true; context: WikiCallContext } | WikiFailure
34
+
35
+ /** Consent card summary fields. */
36
+ export interface WikiConsentFields {
37
+ readonly title: string
38
+ readonly url?: string
39
+ readonly location: string
40
+ readonly details: readonly string[]
41
+ }
42
+
43
+ /** The gates and consent steps every wiki tool family runs through (implemented by the wiki tool runtime). */
44
+ export interface WikiToolKit {
45
+ /**
46
+ * Switch, route, whitelist, and channel gates of one call.
47
+ * @param sessionId The calling Session.
48
+ * @param tool Tool name (its switch decides).
49
+ * @returns The call context, or the failure.
50
+ */
51
+ gate(sessionId: string, tool: string): WikiGate
52
+ /**
53
+ * Resolve a node reference and check `wikiRoots` containment.
54
+ * @param context The call context.
55
+ * @param ref Parsed reference (a space is rejected).
56
+ * @returns The node, or the failure.
57
+ */
58
+ containedNode(context: WikiCallContext, ref: WikiRef): Promise<WikiNode | WikiFailure>
59
+ /**
60
+ * Ask the topic owner on a consent card and wait.
61
+ * @param sessionId The calling Session.
62
+ * @param context The call context.
63
+ * @param action What is asked for.
64
+ * @param fields Card summary.
65
+ * @param signal The tool call's abort signal.
66
+ * @returns Undefined when allowed, else the failure.
67
+ */
68
+ consent(
69
+ sessionId: string,
70
+ context: WikiCallContext,
71
+ action: WikiConsentAction,
72
+ fields: WikiConsentFields,
73
+ signal: AbortSignal,
74
+ ): Promise<WikiFailure | undefined>
75
+ /**
76
+ * Read consent for one node: reuses the Session's standing grant, otherwise asks and records it.
77
+ * @param sessionId The calling Session.
78
+ * @param context The call context.
79
+ * @param node The node to open.
80
+ * @param signal The tool call's abort signal.
81
+ * @returns Undefined when reading may proceed, else the failure.
82
+ */
83
+ readConsent(sessionId: string, context: WikiCallContext, node: WikiNode, signal: AbortSignal): Promise<WikiFailure | undefined>
84
+ /**
85
+ * Record a standing read grant (for a document the owner just approved creating).
86
+ * @param sessionId The calling Session.
87
+ * @param documentId The node's `objToken`.
88
+ */
89
+ grantRead(sessionId: string, documentId: string): void
90
+ /**
91
+ * `空间 / 父节点` label of a node.
92
+ * @param api Wiki endpoints.
93
+ * @param node The node.
94
+ * @returns The label.
95
+ */
96
+ locationOf(api: WikiApi, node: WikiNode): Promise<string>
97
+ /** Whether writes are switched on (`feishuTools.wikiWrite`). */
98
+ writeEnabled(): boolean
99
+ /**
100
+ * Structured warning sink.
101
+ * @param message Format string.
102
+ * @param args Format arguments.
103
+ */
104
+ warn(message: string, ...args: unknown[]): void
105
+ }
106
+
107
+ /** One model-facing tool of a wiki tool family (the runtime adds output rendering and mounting). */
108
+ export interface WikiToolSpec {
109
+ readonly name: string
110
+ /** Switch that mounts it: `wikiRead` or `wikiWrite`. */
111
+ readonly access: 'read' | 'write'
112
+ readonly description: string
113
+ readonly parameters: Record<string, unknown>
114
+ /**
115
+ * Run one call; never rejects for Feishu failures (they become failure values).
116
+ * @param kit Gates and consent.
117
+ * @param sessionId The calling Session.
118
+ * @param rawArgs Model arguments (validated here).
119
+ * @param signal The tool call's abort signal.
120
+ * @returns The tool value.
121
+ */
122
+ execute(kit: WikiToolKit, sessionId: string, rawArgs: Record<string, unknown>, signal: AbortSignal): Promise<object>
123
+ /**
124
+ * One-line call presentation.
125
+ * @param args Model arguments.
126
+ * @returns Display text.
127
+ */
128
+ presentCall(args: Record<string, unknown>): string
129
+ }
130
+
131
+ /** Characters of each content shown on a consent card. */
132
+ export const PREVIEW_CHARS = 300
133
+
134
+ // ---- Results ------------------------------------------------------------
135
+
136
+ /** Stable failure codes; the readable `reason` carries the details. */
137
+ export type WikiFailureCode =
138
+ | 'TOOL_DISABLED'
139
+ | 'ROUTE_NOT_FOUND'
140
+ | 'NOT_AUTHORIZED'
141
+ | 'CHANNEL_UNAVAILABLE'
142
+ | 'INVALID_REFERENCE'
143
+ | 'INVALID_OPERATION'
144
+ | 'OUTSIDE_ROOTS'
145
+ | 'NOT_FOUND'
146
+ | 'NO_ACCESS'
147
+ | 'UNSUPPORTED_TYPE'
148
+ | 'NOT_OWNED'
149
+ | 'REVISION_CONFLICT'
150
+ | 'CONSENT_DENIED'
151
+ | 'CONSENT_EXPIRED'
152
+ | 'CONSENT_UNAVAILABLE'
153
+ | 'PERMISSION_DENIED'
154
+ | 'CONTENT_FAILED'
155
+ | 'EDIT_FAILED'
156
+ | 'EDIT_PARTIAL'
157
+ | 'API_FAILED'
158
+ | 'ABORTED'
159
+
160
+ export const FAILURE_REASONS: Readonly<Record<WikiFailureCode, string>> = {
161
+ TOOL_DISABLED: '知识库工具已在插件配置中关闭',
162
+ ROUTE_NOT_FOUND: '当前会话没有绑定飞书话题,无法确定授权人',
163
+ NOT_AUTHORIZED: '当前话题的发起人不在知识库白名单中;请管理员发送 /auth wiki add @该用户 后重试',
164
+ CHANNEL_UNAVAILABLE: '飞书连接当前不可用,请稍后重试',
165
+ INVALID_REFERENCE: '无法识别的知识库引用:请传知识库节点链接 https://<租户>.feishu.cn/wiki/<node_token>、node_token,或知识空间链接 https://<租户>.feishu.cn/wiki/space/<space_id>',
166
+ INVALID_OPERATION: '操作参数不正确',
167
+ OUTSIDE_ROOTS: '该位置不在插件配置的知识库范围(wikiRoots)内,不能访问',
168
+ NOT_FOUND: '知识库节点不存在或已被删除',
169
+ NO_ACCESS: '机器人对该知识库节点没有访问权限;请知识库管理员把应用(或包含机器人的群)加为该空间成员',
170
+ UNSUPPORTED_TYPE: '这个工具不支持该类型的节点:飞书文档(docx)用 feishu_wiki_read / feishu_wiki_edit,电子表格(sheet)用 feishu_wiki_sheet_read / feishu_wiki_sheet_edit,多维表格(bitable)用 feishu_wiki_base_read / feishu_wiki_base_edit;其他类型暂不支持',
171
+ NOT_OWNED: '只能修改由本应用创建(所有者是本应用)的文档',
172
+ REVISION_CONFLICT: '文档在你读取之后被修改过;请重新用 feishu_wiki_read 读取最新内容,基于新的 revision_id 再修改',
173
+ CONSENT_DENIED: '话题发起人拒绝了这次知识库操作',
174
+ CONSENT_EXPIRED: '话题发起人没有在授权卡有效期内确认,已自动拒绝',
175
+ CONSENT_UNAVAILABLE: '授权卡发送失败,无法征得话题发起人同意,已取消操作',
176
+ PERMISSION_DENIED: '飞书拒绝调用:应用缺少知识库或文档权限(wiki:wiki、docx:document),请管理员在开发者后台开通并发布版本',
177
+ CONTENT_FAILED: '文档已创建,但写入内容失败',
178
+ EDIT_FAILED: '修改失败',
179
+ EDIT_PARTIAL: '飞书报告操作只部分生效;已停止后续操作,请用 feishu_wiki_read 读取最新内容核验后再继续',
180
+ API_FAILED: '飞书接口调用失败',
181
+ ABORTED: '已取消',
182
+ }
183
+
184
+ /** One failed call. */
185
+ export interface WikiFailure {
186
+ readonly ok: false
187
+ readonly code: WikiFailureCode
188
+ readonly reason: string
189
+ /** Set by `CONTENT_FAILED`: the document that was created anyway. */
190
+ readonly node_token?: string
191
+ readonly url?: string
192
+ /** Set by `EDIT_FAILED`/`EDIT_PARTIAL`: operations fully applied before the stop. */
193
+ readonly applied?: number
194
+ /** Set by `REVISION_CONFLICT`: the current revision. */
195
+ readonly revision_id?: number
196
+ }
197
+
198
+ export function failure(code: WikiFailureCode, reason?: string): WikiFailure {
199
+ return { ok: false, code, reason: reason ?? FAILURE_REASONS[code] }
200
+ }
201
+
202
+ export function parseArgs<T>(tool: string, schema: z.ZodType<T>, rawArgs: Record<string, unknown>): T {
203
+ const parsed = schema.safeParse(rawArgs)
204
+ if (parsed.success) return parsed.data
205
+ const issue = parsed.error.issues[0]
206
+ const where = issue === undefined || issue.path.length === 0 ? 'arguments' : issue.path.join('.')
207
+ throw new Error(`${tool}: invalid ${where}: ${issue?.message ?? 'invalid arguments'}`)
208
+ }
209
+
210
+ /** Run one gated call body, mapping thrown Feishu errors to failures. */
211
+ export async function guarded<T>(
212
+ deps: { warn(message: string, ...args: unknown[]): void },
213
+ tool: string,
214
+ body: () => Promise<T | WikiFailure>,
215
+ ): Promise<T | WikiFailure> {
216
+ try {
217
+ return await body()
218
+ } catch (error) {
219
+ const mapped = failureOf(error)
220
+ if (mapped.code === 'API_FAILED') deps.warn('dsh-channels: %s failed: %s', tool, mapped.reason)
221
+ return mapped
222
+ }
223
+ }
224
+
225
+ /** Feishu codes: node missing/deleted, no resource access, missing app scope. */
226
+ const NOT_FOUND_CODES: ReadonlySet<number> = new Set([131005, 131012, 131013, 131014, 131016])
227
+ const NO_ACCESS_CODES: ReadonlySet<number> = new Set([131006, 1770032, 1770002])
228
+ const PERMISSION_CODES: ReadonlySet<number> = new Set([99991672, 99991679])
229
+
230
+ export function failureOf(error: unknown): WikiFailure {
231
+ const detail = describeError(error)
232
+ if (error instanceof FeishuApiError && error.code !== undefined) {
233
+ if (NOT_FOUND_CODES.has(error.code)) return failure('NOT_FOUND', `${FAILURE_REASONS.NOT_FOUND}(${detail})`)
234
+ if (NO_ACCESS_CODES.has(error.code)) return failure('NO_ACCESS', `${FAILURE_REASONS.NO_ACCESS}(${detail})`)
235
+ if (PERMISSION_CODES.has(error.code)) return failure('PERMISSION_DENIED', `${FAILURE_REASONS.PERMISSION_DENIED}(${detail})`)
236
+ }
237
+ return failure('API_FAILED', `${FAILURE_REASONS.API_FAILED}:${detail}`)
238
+ }
239
+
240
+ export function consentFailure(outcome: WikiConsentOutcome): WikiFailure | undefined {
241
+ switch (outcome) {
242
+ case 'allowed': return undefined
243
+ case 'denied': return failure('CONSENT_DENIED')
244
+ case 'expired': return failure('CONSENT_EXPIRED')
245
+ case 'cancelled': return failure('ABORTED')
246
+ case 'unavailable': return failure('CONSENT_UNAVAILABLE')
247
+ }
248
+ }
249
+
250
+ /** Whether the app owns a node: Feishu reports the bot open_id as the owner. */
251
+ export function ownedByApp(node: WikiNode, botOpenId: string | undefined): boolean {
252
+ return botOpenId !== undefined && botOpenId !== '' && node.owner === botOpenId
253
+ }
254
+
255
+ /** A node's web link; falls back to the first root's origin. */
256
+ export function nodeUrl(roots: readonly WikiRoot[], node: WikiNode): string {
257
+ if (node.url !== undefined) return node.url
258
+ const first = roots[0]
259
+ const origin = first === undefined ? 'https://feishu.cn' : new URL(first.link).origin
260
+ return `${origin}/wiki/${node.nodeToken}`
261
+ }
262
+
263
+ /** One-line clipped preview with Markdown control characters neutralized. */
264
+ export function preview(value: string, max = 80): string {
265
+ const flat = value.replace(/\s+/gu, ' ').trim().replace(/([[\]()*_`~<>])/gu, '\\$1')
266
+ return flat.length <= max ? flat : `${flat.slice(0, max)}…`
267
+ }
268
+
269
+ /** A fenced preview of the first characters of a content. */
270
+ export function previewBlock(content: string): string {
271
+ const head = content.length <= PREVIEW_CHARS ? content : `${content.slice(0, PREVIEW_CHARS)}\n…`
272
+ return `\`\`\`\n${head.replace(/```/gu, '`​``')}\n\`\`\``
273
+ }
274
+
275
+ export function display(value: unknown): string {
276
+ return typeof value === 'string' ? preview(value, 120) : '?'
277
+ }
278
+
279
+ /** Readable, bounded text for a thrown value. */
280
+ export function describeError(error: unknown): string {
281
+ const text = error instanceof FeishuApiError ? error.detail : error instanceof Error ? error.message : String(error)
282
+ const cleaned = text.replace(/[\u0000-\u001f\u007f]+/gu, ' ').trim()
283
+ return cleaned.length <= 300 ? cleaned : `${cleaned.slice(0, 300)}…`
284
+ }