@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,984 @@
1
+ /**
2
+ * Model-facing wiki (知识库) tools: `feishu_wiki_list`, `feishu_wiki_read`
3
+ * (switch `feishuTools.wikiRead`), `feishu_wiki_create`, and
4
+ * `feishu_wiki_edit` (switch `feishuTools.wikiWrite`).
5
+ *
6
+ * Every call acts with the app identity and is gated, in order, by:
7
+ * - the switch and a non-empty `wikiRoots` (otherwise nothing is mounted);
8
+ * - the topic owner being the administrator or on the wiki whitelist
9
+ * (`/auth wiki add`), checked per call so revocation applies at once;
10
+ * - `wikiRoots` containment of the node (see `scope.ts`);
11
+ * - docx only for reading and editing;
12
+ * - edits only on documents the app owns (wiki node `owner` equals the bot
13
+ * open_id) whose current revision equals the `revision_id` the model read;
14
+ * - the topic owner's consent card for opening a document (remembered per
15
+ * Session and document), for every create, and for every edit.
16
+ *
17
+ * Browsing (`feishu_wiki_list`) needs no card. Every failure is a structured
18
+ * value with a stable code and a readable reason.
19
+ *
20
+ * @module dsh-channels/feishu/wiki/tools
21
+ */
22
+
23
+ import { z } from 'zod'
24
+ import type { WikiConsentAction } from '../../cards/wiki.ts'
25
+ import type { FeishuConfig } from '../../config.ts'
26
+ import type { AgentContext, ToolContentBlock, ToolDefinition, ToolExecution } from '../../host/agent.ts'
27
+ import type { FeishuChannel } from '../../feishu-channel.ts'
28
+ import { FeishuApiError } from '../../feishu-channel.ts'
29
+ import type { InteractionRoute } from '../../interactions/pending.ts'
30
+ import { MOUNT_INJECT_RETRY_WAIT_MS, MOUNT_INJECT_WAIT_MS, waitForAvailability } from '../tools.ts'
31
+ import type { WikiApi, WikiDocFormat, WikiNode, WikiUpdateRequest } from './api.ts'
32
+ import type { WikiConsent, WikiConsentOutcome } from './consent.ts'
33
+ import { parseWikiRef, parseWikiRoots } from './links.ts'
34
+ import type { WikiRef, WikiRoot } from './links.ts'
35
+ import { containingRoot, spaceIsRoot } from './scope.ts'
36
+
37
+ // ---- Public identity ----------------------------------------------------
38
+
39
+ /** Model-facing tool names; stable across releases (session history references them). */
40
+ export const WIKI_LIST_TOOL_NAME = 'feishu_wiki_list'
41
+ export const WIKI_READ_TOOL_NAME = 'feishu_wiki_read'
42
+ export const WIKI_CREATE_TOOL_NAME = 'feishu_wiki_create'
43
+ export const WIKI_EDIT_TOOL_NAME = 'feishu_wiki_edit'
44
+
45
+ /** Most operations one `feishu_wiki_edit` call may apply. */
46
+ export const WIKI_EDIT_MAX_OPERATIONS = 20
47
+
48
+ /** Longest accepted document title (characters). */
49
+ const MAX_TITLE_CHARS = 200
50
+
51
+ /** Longest content one create/edit operation may carry (characters). */
52
+ const MAX_CONTENT_CHARS = 200_000
53
+
54
+ /** Characters of each content shown on a consent card. */
55
+ const PREVIEW_CHARS = 300
56
+
57
+ // ---- Arguments ----------------------------------------------------------
58
+
59
+ const refSchema = z.string().trim().min(1).max(2048)
60
+ const blockIdSchema = z.string().trim().min(1).max(128)
61
+ const contentSchema = z.string().max(MAX_CONTENT_CHARS)
62
+ const formatSchema = z.enum(['markdown', 'xml'])
63
+
64
+ export const wikiListArgsSchema = z.object({
65
+ parent: refSchema.optional(),
66
+ page_token: z.string().trim().min(1).max(512).optional(),
67
+ }).strict()
68
+
69
+ export const wikiReadArgsSchema = z.object({
70
+ doc: refSchema,
71
+ format: formatSchema.optional(),
72
+ scope: z.enum(['full', 'outline', 'section', 'range', 'keyword']).optional(),
73
+ start_block_id: blockIdSchema.optional(),
74
+ end_block_id: blockIdSchema.optional(),
75
+ keyword: z.string().trim().min(1).max(500).optional(),
76
+ max_depth: z.number().int().min(-1).max(20).optional(),
77
+ }).strict()
78
+
79
+ export const wikiCreateArgsSchema = z.object({
80
+ parent: refSchema,
81
+ title: z.string().trim().min(1).max(MAX_TITLE_CHARS),
82
+ content: contentSchema.optional(),
83
+ format: formatSchema.optional(),
84
+ }).strict()
85
+
86
+ const rangeFields = {
87
+ block_id: blockIdSchema.optional(),
88
+ start_block_id: blockIdSchema.optional(),
89
+ end_block_id: blockIdSchema.optional(),
90
+ }
91
+
92
+ const wikiEditOperationSchema = z.discriminatedUnion('command', [
93
+ z.object({ command: z.literal('append'), content: contentSchema.min(1) }).strict(),
94
+ z.object({ command: z.literal('insert_after'), block_id: blockIdSchema, content: contentSchema.min(1) }).strict(),
95
+ z.object({ command: z.literal('replace'), ...rangeFields, content: contentSchema.min(1) }).strict(),
96
+ z.object({ command: z.literal('delete'), ...rangeFields }).strict(),
97
+ z.object({ command: z.literal('str_replace'), pattern: z.string().min(1).max(10_000), content: contentSchema }).strict(),
98
+ z.object({ command: z.literal('overwrite'), content: contentSchema.min(1) }).strict(),
99
+ ])
100
+
101
+ export type WikiEditOperation = z.infer<typeof wikiEditOperationSchema>
102
+
103
+ export const wikiEditArgsSchema = z.object({
104
+ doc: refSchema,
105
+ revision_id: z.number().int().min(0),
106
+ format: formatSchema.optional(),
107
+ operations: z.array(wikiEditOperationSchema).min(1).max(WIKI_EDIT_MAX_OPERATIONS),
108
+ }).strict()
109
+
110
+ // ---- Results ------------------------------------------------------------
111
+
112
+ /** Stable failure codes; the readable `reason` carries the details. */
113
+ export type WikiFailureCode =
114
+ | 'TOOL_DISABLED'
115
+ | 'ROUTE_NOT_FOUND'
116
+ | 'NOT_AUTHORIZED'
117
+ | 'CHANNEL_UNAVAILABLE'
118
+ | 'INVALID_REFERENCE'
119
+ | 'INVALID_OPERATION'
120
+ | 'OUTSIDE_ROOTS'
121
+ | 'NOT_FOUND'
122
+ | 'NO_ACCESS'
123
+ | 'UNSUPPORTED_TYPE'
124
+ | 'NOT_OWNED'
125
+ | 'REVISION_CONFLICT'
126
+ | 'CONSENT_DENIED'
127
+ | 'CONSENT_EXPIRED'
128
+ | 'CONSENT_UNAVAILABLE'
129
+ | 'PERMISSION_DENIED'
130
+ | 'CONTENT_FAILED'
131
+ | 'EDIT_FAILED'
132
+ | 'EDIT_PARTIAL'
133
+ | 'API_FAILED'
134
+ | 'ABORTED'
135
+
136
+ const FAILURE_REASONS: Readonly<Record<WikiFailureCode, string>> = {
137
+ TOOL_DISABLED: '知识库工具已在插件配置中关闭',
138
+ ROUTE_NOT_FOUND: '当前会话没有绑定飞书话题,无法确定授权人',
139
+ NOT_AUTHORIZED: '当前话题的发起人不在知识库白名单中;请管理员发送 /auth wiki add @该用户 后重试',
140
+ CHANNEL_UNAVAILABLE: '飞书连接当前不可用,请稍后重试',
141
+ INVALID_REFERENCE: '无法识别的知识库引用:请传知识库节点链接 https://<租户>.feishu.cn/wiki/<node_token>、node_token,或知识空间链接 https://<租户>.feishu.cn/wiki/space/<space_id>',
142
+ INVALID_OPERATION: '编辑操作参数不完整',
143
+ OUTSIDE_ROOTS: '该位置不在插件配置的知识库范围(wikiRoots)内,不能访问',
144
+ NOT_FOUND: '知识库节点不存在或已被删除',
145
+ NO_ACCESS: '机器人对该知识库节点没有访问权限;请知识库管理员把应用(或包含机器人的群)加为该空间成员',
146
+ UNSUPPORTED_TYPE: '目前只支持读取和修改飞书文档(docx);表格、多维表格等类型暂不支持',
147
+ NOT_OWNED: '只能修改由本应用创建(所有者是本应用)的文档',
148
+ REVISION_CONFLICT: '文档在你读取之后被修改过;请重新用 feishu_wiki_read 读取最新内容,基于新的 revision_id 再修改',
149
+ CONSENT_DENIED: '话题发起人拒绝了这次知识库操作',
150
+ CONSENT_EXPIRED: '话题发起人没有在授权卡有效期内确认,已自动拒绝',
151
+ CONSENT_UNAVAILABLE: '授权卡发送失败,无法征得话题发起人同意,已取消操作',
152
+ PERMISSION_DENIED: '飞书拒绝调用:应用缺少知识库或文档权限(wiki:wiki、docx:document),请管理员在开发者后台开通并发布版本',
153
+ CONTENT_FAILED: '文档已创建,但写入内容失败',
154
+ EDIT_FAILED: '修改失败',
155
+ EDIT_PARTIAL: '飞书报告操作只部分生效;已停止后续操作,请用 feishu_wiki_read 读取最新内容核验后再继续',
156
+ API_FAILED: '飞书接口调用失败',
157
+ ABORTED: '已取消',
158
+ }
159
+
160
+ /** One failed call. */
161
+ export interface WikiFailure {
162
+ readonly ok: false
163
+ readonly code: WikiFailureCode
164
+ readonly reason: string
165
+ /** Set by `CONTENT_FAILED`: the document that was created anyway. */
166
+ readonly node_token?: string
167
+ readonly url?: string
168
+ /** Set by `EDIT_FAILED`/`EDIT_PARTIAL`: operations fully applied before the stop. */
169
+ readonly applied?: number
170
+ /** Set by `REVISION_CONFLICT`: the current revision. */
171
+ readonly revision_id?: number
172
+ }
173
+
174
+ /** One node as the tools report it. */
175
+ export interface WikiNodeView {
176
+ readonly title: string
177
+ readonly node_token: string
178
+ readonly obj_type: string
179
+ readonly node_type: string
180
+ readonly has_child: boolean
181
+ readonly url: string
182
+ /** The app owns the document (it may be edited when wikiWrite is on). */
183
+ readonly owned_by_app: boolean
184
+ }
185
+
186
+ /** Successful `feishu_wiki_list`. */
187
+ export interface WikiListValue {
188
+ readonly ok: true
189
+ readonly kind: 'roots' | 'children'
190
+ /** Listed parent (children only). */
191
+ readonly parent?: string
192
+ readonly items: ReadonlyArray<WikiNodeView | WikiRootView>
193
+ readonly page_token?: string
194
+ }
195
+
196
+ /** One configured root in a `kind: 'roots'` listing. */
197
+ export interface WikiRootView {
198
+ readonly root: 'space' | 'node'
199
+ readonly title: string
200
+ readonly link: string
201
+ readonly space_id?: string
202
+ readonly node_token?: string
203
+ readonly has_child?: boolean
204
+ readonly error?: string
205
+ }
206
+
207
+ /** Successful `feishu_wiki_read`. */
208
+ export interface WikiReadValue {
209
+ readonly ok: true
210
+ readonly title: string
211
+ readonly node_token: string
212
+ readonly url: string
213
+ readonly revision_id: number
214
+ readonly format: WikiDocFormat
215
+ readonly editable: boolean
216
+ readonly content: string
217
+ }
218
+
219
+ /** Successful `feishu_wiki_create`. */
220
+ export interface WikiCreateValue {
221
+ readonly ok: true
222
+ readonly title: string
223
+ readonly node_token: string
224
+ readonly url: string
225
+ readonly revision_id?: number
226
+ readonly warnings: readonly string[]
227
+ }
228
+
229
+ /** Successful `feishu_wiki_edit`. */
230
+ export interface WikiEditValue {
231
+ readonly ok: true
232
+ readonly title: string
233
+ readonly node_token: string
234
+ readonly url: string
235
+ readonly applied: number
236
+ readonly revision_id?: number
237
+ readonly warnings: readonly string[]
238
+ }
239
+
240
+ export type WikiToolValue = WikiFailure | WikiListValue | WikiReadValue | WikiCreateValue | WikiEditValue
241
+
242
+ const OUTPUT_SCHEMA: Record<string, unknown> = {
243
+ type: 'object',
244
+ properties: { ok: { type: 'boolean' }, code: { type: 'string' }, reason: { type: 'string' } },
245
+ required: ['ok'],
246
+ }
247
+
248
+ // ---- Descriptions -------------------------------------------------------
249
+
250
+ const FORMAT_NOTE = 'format 默认 markdown;xml 为飞书文档 XML(类 HTML 标签:<p> <h1>-<h6> <ul>/<ol><li> <pre><code> <table> <callout> 等),只有 xml 读取会给每个块带上 id 属性(block id),需要按块修改时用 xml 读取。'
251
+
252
+ const LIST_DESCRIPTION =
253
+ '浏览飞书知识库(只能访问插件配置的知识库范围 wikiRoots)。不传 parent 时列出可访问的根(知识空间或节点);'
254
+ + '传 parent(知识库节点链接、node_token,或知识空间链接)时列出它的直接子节点,has_child 为 true 的节点可以继续往下浏览。'
255
+ + '结果分页,带 page_token 时用它取下一页。返回的 node_token 可直接传给其他知识库工具。'
256
+
257
+ const READ_DESCRIPTION =
258
+ '读取知识库中的一篇飞书文档(docx)。doc 传知识库节点链接或 node_token。'
259
+ + '第一次读取某篇文档会在话题里弹出授权卡,由话题发起人确认,确认后本话题内再次读取同一篇不再询问;等待期间不要重复调用。'
260
+ + 'scope 默认 full(整篇);大文档先用 outline 看目录,再用 section(start_block_id 为标题块 id)、range(start/end_block_id)或 keyword(关键词,支持 a|b)读取局部;max_depth 在 outline 中表示标题层级上限。'
261
+ + FORMAT_NOTE
262
+ + '返回的 revision_id 用于 feishu_wiki_edit;editable 表示这篇文档能否被修改(只有本应用创建的文档可以修改)。'
263
+
264
+ const CREATE_DESCRIPTION =
265
+ '在知识库中新建一篇飞书文档(docx)。parent 传父节点(知识库节点链接或 node_token)或知识空间链接(建在空间顶层),必须位于插件配置的知识库范围内;'
266
+ + '新文档本身也可以作为目录,之后把它的 node_token 作为 parent 在下面继续新建子文档。'
267
+ + 'title 为标题;content 为正文(可选,不要在正文里重复标题)。' + FORMAT_NOTE
268
+ + '每次新建都会在话题里弹出授权卡,由话题发起人确认后才执行;等待期间不要重复调用。'
269
+
270
+ const EDIT_DESCRIPTION =
271
+ '修改知识库中由本应用创建的飞书文档(docx)。先用 feishu_wiki_read 读取(按块修改时用 format=xml 拿到 block id),把读到的 revision_id 原样传入;文档在此之后被他人改过会被拒绝,需要重新读取。'
272
+ + `operations 按顺序执行(最多 ${WIKI_EDIT_MAX_OPERATIONS} 个):`
273
+ + 'append(文末追加 content)、insert_after(在 block_id 之后插入 content)、replace(用 content 替换 block_id,或同一父块下 start_block_id 到 end_block_id 的连续块)、'
274
+ + 'delete(删除 block_id,或 start_block_id 到 end_block_id)、str_replace(把文本 pattern 替换为 content,content 为空表示删除)、overwrite(清空后用 content 重写整篇,会丢失评论与不支持的内容,尽量少用)。'
275
+ + '替换和删除会使涉及的旧 block id 失效,同一个块的多处修改请合并成一次 replace。' + FORMAT_NOTE
276
+ + '每次修改都会在话题里弹出授权卡,由话题发起人确认后才执行;等待期间不要重复调用。'
277
+
278
+ // ---- Runtime ------------------------------------------------------------
279
+
280
+ export interface WikiToolRuntimeDeps {
281
+ /** Live plugin configuration (switches, roots). */
282
+ config(): FeishuConfig
283
+ /** Live Feishu channel; undefined while disconnected/shut down. */
284
+ channel(): FeishuChannel | undefined
285
+ /** Consent placement of one session (its topic and owner), re-read per call. */
286
+ routeOfSession(sessionId: string): InteractionRoute | undefined
287
+ /** Whether a user is the administrator or on the wiki whitelist. */
288
+ mayUseWiki(userId: string): boolean
289
+ /** Topic-owner consent cards. */
290
+ consent: WikiConsent
291
+ /** Structured warning sink. */
292
+ warn(message: string, ...args: unknown[]): void
293
+ }
294
+
295
+ /** What every gated call works with. */
296
+ interface CallContext {
297
+ readonly route: InteractionRoute
298
+ readonly api: WikiApi
299
+ readonly roots: readonly WikiRoot[]
300
+ readonly botOpenId: string | undefined
301
+ }
302
+
303
+ type Gate = { ok: true; context: CallContext } | WikiFailure
304
+
305
+ /**
306
+ * The wiki tool runtime: mounts the enabled tools into Agent scopes and runs
307
+ * each call through the gates in the module contract.
308
+ */
309
+ export class WikiToolRuntime {
310
+ private readonly mountAttempts = new WeakMap<AgentContext, Promise<void>>()
311
+ private readonly spaceNames = new Map<string, string>()
312
+ private warnedUnavailable = false
313
+ private warnedMountFailure = false
314
+
315
+ constructor(private readonly deps: WikiToolRuntimeDeps) {}
316
+
317
+ /** Tool names the current configuration mounts. */
318
+ enabledTools(): string[] {
319
+ const config = this.deps.config()
320
+ if (config.wikiRoots.length === 0) return []
321
+ return [
322
+ ...(config.feishuTools.wikiRead ? [WIKI_LIST_TOOL_NAME, WIKI_READ_TOOL_NAME] : []),
323
+ ...(config.feishuTools.wikiWrite ? [WIKI_CREATE_TOOL_NAME, WIKI_EDIT_TOOL_NAME] : []),
324
+ ]
325
+ }
326
+
327
+ /**
328
+ * Register the enabled tools inside one Agent scope; same mount policy as
329
+ * the other channel tools (a missing or slow `tools` service never blocks
330
+ * session creation).
331
+ *
332
+ * @param agentCtx Agent scope to register in.
333
+ * @param sessionId Session the tools act for.
334
+ */
335
+ async mount(agentCtx: AgentContext, sessionId: string): Promise<void> {
336
+ if (this.enabledTools().length === 0) return
337
+ const existing = this.mountAttempts.get(agentCtx)
338
+ if (existing !== undefined) return await existing
339
+ const attempt = this.mountAttempt(agentCtx, sessionId)
340
+ this.mountAttempts.set(agentCtx, attempt)
341
+ await attempt
342
+ }
343
+
344
+ private async mountAttempt(agentCtx: AgentContext, sessionId: string): Promise<void> {
345
+ let injected = false
346
+ const outcome = await waitForAvailability(
347
+ Promise.resolve(agentCtx.inject(['tools'], (ctx) => {
348
+ injected = true
349
+ this.registerInScope(ctx, sessionId)
350
+ })),
351
+ this.warnedUnavailable ? MOUNT_INJECT_RETRY_WAIT_MS : MOUNT_INJECT_WAIT_MS,
352
+ )
353
+ if (outcome === 'failed') {
354
+ this.warnMountFailure('dsh-channels: mounting the wiki tools failed')
355
+ return
356
+ }
357
+ if (!injected && !this.warnedUnavailable) {
358
+ this.warnedUnavailable = true
359
+ this.deps.warn('dsh-channels: 知识库工具未挂载:宿主 tools 服务不可用(会话继续)')
360
+ }
361
+ }
362
+
363
+ private registerInScope(agentCtx: AgentContext, sessionId: string): void {
364
+ const tools = agentCtx.tools
365
+ if (tools === undefined || typeof tools.register !== 'function') {
366
+ this.warnMountFailure('dsh-channels: 知识库工具未注册:宿主 tools 服务不可用')
367
+ return
368
+ }
369
+ for (const name of this.enabledTools()) {
370
+ try {
371
+ tools.register(this.definitionFor(name, sessionId))
372
+ } catch (error) {
373
+ this.warnMountFailure(`dsh-channels: registering ${name} failed: ${describeError(error)}`)
374
+ }
375
+ }
376
+ }
377
+
378
+ private warnMountFailure(message: string): void {
379
+ if (this.warnedMountFailure) return
380
+ this.warnedMountFailure = true
381
+ this.deps.warn(message)
382
+ }
383
+
384
+ private definitionFor(name: string, sessionId: string): ToolDefinition {
385
+ const base = {
386
+ output: { schema: OUTPUT_SCHEMA, render: (_args: Record<string, unknown>, value: unknown) => renderWikiResult(value) },
387
+ presentResult: (_args: Record<string, unknown>, value: unknown) => presentWikiResult(value),
388
+ }
389
+ switch (name) {
390
+ case WIKI_LIST_TOOL_NAME:
391
+ return {
392
+ ...base,
393
+ name,
394
+ description: LIST_DESCRIPTION,
395
+ parameters: LIST_PARAMETERS,
396
+ execute: async rawArgs => await this.list(sessionId, rawArgs),
397
+ presentCall: () => '浏览知识库',
398
+ isConcurrencySafe: () => true,
399
+ }
400
+ case WIKI_READ_TOOL_NAME:
401
+ return {
402
+ ...base,
403
+ name,
404
+ description: READ_DESCRIPTION,
405
+ parameters: READ_PARAMETERS,
406
+ execute: async (rawArgs, exec) => await this.read(sessionId, rawArgs, exec),
407
+ presentCall: args => `读取知识库文档:${display(args.doc)}`,
408
+ isConcurrencySafe: () => false,
409
+ }
410
+ case WIKI_CREATE_TOOL_NAME:
411
+ return {
412
+ ...base,
413
+ name,
414
+ description: CREATE_DESCRIPTION,
415
+ parameters: CREATE_PARAMETERS,
416
+ execute: async (rawArgs, exec) => await this.create(sessionId, rawArgs, exec),
417
+ presentCall: args => `新建知识库文档:${display(args.title)}`,
418
+ isConcurrencySafe: () => false,
419
+ }
420
+ default:
421
+ return {
422
+ ...base,
423
+ name,
424
+ description: EDIT_DESCRIPTION,
425
+ parameters: EDIT_PARAMETERS,
426
+ execute: async (rawArgs, exec) => await this.edit(sessionId, rawArgs, exec),
427
+ presentCall: args => `修改知识库文档:${display(args.doc)}`,
428
+ isConcurrencySafe: () => false,
429
+ }
430
+ }
431
+ }
432
+
433
+ // ---- Gates -----------------------------------------------------------
434
+
435
+ private gate(sessionId: string, tool: string): Gate {
436
+ if (!this.enabledTools().includes(tool)) return failure('TOOL_DISABLED')
437
+ const route = this.deps.routeOfSession(sessionId)
438
+ if (route === undefined) return failure('ROUTE_NOT_FOUND')
439
+ if (!this.deps.mayUseWiki(route.senderUserId)) return failure('NOT_AUTHORIZED')
440
+ const channel = this.deps.channel()
441
+ const api = channel?.wiki
442
+ if (channel === undefined || api === undefined) return failure('CHANNEL_UNAVAILABLE')
443
+ return {
444
+ ok: true,
445
+ context: { route, api, roots: parseWikiRoots(this.deps.config().wikiRoots), botOpenId: channel.botOpenId() },
446
+ }
447
+ }
448
+
449
+ /** Resolve a node/docx reference and check containment. */
450
+ private async containedNode(context: CallContext, ref: WikiRef): Promise<WikiNode | WikiFailure> {
451
+ if (ref.kind === 'space') return failure('INVALID_REFERENCE', '这里需要一个文档或节点,而不是整个知识空间')
452
+ const node = await context.api.getNode(ref.token, ref.kind === 'docx' ? 'docx' : 'wiki')
453
+ const contained = await containingRoot(context.api, context.roots, node)
454
+ return contained === undefined ? failure('OUTSIDE_ROOTS') : node
455
+ }
456
+
457
+ private async consent(
458
+ sessionId: string,
459
+ context: CallContext,
460
+ action: WikiConsentAction,
461
+ fields: { title: string; url?: string; location: string; details: readonly string[] },
462
+ signal: AbortSignal,
463
+ ): Promise<WikiFailure | undefined> {
464
+ const outcome = await this.deps.consent.request({ sessionId, route: context.route, action, ...fields, signal })
465
+ return consentFailure(outcome)
466
+ }
467
+
468
+ private async spaceName(api: WikiApi, spaceId: string): Promise<string> {
469
+ const cached = this.spaceNames.get(spaceId)
470
+ if (cached !== undefined) return cached
471
+ const name = await api.spaceName(spaceId).catch(() => '')
472
+ const label = name === '' ? `知识空间 ${spaceId}` : name
473
+ this.spaceNames.set(spaceId, label)
474
+ return label
475
+ }
476
+
477
+ /** `空间 / 父节点` for a node (one parent lookup). */
478
+ private async locationOf(api: WikiApi, node: WikiNode): Promise<string> {
479
+ const space = await this.spaceName(api, node.spaceId)
480
+ if (node.parentNodeToken === undefined) return space
481
+ const parent = await api.getNode(node.parentNodeToken, 'wiki').catch(() => undefined)
482
+ return parent === undefined || parent.title === '' ? space : `${space} / ${parent.title}`
483
+ }
484
+
485
+ private nodeView(context: CallContext, node: WikiNode): WikiNodeView {
486
+ return {
487
+ title: node.title,
488
+ node_token: node.nodeToken,
489
+ obj_type: node.objType,
490
+ node_type: node.nodeType,
491
+ has_child: node.hasChild,
492
+ url: nodeUrl(context.roots, node),
493
+ owned_by_app: ownedByApp(node, context.botOpenId),
494
+ }
495
+ }
496
+
497
+ // ---- feishu_wiki_list ------------------------------------------------
498
+
499
+ private async list(sessionId: string, rawArgs: Record<string, unknown>): Promise<WikiToolValue> {
500
+ const args = parseArgs(WIKI_LIST_TOOL_NAME, wikiListArgsSchema, rawArgs)
501
+ const gate = this.gate(sessionId, WIKI_LIST_TOOL_NAME)
502
+ if (!gate.ok) return gate
503
+ const { context } = gate
504
+ return await guarded(this.deps, WIKI_LIST_TOOL_NAME, async () => {
505
+ if (args.parent === undefined) {
506
+ const items = await Promise.all(context.roots.map(root => this.rootView(context, root)))
507
+ return { ok: true, kind: 'roots', items }
508
+ }
509
+ const ref = parseWikiRef(args.parent)
510
+ if (ref === undefined) return failure('INVALID_REFERENCE')
511
+ let spaceId: string
512
+ let parentToken: string | undefined
513
+ if (ref.kind === 'space') {
514
+ if (!spaceIsRoot(context.roots, ref.spaceId)) return failure('OUTSIDE_ROOTS')
515
+ spaceId = ref.spaceId
516
+ } else {
517
+ const node = await this.containedNode(context, ref)
518
+ if ('ok' in node) return node
519
+ spaceId = node.spaceId
520
+ parentToken = node.nodeToken
521
+ }
522
+ const page = await context.api.listChildren(spaceId, parentToken, args.page_token)
523
+ return {
524
+ ok: true,
525
+ kind: 'children',
526
+ parent: args.parent,
527
+ items: page.items.map(node => this.nodeView(context, node)),
528
+ ...(page.pageToken === undefined ? {} : { page_token: page.pageToken }),
529
+ }
530
+ })
531
+ }
532
+
533
+ private async rootView(context: CallContext, root: WikiRoot): Promise<WikiRootView> {
534
+ try {
535
+ if (root.kind === 'space') {
536
+ return { root: 'space', title: await this.spaceName(context.api, root.spaceId), link: root.link, space_id: root.spaceId }
537
+ }
538
+ const node = await context.api.getNode(root.nodeToken, 'wiki')
539
+ return { root: 'node', title: node.title, link: root.link, node_token: node.nodeToken, has_child: node.hasChild }
540
+ } catch (error) {
541
+ const base = root.kind === 'space'
542
+ ? { root: 'space' as const, space_id: root.spaceId }
543
+ : { root: 'node' as const, node_token: root.nodeToken }
544
+ return { ...base, title: '', link: root.link, error: failureOf(error).reason }
545
+ }
546
+ }
547
+
548
+ // ---- feishu_wiki_read ------------------------------------------------
549
+
550
+ private async read(sessionId: string, rawArgs: Record<string, unknown>, exec: ToolExecution): Promise<WikiToolValue> {
551
+ const args = parseArgs(WIKI_READ_TOOL_NAME, wikiReadArgsSchema, rawArgs)
552
+ const gate = this.gate(sessionId, WIKI_READ_TOOL_NAME)
553
+ if (!gate.ok) return gate
554
+ const { context } = gate
555
+ return await guarded(this.deps, WIKI_READ_TOOL_NAME, async () => {
556
+ const ref = parseWikiRef(args.doc)
557
+ if (ref === undefined) return failure('INVALID_REFERENCE')
558
+ const node = await this.containedNode(context, ref)
559
+ if ('ok' in node) return node
560
+ if (node.objType !== 'docx') return failure('UNSUPPORTED_TYPE', `${FAILURE_REASONS.UNSUPPORTED_TYPE}(该节点类型:${node.objType})`)
561
+ const url = nodeUrl(context.roots, node)
562
+ if (!this.deps.consent.hasReadGrant(sessionId, node.objToken)) {
563
+ const denied = await this.consent(sessionId, context, 'read', {
564
+ title: node.title, url, location: await this.locationOf(context.api, node), details: [],
565
+ }, exec.signal)
566
+ if (denied !== undefined) return denied
567
+ this.deps.consent.grantRead(sessionId, node.objToken)
568
+ }
569
+ if (exec.signal.aborted) return failure('ABORTED')
570
+ const format = args.format ?? 'markdown'
571
+ const document = await context.api.fetchDocument(node.objToken, {
572
+ format,
573
+ withBlockIds: true,
574
+ scope: args.scope ?? 'full',
575
+ ...(args.start_block_id === undefined ? {} : { startBlockId: args.start_block_id }),
576
+ ...(args.end_block_id === undefined ? {} : { endBlockId: args.end_block_id }),
577
+ ...(args.keyword === undefined ? {} : { keyword: args.keyword }),
578
+ ...(args.max_depth === undefined ? {} : { maxDepth: args.max_depth }),
579
+ })
580
+ return {
581
+ ok: true,
582
+ title: node.title,
583
+ node_token: node.nodeToken,
584
+ url,
585
+ revision_id: document.revisionId,
586
+ format,
587
+ editable: this.deps.config().feishuTools.wikiWrite && ownedByApp(node, context.botOpenId),
588
+ content: document.content,
589
+ }
590
+ })
591
+ }
592
+
593
+ // ---- feishu_wiki_create ----------------------------------------------
594
+
595
+ private async create(sessionId: string, rawArgs: Record<string, unknown>, exec: ToolExecution): Promise<WikiToolValue> {
596
+ const args = parseArgs(WIKI_CREATE_TOOL_NAME, wikiCreateArgsSchema, rawArgs)
597
+ const gate = this.gate(sessionId, WIKI_CREATE_TOOL_NAME)
598
+ if (!gate.ok) return gate
599
+ const { context } = gate
600
+ return await guarded(this.deps, WIKI_CREATE_TOOL_NAME, async () => {
601
+ const ref = parseWikiRef(args.parent)
602
+ if (ref === undefined) return failure('INVALID_REFERENCE')
603
+ let spaceId: string
604
+ let parentToken: string | undefined
605
+ let location: string
606
+ let parentUrl: string | undefined
607
+ if (ref.kind === 'space') {
608
+ if (!spaceIsRoot(context.roots, ref.spaceId)) return failure('OUTSIDE_ROOTS')
609
+ spaceId = ref.spaceId
610
+ location = `${await this.spaceName(context.api, spaceId)}(顶层)`
611
+ } else {
612
+ const parent = await this.containedNode(context, ref)
613
+ if ('ok' in parent) return parent
614
+ spaceId = parent.spaceId
615
+ parentToken = parent.nodeToken
616
+ parentUrl = nodeUrl(context.roots, parent)
617
+ location = `${await this.spaceName(context.api, spaceId)} / ${parent.title === '' ? parent.nodeToken : parent.title}`
618
+ }
619
+ const format = args.format ?? 'markdown'
620
+ const content = args.content === undefined || args.content.trim() === '' ? undefined : args.content
621
+ const details = content === undefined
622
+ ? ['**内容**:空文档']
623
+ : [`**内容预览**(${format === 'xml' ? 'XML' : 'Markdown'},共 ${content.length} 字)\n${previewBlock(content)}`]
624
+ const denied = await this.consent(sessionId, context, 'create', {
625
+ title: args.title, ...(parentUrl === undefined ? {} : { url: parentUrl }), location, details,
626
+ }, exec.signal)
627
+ if (denied !== undefined) return denied
628
+ if (exec.signal.aborted) return failure('ABORTED')
629
+ const node = await context.api.createDocxNode(spaceId, parentToken, args.title)
630
+ const url = nodeUrl(context.roots, node)
631
+ // The topic owner approved this document; reading it back needs no new card.
632
+ this.deps.consent.grantRead(sessionId, node.objToken)
633
+ if (content === undefined) {
634
+ return { ok: true, title: node.title || args.title, node_token: node.nodeToken, url, warnings: [] }
635
+ }
636
+ try {
637
+ const written = await context.api.updateDocument(node.objToken, {
638
+ command: 'block_insert_after', format, content, blockId: '-1',
639
+ })
640
+ if (written.result === 'failed') {
641
+ return { ...failure('CONTENT_FAILED', `${FAILURE_REASONS.CONTENT_FAILED}:${written.warnings.join(';') || '飞书返回 failed'};可用 feishu_wiki_edit 重新写入`), node_token: node.nodeToken, url }
642
+ }
643
+ if (written.result === 'partial_success') {
644
+ return { ...failure('CONTENT_FAILED', `文档已创建,但内容只部分写入:${written.warnings.join(';') || '飞书返回 partial_success'};请先用 feishu_wiki_read 核验,再用 feishu_wiki_edit 补齐`), node_token: node.nodeToken, url }
645
+ }
646
+ return {
647
+ ok: true,
648
+ title: node.title || args.title,
649
+ node_token: node.nodeToken,
650
+ url,
651
+ ...(written.revisionId === undefined ? {} : { revision_id: written.revisionId }),
652
+ warnings: written.warnings,
653
+ }
654
+ } catch (error) {
655
+ const reason = failureOf(error).reason
656
+ return { ...failure('CONTENT_FAILED', `${FAILURE_REASONS.CONTENT_FAILED}:${reason};可用 feishu_wiki_edit 重新写入`), node_token: node.nodeToken, url }
657
+ }
658
+ })
659
+ }
660
+
661
+ // ---- feishu_wiki_edit ------------------------------------------------
662
+
663
+ private async edit(sessionId: string, rawArgs: Record<string, unknown>, exec: ToolExecution): Promise<WikiToolValue> {
664
+ const args = parseArgs(WIKI_EDIT_TOOL_NAME, wikiEditArgsSchema, rawArgs)
665
+ const gate = this.gate(sessionId, WIKI_EDIT_TOOL_NAME)
666
+ if (!gate.ok) return gate
667
+ const { context } = gate
668
+ const format = args.format ?? 'markdown'
669
+ const requests: WikiUpdateRequest[] = []
670
+ for (const [index, operation] of args.operations.entries()) {
671
+ const request = updateRequestOf(operation, format)
672
+ if (typeof request === 'string') return failure('INVALID_OPERATION', `第 ${index + 1} 个操作(${operation.command}):${request}`)
673
+ requests.push(request)
674
+ }
675
+ return await guarded(this.deps, WIKI_EDIT_TOOL_NAME, async () => {
676
+ const ref = parseWikiRef(args.doc)
677
+ if (ref === undefined) return failure('INVALID_REFERENCE')
678
+ const node = await this.containedNode(context, ref)
679
+ if ('ok' in node) return node
680
+ if (node.objType !== 'docx') return failure('UNSUPPORTED_TYPE', `${FAILURE_REASONS.UNSUPPORTED_TYPE}(该节点类型:${node.objType})`)
681
+ if (!ownedByApp(node, context.botOpenId)) {
682
+ return failure('NOT_OWNED', `${FAILURE_REASONS.NOT_OWNED}(该文档所有者:${node.owner ?? '未知'})`)
683
+ }
684
+ const current = await context.api.documentRevision(node.objToken)
685
+ if (current !== args.revision_id) {
686
+ return { ...failure('REVISION_CONFLICT', `${FAILURE_REASONS.REVISION_CONFLICT}(读取时 ${args.revision_id},当前 ${current})`), revision_id: current }
687
+ }
688
+ const url = nodeUrl(context.roots, node)
689
+ const denied = await this.consent(sessionId, context, 'edit', {
690
+ title: node.title,
691
+ url,
692
+ location: await this.locationOf(context.api, node),
693
+ details: operationLines(args.operations),
694
+ }, exec.signal)
695
+ if (denied !== undefined) return denied
696
+ // The document may have changed while the card was open.
697
+ if (await context.api.documentRevision(node.objToken) !== args.revision_id) {
698
+ return failure('REVISION_CONFLICT', `${FAILURE_REASONS.REVISION_CONFLICT}(等待授权期间文档被修改)`)
699
+ }
700
+ const warnings: string[] = []
701
+ // Each update names its base revision so a concurrent edit conflicts instead of landing on newer content.
702
+ let revision = args.revision_id
703
+ for (const [index, request] of requests.entries()) {
704
+ if (exec.signal.aborted) return { ...failure('ABORTED', `已取消;前 ${index} 个操作已执行`), applied: index }
705
+ let result
706
+ try {
707
+ result = await context.api.updateDocument(node.objToken, { ...request, revisionId: revision })
708
+ } catch (error) {
709
+ const reason = failureOf(error).reason
710
+ return { ...failure('EDIT_FAILED', `第 ${index + 1} 个操作失败:${reason};前 ${index} 个操作已执行`), applied: index }
711
+ }
712
+ warnings.push(...result.warnings)
713
+ if (result.revisionId !== undefined) revision = result.revisionId
714
+ if (result.result === 'failed') {
715
+ return { ...failure('EDIT_FAILED', `第 ${index + 1} 个操作失败:${result.warnings.join(';') || '飞书返回 failed'};前 ${index} 个操作已执行`), applied: index }
716
+ }
717
+ if (result.result === 'partial_success') {
718
+ return { ...failure('EDIT_PARTIAL', `第 ${index + 1} 个操作只部分生效(${result.warnings.join(';') || '飞书返回 partial_success'});前 ${index} 个操作已执行,已停止后续操作;请用 feishu_wiki_read 读取核验后再继续`), applied: index, revision_id: revision }
719
+ }
720
+ }
721
+ return {
722
+ ok: true,
723
+ title: node.title,
724
+ node_token: node.nodeToken,
725
+ url,
726
+ applied: requests.length,
727
+ revision_id: revision,
728
+ warnings,
729
+ }
730
+ })
731
+ }
732
+ }
733
+
734
+ // ---- Helpers ------------------------------------------------------------
735
+
736
+ function failure(code: WikiFailureCode, reason?: string): WikiFailure {
737
+ return { ok: false, code, reason: reason ?? FAILURE_REASONS[code] }
738
+ }
739
+
740
+ function parseArgs<T>(tool: string, schema: z.ZodType<T>, rawArgs: Record<string, unknown>): T {
741
+ const parsed = schema.safeParse(rawArgs)
742
+ if (parsed.success) return parsed.data
743
+ const issue = parsed.error.issues[0]
744
+ const where = issue === undefined || issue.path.length === 0 ? 'arguments' : issue.path.join('.')
745
+ throw new Error(`${tool}: invalid ${where}: ${issue?.message ?? 'invalid arguments'}`)
746
+ }
747
+
748
+ /** Run one gated call body, mapping thrown Feishu errors to failures. */
749
+ async function guarded(
750
+ deps: Pick<WikiToolRuntimeDeps, 'warn'>,
751
+ tool: string,
752
+ body: () => Promise<WikiToolValue>,
753
+ ): Promise<WikiToolValue> {
754
+ try {
755
+ return await body()
756
+ } catch (error) {
757
+ const mapped = failureOf(error)
758
+ if (mapped.code === 'API_FAILED') deps.warn('dsh-channels: %s failed: %s', tool, mapped.reason)
759
+ return mapped
760
+ }
761
+ }
762
+
763
+ /** Feishu codes: node missing/deleted, no resource access, missing app scope. */
764
+ const NOT_FOUND_CODES: ReadonlySet<number> = new Set([131005, 131012, 131013, 131014, 131016])
765
+ const NO_ACCESS_CODES: ReadonlySet<number> = new Set([131006, 1770032, 1770002])
766
+ const PERMISSION_CODES: ReadonlySet<number> = new Set([99991672, 99991679])
767
+
768
+ function failureOf(error: unknown): WikiFailure {
769
+ const detail = describeError(error)
770
+ if (error instanceof FeishuApiError && error.code !== undefined) {
771
+ if (NOT_FOUND_CODES.has(error.code)) return failure('NOT_FOUND', `${FAILURE_REASONS.NOT_FOUND}(${detail})`)
772
+ if (NO_ACCESS_CODES.has(error.code)) return failure('NO_ACCESS', `${FAILURE_REASONS.NO_ACCESS}(${detail})`)
773
+ if (PERMISSION_CODES.has(error.code)) return failure('PERMISSION_DENIED', `${FAILURE_REASONS.PERMISSION_DENIED}(${detail})`)
774
+ }
775
+ return failure('API_FAILED', `${FAILURE_REASONS.API_FAILED}:${detail}`)
776
+ }
777
+
778
+ function consentFailure(outcome: WikiConsentOutcome): WikiFailure | undefined {
779
+ switch (outcome) {
780
+ case 'allowed': return undefined
781
+ case 'denied': return failure('CONSENT_DENIED')
782
+ case 'expired': return failure('CONSENT_EXPIRED')
783
+ case 'cancelled': return failure('ABORTED')
784
+ case 'unavailable': return failure('CONSENT_UNAVAILABLE')
785
+ }
786
+ }
787
+
788
+ /** Whether the app owns a node: Feishu reports the bot open_id as the owner. */
789
+ function ownedByApp(node: WikiNode, botOpenId: string | undefined): boolean {
790
+ return botOpenId !== undefined && botOpenId !== '' && node.owner === botOpenId
791
+ }
792
+
793
+ /** A node's web link; falls back to the first root's origin. */
794
+ function nodeUrl(roots: readonly WikiRoot[], node: WikiNode): string {
795
+ if (node.url !== undefined) return node.url
796
+ const first = roots[0]
797
+ const origin = first === undefined ? 'https://feishu.cn' : new URL(first.link).origin
798
+ return `${origin}/wiki/${node.nodeToken}`
799
+ }
800
+
801
+ /** Map one validated operation to a `docs_ai` update, or the reason it is incomplete. */
802
+ function updateRequestOf(operation: WikiEditOperation, format: WikiDocFormat): WikiUpdateRequest | string {
803
+ switch (operation.command) {
804
+ case 'append':
805
+ return { command: 'block_insert_after', format, content: operation.content, blockId: '-1' }
806
+ case 'insert_after':
807
+ return { command: 'block_insert_after', format, content: operation.content, blockId: operation.block_id }
808
+ case 'replace': {
809
+ const range = rangeOf(operation)
810
+ return typeof range === 'string' ? range : { command: 'block_replace', format, content: operation.content, ...range }
811
+ }
812
+ case 'delete': {
813
+ const range = rangeOf(operation)
814
+ return typeof range === 'string' ? range : { command: 'block_delete', format, ...range }
815
+ }
816
+ case 'str_replace':
817
+ return { command: 'str_replace', format, pattern: operation.pattern, content: operation.content }
818
+ case 'overwrite':
819
+ return { command: 'overwrite', format, content: operation.content }
820
+ }
821
+ }
822
+
823
+ /** `block_id` alone, or `start_block_id` + `end_block_id` together. */
824
+ function rangeOf(operation: { block_id?: string; start_block_id?: string; end_block_id?: string }):
825
+ Pick<WikiUpdateRequest, 'blockId' | 'startBlockId' | 'endBlockId'> | string {
826
+ const hasRange = operation.start_block_id !== undefined || operation.end_block_id !== undefined
827
+ if (operation.block_id !== undefined && !hasRange) return { blockId: operation.block_id }
828
+ if (operation.block_id === undefined && operation.start_block_id !== undefined && operation.end_block_id !== undefined) {
829
+ return { startBlockId: operation.start_block_id, endBlockId: operation.end_block_id }
830
+ }
831
+ return '需要 block_id,或成对的 start_block_id 与 end_block_id(二者不能同时使用)'
832
+ }
833
+
834
+ /** The consent card's operation list. */
835
+ /** Consent-card details: a header, then one entry per operation so every operation stays visible. */
836
+ function operationLines(operations: readonly WikiEditOperation[]): string[] {
837
+ const lines = operations.map((operation, index) => {
838
+ const n = `${index + 1}.`
839
+ switch (operation.command) {
840
+ case 'append': return `${n} 在文末追加 ${operation.content.length} 字:${preview(operation.content)}`
841
+ case 'insert_after': return `${n} 在块 \`${operation.block_id}\` 后插入 ${operation.content.length} 字:${preview(operation.content)}`
842
+ case 'replace': return `${n} 替换${blockRangeLabel(operation)}为 ${operation.content.length} 字:${preview(operation.content)}`
843
+ case 'delete': return `${n} 删除${blockRangeLabel(operation)}`
844
+ case 'str_replace': return `${n} 把文本「${preview(operation.pattern)}」替换为「${preview(operation.content)}」`
845
+ case 'overwrite': return `${n} ⚠️ 清空整篇并重写为 ${operation.content.length} 字:${preview(operation.content)}`
846
+ }
847
+ })
848
+ return [`**将执行 ${operations.length} 个操作**`, ...lines]
849
+ }
850
+
851
+ function blockRangeLabel(operation: { block_id?: string; start_block_id?: string; end_block_id?: string }): string {
852
+ return operation.block_id !== undefined
853
+ ? `块 \`${operation.block_id}\``
854
+ : `块 \`${operation.start_block_id ?? '?'}\` 到 \`${operation.end_block_id ?? '?'}\``
855
+ }
856
+
857
+ /** One-line clipped preview with Markdown control characters neutralized. */
858
+ function preview(value: string, max = 80): string {
859
+ const flat = value.replace(/\s+/gu, ' ').trim().replace(/([[\]()*_`~<>])/gu, '\\$1')
860
+ return flat.length <= max ? flat : `${flat.slice(0, max)}…`
861
+ }
862
+
863
+ /** A fenced preview of the first characters of a content. */
864
+ function previewBlock(content: string): string {
865
+ const head = content.length <= PREVIEW_CHARS ? content : `${content.slice(0, PREVIEW_CHARS)}\n…`
866
+ return `\`\`\`\n${head.replace(/```/gu, '`​``')}\n\`\`\``
867
+ }
868
+
869
+ function display(value: unknown): string {
870
+ return typeof value === 'string' ? preview(value, 120) : '?'
871
+ }
872
+
873
+ /** Readable, bounded text for a thrown value. */
874
+ function describeError(error: unknown): string {
875
+ const text = error instanceof FeishuApiError ? error.detail : error instanceof Error ? error.message : String(error)
876
+ const cleaned = text.replace(/[\u0000-\u001f\u007f]+/gu, ' ').trim()
877
+ return cleaned.length <= 300 ? cleaned : `${cleaned.slice(0, 300)}…`
878
+ }
879
+
880
+ function isValue(value: unknown): value is WikiToolValue {
881
+ return typeof value === 'object' && value !== null && typeof (value as { ok?: unknown }).ok === 'boolean'
882
+ }
883
+
884
+ /**
885
+ * Model-visible content of one call: a read returns its document content as
886
+ * plain text after a short header; everything else is JSON.
887
+ *
888
+ * @param value The tool value.
889
+ * @returns One text block.
890
+ */
891
+ export function renderWikiResult(value: unknown): readonly ToolContentBlock[] {
892
+ if (!isValue(value)) return [{ type: 'text', text: '知识库工具结果不可用' }]
893
+ if (value.ok && 'content' in value) {
894
+ const { content, ...meta } = value
895
+ return [{ type: 'text', text: `${JSON.stringify(meta)}\n\n${content}` }]
896
+ }
897
+ return [{ type: 'text', text: JSON.stringify(value) }]
898
+ }
899
+
900
+ /**
901
+ * One-line result presentation.
902
+ *
903
+ * @param value The tool value.
904
+ * @returns Display text.
905
+ */
906
+ export function presentWikiResult(value: unknown): string {
907
+ if (!isValue(value)) return '知识库工具结果不可用'
908
+ if (!value.ok) return `失败(${value.code}):${value.reason}`
909
+ if ('items' in value) return `列出 ${value.items.length} 项`
910
+ if ('content' in value) return `已读取《${value.title}》(${value.content.length} 字)`
911
+ if ('applied' in value) return `已修改《${value.title}》(${value.applied} 个操作)`
912
+ return `已新建《${value.title}》`
913
+ }
914
+
915
+ // ---- JSON Schemas advertised to the model --------------------------------
916
+
917
+ const REF_DESCRIPTION = '知识库节点链接 https://<租户>.feishu.cn/wiki/<node_token> 或 node_token'
918
+ const FORMAT_PROPERTY = { type: 'string', enum: ['markdown', 'xml'], description: '内容格式,默认 markdown;xml 读取时带 block id' }
919
+ const BLOCK_ID_PROPERTY = { type: 'string', description: '块 id(xml 读取结果中的 id 属性)' }
920
+
921
+ const LIST_PARAMETERS: Record<string, unknown> = {
922
+ type: 'object',
923
+ properties: {
924
+ parent: { type: 'string', description: `要列出子节点的位置:${REF_DESCRIPTION},或知识空间链接 https://<租户>.feishu.cn/wiki/space/<space_id>;不传则列出可访问的根` },
925
+ page_token: { type: 'string', description: '上一页返回的 page_token' },
926
+ },
927
+ additionalProperties: false,
928
+ }
929
+
930
+ const READ_PARAMETERS: Record<string, unknown> = {
931
+ type: 'object',
932
+ properties: {
933
+ doc: { type: 'string', description: REF_DESCRIPTION },
934
+ format: FORMAT_PROPERTY,
935
+ scope: { type: 'string', enum: ['full', 'outline', 'section', 'range', 'keyword'], description: '读取范围,默认 full' },
936
+ start_block_id: { ...BLOCK_ID_PROPERTY, description: 'section 的标题块 id,或 range 的起点' },
937
+ end_block_id: { ...BLOCK_ID_PROPERTY, description: 'range 的终点,-1 表示到文末' },
938
+ keyword: { type: 'string', description: 'keyword 模式的关键词,多个用 | 分隔' },
939
+ max_depth: { type: 'integer', description: 'outline:标题层级上限;其他:子树深度,-1 不限' },
940
+ },
941
+ required: ['doc'],
942
+ additionalProperties: false,
943
+ }
944
+
945
+ const CREATE_PARAMETERS: Record<string, unknown> = {
946
+ type: 'object',
947
+ properties: {
948
+ parent: { type: 'string', description: `父位置:${REF_DESCRIPTION},或知识空间链接(建在空间顶层)` },
949
+ title: { type: 'string', description: '文档标题' },
950
+ content: { type: 'string', description: '正文(可选)' },
951
+ format: FORMAT_PROPERTY,
952
+ },
953
+ required: ['parent', 'title'],
954
+ additionalProperties: false,
955
+ }
956
+
957
+ const EDIT_PARAMETERS: Record<string, unknown> = {
958
+ type: 'object',
959
+ properties: {
960
+ doc: { type: 'string', description: REF_DESCRIPTION },
961
+ revision_id: { type: 'integer', description: 'feishu_wiki_read 返回的 revision_id' },
962
+ format: FORMAT_PROPERTY,
963
+ operations: {
964
+ type: 'array',
965
+ minItems: 1,
966
+ maxItems: WIKI_EDIT_MAX_OPERATIONS,
967
+ items: {
968
+ type: 'object',
969
+ properties: {
970
+ command: { type: 'string', enum: ['append', 'insert_after', 'replace', 'delete', 'str_replace', 'overwrite'] },
971
+ block_id: BLOCK_ID_PROPERTY,
972
+ start_block_id: BLOCK_ID_PROPERTY,
973
+ end_block_id: BLOCK_ID_PROPERTY,
974
+ pattern: { type: 'string', description: 'str_replace 要查找的文本' },
975
+ content: { type: 'string', description: '写入的内容' },
976
+ },
977
+ required: ['command'],
978
+ additionalProperties: false,
979
+ },
980
+ },
981
+ },
982
+ required: ['doc', 'revision_id', 'operations'],
983
+ additionalProperties: false,
984
+ }