@modesto_ma/dsh-channels 0.4.0 → 0.5.0-beta.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +83 -57
- package/docs/access-control-design.md +8 -5
- package/docs/attachment-pipeline-v2.md +33 -41
- package/docs/feishu-onboarding.md +36 -33
- package/docs/persistence.md +6 -4
- package/docs/refactor-plan.md +1 -1
- package/docs/workspace-isolation-design.md +7 -8
- package/lib/client.js +444 -288
- package/lib/client.js.map +1 -1
- package/lib/index.js +9017 -6794
- package/lib/index.js.map +1 -1
- package/package.json +1 -1
- package/src/access/cards.ts +9 -9
- package/src/access/controller.ts +10 -10
- package/src/access/index.ts +1 -0
- package/src/access/policy.ts +10 -10
- package/src/access/requests.ts +32 -31
- package/src/access/store.ts +43 -13
- package/src/access/targets.ts +56 -0
- package/src/cards/approval.ts +96 -26
- package/src/cards/index.ts +1 -0
- package/src/cards/onboarding.ts +1 -0
- package/src/cards/picker.ts +108 -0
- package/src/cards/streaming.ts +56 -7
- package/src/cards/wiki.ts +124 -0
- package/src/client/api.ts +5 -25
- package/src/client/panel/access-card.tsx +24 -15
- package/src/client/panel/access-tab.tsx +3 -3
- package/src/client/panel/connection-tab.tsx +70 -1
- package/src/client/panel/index.tsx +11 -62
- package/src/client/panel/shared.tsx +96 -24
- package/src/client/panel/workspace-tab.tsx +2 -18
- package/src/client/string-list.ts +139 -0
- package/src/client/styles.ts +5 -0
- package/src/commands/registry.ts +8 -5
- package/src/config.ts +55 -54
- package/src/domain.ts +36 -123
- package/src/feishu/delivery.ts +34 -1
- package/src/feishu/inbound-attachments.ts +1 -1
- package/src/feishu/manifest.ts +8 -31
- package/src/feishu/message-read.ts +97 -24
- package/src/feishu/scopes.ts +194 -0
- package/src/feishu/send-file.ts +543 -0
- package/src/feishu/slash-commands.ts +134 -0
- package/src/feishu/tools.ts +72 -112
- package/src/feishu/wiki/api.ts +346 -0
- package/src/feishu/wiki/consent.ts +183 -0
- package/src/feishu/wiki/links.ts +122 -0
- package/src/feishu/wiki/scope.ts +66 -0
- package/src/feishu/wiki/tools.ts +984 -0
- package/src/feishu-channel.ts +280 -9
- package/src/index.ts +5 -4
- package/src/interactions/approvals.ts +187 -7
- package/src/interactions/pending.ts +3 -3
- package/src/routing/inbound.ts +73 -106
- package/src/service.ts +545 -440
- package/src/session/manager.ts +1 -1
- package/src/session/routes.ts +2 -3
- package/src/settings/contract.ts +110 -89
- package/src/settings/routes.ts +3 -17
- package/src/settings/surface.ts +144 -94
- package/src/streamer.ts +94 -5
- package/src/workspaces.ts +21 -29
- package/src/cleanup.ts +0 -546
- package/src/client/panel/cleanup-card.tsx +0 -106
- package/src/feishu/pending-attachments.ts +0 -562
|
@@ -0,0 +1,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
|
+
}
|