@modesto_ma/dsh-channels 0.5.0-beta.2 → 0.5.0-beta.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +6 -6
- package/docs/attachment-pipeline-v2.md +2 -2
- package/docs/feishu-onboarding.md +4 -3
- package/lib/index.js +1606 -248
- package/lib/index.js.map +1 -1
- package/package.json +1 -1
- package/src/config.ts +26 -14
- package/src/feishu/scopes.ts +13 -1
- package/src/feishu/wiki/a1.ts +97 -0
- package/src/feishu/wiki/api.ts +21 -6
- package/src/feishu/wiki/base-api.ts +267 -0
- package/src/feishu/wiki/base-tools.ts +423 -0
- package/src/feishu/wiki/shared.ts +284 -0
- package/src/feishu/wiki/sheet-api.ts +250 -0
- package/src/feishu/wiki/sheet-tools.ts +493 -0
- package/src/feishu/wiki/tools.ts +102 -189
- package/src/feishu-channel.ts +14 -1
- package/src/settings/surface.ts +6 -6
package/src/feishu/wiki/tools.ts
CHANGED
|
@@ -27,12 +27,21 @@ import type { AgentContext, ToolContentBlock, ToolDefinition, ToolExecution } fr
|
|
|
27
27
|
import type { FeishuChannel } from '../../feishu-channel.ts'
|
|
28
28
|
import { FeishuApiError } from '../../feishu-channel.ts'
|
|
29
29
|
import type { InteractionRoute } from '../../interactions/pending.ts'
|
|
30
|
+
import {
|
|
31
|
+
consentFailure, describeError, display, failure, failureOf, FAILURE_REASONS, guarded, nodeUrl, ownedByApp,
|
|
32
|
+
parseArgs, preview, previewBlock,
|
|
33
|
+
} from './shared.ts'
|
|
34
|
+
import type { WikiCallContext, WikiFailure, WikiFailureCode, WikiGate, WikiToolKit, WikiToolSpec } from './shared.ts'
|
|
30
35
|
import { MOUNT_INJECT_RETRY_WAIT_MS, MOUNT_INJECT_WAIT_MS, waitForAvailability } from '../tools.ts'
|
|
31
36
|
import type { WikiApi, WikiDocFormat, WikiNode, WikiUpdateRequest } from './api.ts'
|
|
32
37
|
import type { WikiConsent, WikiConsentOutcome } from './consent.ts'
|
|
33
38
|
import { parseWikiRef, parseWikiRoots } from './links.ts'
|
|
34
39
|
import type { WikiRef, WikiRoot } from './links.ts'
|
|
35
40
|
import { containingRoot, spaceIsRoot } from './scope.ts'
|
|
41
|
+
import { BASE_TOOL_SPECS } from './base-tools.ts'
|
|
42
|
+
import { SHEET_TOOL_SPECS } from './sheet-tools.ts'
|
|
43
|
+
import type { WikiSheetEditValue, WikiSheetReadValue } from './sheet-tools.ts'
|
|
44
|
+
import type { WikiBaseEditValue, WikiBaseReadValue } from './base-tools.ts'
|
|
36
45
|
|
|
37
46
|
// ---- Public identity ----------------------------------------------------
|
|
38
47
|
|
|
@@ -51,9 +60,6 @@ const MAX_TITLE_CHARS = 200
|
|
|
51
60
|
/** Longest content one create/edit operation may carry (characters). */
|
|
52
61
|
const MAX_CONTENT_CHARS = 200_000
|
|
53
62
|
|
|
54
|
-
/** Characters of each content shown on a consent card. */
|
|
55
|
-
const PREVIEW_CHARS = 300
|
|
56
|
-
|
|
57
63
|
// ---- Arguments ----------------------------------------------------------
|
|
58
64
|
|
|
59
65
|
const refSchema = z.string().trim().min(1).max(2048)
|
|
@@ -79,6 +85,7 @@ export const wikiReadArgsSchema = z.object({
|
|
|
79
85
|
export const wikiCreateArgsSchema = z.object({
|
|
80
86
|
parent: refSchema,
|
|
81
87
|
title: z.string().trim().min(1).max(MAX_TITLE_CHARS),
|
|
88
|
+
type: z.enum(['docx', 'sheet', 'bitable']).optional(),
|
|
82
89
|
content: contentSchema.optional(),
|
|
83
90
|
format: formatSchema.optional(),
|
|
84
91
|
}).strict()
|
|
@@ -107,69 +114,6 @@ export const wikiEditArgsSchema = z.object({
|
|
|
107
114
|
operations: z.array(wikiEditOperationSchema).min(1).max(WIKI_EDIT_MAX_OPERATIONS),
|
|
108
115
|
}).strict()
|
|
109
116
|
|
|
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
117
|
|
|
174
118
|
/** One node as the tools report it. */
|
|
175
119
|
export interface WikiNodeView {
|
|
@@ -237,7 +181,16 @@ export interface WikiEditValue {
|
|
|
237
181
|
readonly warnings: readonly string[]
|
|
238
182
|
}
|
|
239
183
|
|
|
240
|
-
export type WikiToolValue =
|
|
184
|
+
export type WikiToolValue =
|
|
185
|
+
| WikiFailure
|
|
186
|
+
| WikiListValue
|
|
187
|
+
| WikiReadValue
|
|
188
|
+
| WikiCreateValue
|
|
189
|
+
| WikiEditValue
|
|
190
|
+
| WikiSheetReadValue
|
|
191
|
+
| WikiSheetEditValue
|
|
192
|
+
| WikiBaseReadValue
|
|
193
|
+
| WikiBaseEditValue
|
|
241
194
|
|
|
242
195
|
const OUTPUT_SCHEMA: Record<string, unknown> = {
|
|
243
196
|
type: 'object',
|
|
@@ -255,16 +208,16 @@ const LIST_DESCRIPTION =
|
|
|
255
208
|
+ '结果分页,带 page_token 时用它取下一页。返回的 node_token 可直接传给其他知识库工具。'
|
|
256
209
|
|
|
257
210
|
const READ_DESCRIPTION =
|
|
258
|
-
'读取知识库中的一篇飞书文档(docx)。doc 传知识库节点链接或 node_token。'
|
|
211
|
+
'读取知识库中的一篇飞书文档(docx;电子表格用 feishu_wiki_sheet_read,多维表格用 feishu_wiki_base_read)。doc 传知识库节点链接或 node_token。'
|
|
259
212
|
+ '第一次读取某篇文档会在话题里弹出授权卡,由话题发起人确认,确认后本话题内再次读取同一篇不再询问;等待期间不要重复调用。'
|
|
260
213
|
+ 'scope 默认 full(整篇);大文档先用 outline 看目录,再用 section(start_block_id 为标题块 id)、range(start/end_block_id)或 keyword(关键词,支持 a|b)读取局部;max_depth 在 outline 中表示标题层级上限。'
|
|
261
214
|
+ FORMAT_NOTE
|
|
262
215
|
+ '返回的 revision_id 用于 feishu_wiki_edit;editable 表示这篇文档能否被修改(只有本应用创建的文档可以修改)。'
|
|
263
216
|
|
|
264
217
|
const CREATE_DESCRIPTION =
|
|
265
|
-
'在知识库中新建一篇飞书文档(docx)。parent 传父节点(知识库节点链接或 node_token)或知识空间链接(建在空间顶层),必须位于插件配置的知识库范围内;'
|
|
266
|
-
+ '
|
|
267
|
-
+ 'title 为标题;content
|
|
218
|
+
'在知识库中新建一篇飞书文档(docx,默认)、电子表格(type=sheet)或多维表格(type=bitable)。parent 传父节点(知识库节点链接或 node_token)或知识空间链接(建在空间顶层),必须位于插件配置的知识库范围内;'
|
|
219
|
+
+ '新节点本身也可以作为目录,之后把它的 node_token 作为 parent 在下面继续新建子节点。'
|
|
220
|
+
+ 'title 为标题;content 为文档正文(仅 docx,可选,不要在正文里重复标题),电子表格和多维表格建好后是空白的,再用 feishu_wiki_sheet_edit / feishu_wiki_base_edit 写入。' + FORMAT_NOTE
|
|
268
221
|
+ '每次新建都会在话题里弹出授权卡,由话题发起人确认后才执行;等待期间不要重复调用。'
|
|
269
222
|
|
|
270
223
|
const EDIT_DESCRIPTION =
|
|
@@ -277,6 +230,15 @@ const EDIT_DESCRIPTION =
|
|
|
277
230
|
|
|
278
231
|
// ---- Runtime ------------------------------------------------------------
|
|
279
232
|
|
|
233
|
+
const CREATE_TYPE_LABELS: Readonly<Record<'docx' | 'sheet' | 'bitable', string>> = {
|
|
234
|
+
docx: '飞书文档',
|
|
235
|
+
sheet: '电子表格',
|
|
236
|
+
bitable: '多维表格',
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/** The spreadsheet and Base tool families, mounted with the docx tools under the same switches. */
|
|
240
|
+
const FAMILY_SPECS: readonly WikiToolSpec[] = [...SHEET_TOOL_SPECS, ...BASE_TOOL_SPECS]
|
|
241
|
+
|
|
280
242
|
export interface WikiToolRuntimeDeps {
|
|
281
243
|
/** Live plugin configuration (switches, roots). */
|
|
282
244
|
config(): FeishuConfig
|
|
@@ -292,21 +254,13 @@ export interface WikiToolRuntimeDeps {
|
|
|
292
254
|
warn(message: string, ...args: unknown[]): void
|
|
293
255
|
}
|
|
294
256
|
|
|
295
|
-
|
|
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
|
|
257
|
+
type CallContext = WikiCallContext
|
|
304
258
|
|
|
305
259
|
/**
|
|
306
260
|
* The wiki tool runtime: mounts the enabled tools into Agent scopes and runs
|
|
307
261
|
* each call through the gates in the module contract.
|
|
308
262
|
*/
|
|
309
|
-
export class WikiToolRuntime {
|
|
263
|
+
export class WikiToolRuntime implements WikiToolKit {
|
|
310
264
|
private readonly mountAttempts = new WeakMap<AgentContext, Promise<void>>()
|
|
311
265
|
private readonly spaceNames = new Map<string, string>()
|
|
312
266
|
private warnedUnavailable = false
|
|
@@ -318,9 +272,11 @@ export class WikiToolRuntime {
|
|
|
318
272
|
enabledTools(): string[] {
|
|
319
273
|
const config = this.deps.config()
|
|
320
274
|
if (config.wikiRoots.length === 0) return []
|
|
275
|
+
const family = (access: 'read' | 'write'): string[] =>
|
|
276
|
+
FAMILY_SPECS.filter(spec => spec.access === access).map(spec => spec.name)
|
|
321
277
|
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] : []),
|
|
278
|
+
...(config.feishuTools.wikiRead ? [WIKI_LIST_TOOL_NAME, WIKI_READ_TOOL_NAME, ...family('read')] : []),
|
|
279
|
+
...(config.feishuTools.wikiWrite ? [WIKI_CREATE_TOOL_NAME, WIKI_EDIT_TOOL_NAME, ...family('write')] : []),
|
|
324
280
|
]
|
|
325
281
|
}
|
|
326
282
|
|
|
@@ -386,6 +342,18 @@ export class WikiToolRuntime {
|
|
|
386
342
|
output: { schema: OUTPUT_SCHEMA, render: (_args: Record<string, unknown>, value: unknown) => renderWikiResult(value) },
|
|
387
343
|
presentResult: (_args: Record<string, unknown>, value: unknown) => presentWikiResult(value),
|
|
388
344
|
}
|
|
345
|
+
const spec = FAMILY_SPECS.find(candidate => candidate.name === name)
|
|
346
|
+
if (spec !== undefined) {
|
|
347
|
+
return {
|
|
348
|
+
...base,
|
|
349
|
+
name,
|
|
350
|
+
description: spec.description,
|
|
351
|
+
parameters: spec.parameters,
|
|
352
|
+
execute: async (rawArgs, exec) => await spec.execute(this, sessionId, rawArgs, exec.signal),
|
|
353
|
+
presentCall: args => spec.presentCall(args),
|
|
354
|
+
isConcurrencySafe: () => false,
|
|
355
|
+
}
|
|
356
|
+
}
|
|
389
357
|
switch (name) {
|
|
390
358
|
case WIKI_LIST_TOOL_NAME:
|
|
391
359
|
return {
|
|
@@ -432,7 +400,7 @@ export class WikiToolRuntime {
|
|
|
432
400
|
|
|
433
401
|
// ---- Gates -----------------------------------------------------------
|
|
434
402
|
|
|
435
|
-
|
|
403
|
+
gate(sessionId: string, tool: string): WikiGate {
|
|
436
404
|
if (!this.enabledTools().includes(tool)) return failure('TOOL_DISABLED')
|
|
437
405
|
const route = this.deps.routeOfSession(sessionId)
|
|
438
406
|
if (route === undefined) return failure('ROUTE_NOT_FOUND')
|
|
@@ -442,19 +410,26 @@ export class WikiToolRuntime {
|
|
|
442
410
|
if (channel === undefined || api === undefined) return failure('CHANNEL_UNAVAILABLE')
|
|
443
411
|
return {
|
|
444
412
|
ok: true,
|
|
445
|
-
context: {
|
|
413
|
+
context: {
|
|
414
|
+
route,
|
|
415
|
+
api,
|
|
416
|
+
...(channel.sheets === undefined ? {} : { sheets: channel.sheets }),
|
|
417
|
+
...(channel.bases === undefined ? {} : { bases: channel.bases }),
|
|
418
|
+
roots: parseWikiRoots(this.deps.config().wikiRoots),
|
|
419
|
+
botOpenId: channel.botOpenId(),
|
|
420
|
+
},
|
|
446
421
|
}
|
|
447
422
|
}
|
|
448
423
|
|
|
449
424
|
/** Resolve a node/docx reference and check containment. */
|
|
450
|
-
|
|
425
|
+
async containedNode(context: CallContext, ref: WikiRef): Promise<WikiNode | WikiFailure> {
|
|
451
426
|
if (ref.kind === 'space') return failure('INVALID_REFERENCE', '这里需要一个文档或节点,而不是整个知识空间')
|
|
452
427
|
const node = await context.api.getNode(ref.token, ref.kind === 'docx' ? 'docx' : 'wiki')
|
|
453
428
|
const contained = await containingRoot(context.api, context.roots, node)
|
|
454
429
|
return contained === undefined ? failure('OUTSIDE_ROOTS') : node
|
|
455
430
|
}
|
|
456
431
|
|
|
457
|
-
|
|
432
|
+
async consent(
|
|
458
433
|
sessionId: string,
|
|
459
434
|
context: CallContext,
|
|
460
435
|
action: WikiConsentAction,
|
|
@@ -475,7 +450,28 @@ export class WikiToolRuntime {
|
|
|
475
450
|
}
|
|
476
451
|
|
|
477
452
|
/** `空间 / 父节点` for a node (one parent lookup). */
|
|
478
|
-
|
|
453
|
+
async readConsent(sessionId: string, context: CallContext, node: WikiNode, signal: AbortSignal): Promise<WikiFailure | undefined> {
|
|
454
|
+
if (this.deps.consent.hasReadGrant(sessionId, node.objToken)) return undefined
|
|
455
|
+
const denied = await this.consent(sessionId, context, 'read', {
|
|
456
|
+
title: node.title, url: nodeUrl(context.roots, node), location: await this.locationOf(context.api, node), details: [],
|
|
457
|
+
}, signal)
|
|
458
|
+
if (denied === undefined) this.deps.consent.grantRead(sessionId, node.objToken)
|
|
459
|
+
return denied
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
grantRead(sessionId: string, documentId: string): void {
|
|
463
|
+
this.deps.consent.grantRead(sessionId, documentId)
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
writeEnabled(): boolean {
|
|
467
|
+
return this.deps.config().feishuTools.wikiWrite
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
warn(message: string, ...args: unknown[]): void {
|
|
471
|
+
this.deps.warn(message, ...args)
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
async locationOf(api: WikiApi, node: WikiNode): Promise<string> {
|
|
479
475
|
const space = await this.spaceName(api, node.spaceId)
|
|
480
476
|
if (node.parentNodeToken === undefined) return space
|
|
481
477
|
const parent = await api.getNode(node.parentNodeToken, 'wiki').catch(() => undefined)
|
|
@@ -559,13 +555,8 @@ export class WikiToolRuntime {
|
|
|
559
555
|
if ('ok' in node) return node
|
|
560
556
|
if (node.objType !== 'docx') return failure('UNSUPPORTED_TYPE', `${FAILURE_REASONS.UNSUPPORTED_TYPE}(该节点类型:${node.objType})`)
|
|
561
557
|
const url = nodeUrl(context.roots, node)
|
|
562
|
-
|
|
563
|
-
|
|
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
|
-
}
|
|
558
|
+
const denied = await this.readConsent(sessionId, context, node, exec.signal)
|
|
559
|
+
if (denied !== undefined) return denied
|
|
569
560
|
if (exec.signal.aborted) return failure('ABORTED')
|
|
570
561
|
const format = args.format ?? 'markdown'
|
|
571
562
|
const document = await context.api.fetchDocument(node.objToken, {
|
|
@@ -616,20 +607,26 @@ export class WikiToolRuntime {
|
|
|
616
607
|
parentUrl = nodeUrl(context.roots, parent)
|
|
617
608
|
location = `${await this.spaceName(context.api, spaceId)} / ${parent.title === '' ? parent.nodeToken : parent.title}`
|
|
618
609
|
}
|
|
610
|
+
const type = args.type ?? 'docx'
|
|
619
611
|
const format = args.format ?? 'markdown'
|
|
620
612
|
const content = args.content === undefined || args.content.trim() === '' ? undefined : args.content
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
613
|
+
if (type !== 'docx' && content !== undefined) {
|
|
614
|
+
return failure('INVALID_OPERATION', 'content 只用于飞书文档;新建电子表格或多维表格后,用 feishu_wiki_sheet_edit / feishu_wiki_base_edit 写入数据')
|
|
615
|
+
}
|
|
616
|
+
const details = type !== 'docx'
|
|
617
|
+
? [`**类型**:${CREATE_TYPE_LABELS[type]}(空白)`]
|
|
618
|
+
: content === undefined
|
|
619
|
+
? ['**内容**:空文档']
|
|
620
|
+
: [`**内容预览**(${format === 'xml' ? 'XML' : 'Markdown'},共 ${content.length} 字)\n${previewBlock(content)}`]
|
|
624
621
|
const denied = await this.consent(sessionId, context, 'create', {
|
|
625
622
|
title: args.title, ...(parentUrl === undefined ? {} : { url: parentUrl }), location, details,
|
|
626
623
|
}, exec.signal)
|
|
627
624
|
if (denied !== undefined) return denied
|
|
628
625
|
if (exec.signal.aborted) return failure('ABORTED')
|
|
629
|
-
const node = await context.api.
|
|
626
|
+
const node = await context.api.createNode(spaceId, parentToken, args.title, type)
|
|
630
627
|
const url = nodeUrl(context.roots, node)
|
|
631
628
|
// The topic owner approved this document; reading it back needs no new card.
|
|
632
|
-
this.
|
|
629
|
+
this.grantRead(sessionId, node.objToken)
|
|
633
630
|
if (content === undefined) {
|
|
634
631
|
return { ok: true, title: node.title || args.title, node_token: node.nodeToken, url, warnings: [] }
|
|
635
632
|
}
|
|
@@ -733,71 +730,6 @@ export class WikiToolRuntime {
|
|
|
733
730
|
|
|
734
731
|
// ---- Helpers ------------------------------------------------------------
|
|
735
732
|
|
|
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
733
|
/** Map one validated operation to a `docs_ai` update, or the reason it is incomplete. */
|
|
802
734
|
function updateRequestOf(operation: WikiEditOperation, format: WikiDocFormat): WikiUpdateRequest | string {
|
|
803
735
|
switch (operation.command) {
|
|
@@ -854,28 +786,6 @@ function blockRangeLabel(operation: { block_id?: string; start_block_id?: string
|
|
|
854
786
|
: `块 \`${operation.start_block_id ?? '?'}\` 到 \`${operation.end_block_id ?? '?'}\``
|
|
855
787
|
}
|
|
856
788
|
|
|
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
789
|
|
|
880
790
|
function isValue(value: unknown): value is WikiToolValue {
|
|
881
791
|
return typeof value === 'object' && value !== null && typeof (value as { ok?: unknown }).ok === 'boolean'
|
|
@@ -907,6 +817,8 @@ export function presentWikiResult(value: unknown): string {
|
|
|
907
817
|
if (!isValue(value)) return '知识库工具结果不可用'
|
|
908
818
|
if (!value.ok) return `失败(${value.code}):${value.reason}`
|
|
909
819
|
if ('items' in value) return `列出 ${value.items.length} 项`
|
|
820
|
+
if ('records' in value && value.records !== undefined) return `已读取《${value.title}》(${value.records.length} 条记录)`
|
|
821
|
+
if ('tables' in value) return `已读取《${value.title}》(${value.tables.length} 个数据表)`
|
|
910
822
|
if ('content' in value) return `已读取《${value.title}》(${value.content.length} 字)`
|
|
911
823
|
if ('applied' in value) return `已修改《${value.title}》(${value.applied} 个操作)`
|
|
912
824
|
return `已新建《${value.title}》`
|
|
@@ -946,8 +858,9 @@ const CREATE_PARAMETERS: Record<string, unknown> = {
|
|
|
946
858
|
type: 'object',
|
|
947
859
|
properties: {
|
|
948
860
|
parent: { type: 'string', description: `父位置:${REF_DESCRIPTION},或知识空间链接(建在空间顶层)` },
|
|
949
|
-
title: { type: 'string', description: '
|
|
950
|
-
|
|
861
|
+
title: { type: 'string', description: '标题' },
|
|
862
|
+
type: { type: 'string', enum: ['docx', 'sheet', 'bitable'], description: '节点类型:docx 飞书文档(默认)、sheet 电子表格、bitable 多维表格' },
|
|
863
|
+
content: { type: 'string', description: '文档正文(仅 docx,可选)' },
|
|
951
864
|
format: FORMAT_PROPERTY,
|
|
952
865
|
},
|
|
953
866
|
required: ['parent', 'title'],
|
package/src/feishu-channel.ts
CHANGED
|
@@ -14,6 +14,10 @@ import type { ScopeGrant } from './feishu/scopes.ts'
|
|
|
14
14
|
import type { RemoteSlashCommand, SlashCommandApi, SlashCommandEntry } from './feishu/slash-commands.ts'
|
|
15
15
|
import { createWikiApi } from './feishu/wiki/api.ts'
|
|
16
16
|
import type { WikiApi, WikiRawRequest } from './feishu/wiki/api.ts'
|
|
17
|
+
import { createBaseApi } from './feishu/wiki/base-api.ts'
|
|
18
|
+
import type { BaseApi } from './feishu/wiki/base-api.ts'
|
|
19
|
+
import { createSheetApi } from './feishu/wiki/sheet-api.ts'
|
|
20
|
+
import type { SheetApi } from './feishu/wiki/sheet-api.ts'
|
|
17
21
|
import type {
|
|
18
22
|
BotAddedEvent,
|
|
19
23
|
ChatInfo,
|
|
@@ -95,6 +99,10 @@ export interface FeishuChannel {
|
|
|
95
99
|
* a channel-unavailable result.
|
|
96
100
|
*/
|
|
97
101
|
readonly wiki?: WikiApi
|
|
102
|
+
/** Spreadsheet (`sheet_ai/v2`) endpoints used by the wiki sheet tools; optional like {@link wiki}. */
|
|
103
|
+
readonly sheets?: SheetApi
|
|
104
|
+
/** Base (`base/v3`) endpoints used by the wiki base tools; optional like {@link wiki}. */
|
|
105
|
+
readonly bases?: BaseApi
|
|
98
106
|
/** Current official-SDK WebSocket lifecycle snapshot, when initialized. */
|
|
99
107
|
getConnectionStatus(): FeishuConnectionStatus | undefined
|
|
100
108
|
/** Start the WebSocket long connection (resolves after the handshake). */
|
|
@@ -370,6 +378,8 @@ class LarkChannelAdapter implements FeishuChannel {
|
|
|
370
378
|
private readonly dmMode: 'open' | 'disabled'
|
|
371
379
|
readonly slashCommands: SlashCommandApi
|
|
372
380
|
readonly wiki: WikiApi
|
|
381
|
+
readonly sheets: SheetApi
|
|
382
|
+
readonly bases: BaseApi
|
|
373
383
|
|
|
374
384
|
constructor(options: ChannelConnectOptions) {
|
|
375
385
|
this.channel = createLarkChannel({
|
|
@@ -400,7 +410,10 @@ class LarkChannelAdapter implements FeishuChannel {
|
|
|
400
410
|
this.dmMode = options.dmMode
|
|
401
411
|
const rawClient = this.channel.rawClient
|
|
402
412
|
this.slashCommands = slashCommandApi(async <T,>(input: Parameters<RawRequest>[0]) => await rawClient.request<T>(input))
|
|
403
|
-
|
|
413
|
+
const wikiRequest = async <T,>(input: Parameters<WikiRawRequest>[0]): Promise<T> => await rawClient.request<T>(input)
|
|
414
|
+
this.wiki = createWikiApi(wikiRequest)
|
|
415
|
+
this.sheets = createSheetApi(wikiRequest)
|
|
416
|
+
this.bases = createBaseApi(wikiRequest)
|
|
404
417
|
}
|
|
405
418
|
|
|
406
419
|
onMessage(handler: (message: NormalizedMessage) => void | Promise<void>): () => void {
|
package/src/settings/surface.ts
CHANGED
|
@@ -12,6 +12,7 @@
|
|
|
12
12
|
|
|
13
13
|
import { DSH_CHANNELS_VERSION } from '../session-export.ts'
|
|
14
14
|
import type { FeishuConfig } from '../config.ts'
|
|
15
|
+
import { MAX_TIMER_DELAY_MS } from '../config.ts'
|
|
15
16
|
import { FEISHU_USER_ID_PATTERN } from '../domain.ts'
|
|
16
17
|
import { parseWikiRootLink, WIKI_ROOT_LINK_ERRORS } from '../feishu/wiki/links.ts'
|
|
17
18
|
import type { HealthSnapshot } from '../health.ts'
|
|
@@ -325,16 +326,15 @@ const FIELD_META: Record<EditableKey, { label: string; help: string; min?: numbe
|
|
|
325
326
|
},
|
|
326
327
|
'feishuTools.attachmentFetchMaxBytes': {
|
|
327
328
|
label: '单次取回字节预算(字节)',
|
|
328
|
-
help: '一次工具调用的总下载字节预算(默认 104857600 = 100 MiB
|
|
329
|
+
help: '一次工具调用的总下载字节预算(默认 104857600 = 100 MiB,不设上限);文件流式写入原生库,不整文件缓冲,几 GiB 的文件只占磁盘。',
|
|
329
330
|
min: 1,
|
|
330
|
-
max: 1024 * 1024 * 1024,
|
|
331
331
|
step: 1,
|
|
332
332
|
},
|
|
333
333
|
'feishuTools.attachmentFetchTimeoutMs': {
|
|
334
334
|
label: '单次取回超时(ms)',
|
|
335
|
-
help: '一次 feishu_attachment_fetch 的整段超时(默认 120000 ms
|
|
335
|
+
help: '一次 feishu_attachment_fetch 的整段超时(默认 120000 ms,最小 1000,最大 2147483647 ≈ 24.8 天);到期取消下载并逐项报告可读原因。大文件请按带宽同步调大。',
|
|
336
336
|
min: 1_000,
|
|
337
|
-
max:
|
|
337
|
+
max: MAX_TIMER_DELAY_MS,
|
|
338
338
|
step: 1_000,
|
|
339
339
|
},
|
|
340
340
|
'feishuTools.topicMessages': {
|
|
@@ -354,11 +354,11 @@ const FIELD_META: Record<EditableKey, { label: string; help: string; min?: numbe
|
|
|
354
354
|
},
|
|
355
355
|
'feishuTools.wikiRead': {
|
|
356
356
|
label: '启用知识库读取工具',
|
|
357
|
-
help: '
|
|
357
|
+
help: '让模型浏览「知识库范围」内的知识库节点,并读取 docx 文档、电子表格和多维表格(feishu_wiki_list / feishu_wiki_read / feishu_wiki_sheet_read / feishu_wiki_base_read),以应用身份访问。需开通 wiki:wiki、docx:document、sheets:spreadsheet、bitable:app 权限(或对应的只读权限)。只有话题所有者是管理员或在知识库白名单(/auth wiki add @成员)中才能使用;打开文档前会在授权卡上征得话题所有者同意,每个话题一次。知识库范围为空时不挂载。对新会话生效。',
|
|
358
358
|
},
|
|
359
359
|
'feishuTools.wikiWrite': {
|
|
360
360
|
label: '启用知识库写入工具',
|
|
361
|
-
help: '让模型在「知识库范围」内新建 docx
|
|
361
|
+
help: '让模型在「知识库范围」内新建 docx 文档、电子表格和多维表格,编辑本应用拥有的文档和电子表格,增改删本应用拥有的多维表格记录、向其他多维表格新增记录(feishu_wiki_create / feishu_wiki_edit / feishu_wiki_sheet_edit / feishu_wiki_base_edit),以应用身份访问;每次新建或编辑都需话题所有者在审批卡上同意。需开通 wiki:wiki、docx:document、sheets:spreadsheet、bitable:app 权限;使用条件同读取工具(管理员或知识库白名单)。知识库范围为空时不挂载。对新会话生效。',
|
|
362
362
|
},
|
|
363
363
|
wikiRoots: {
|
|
364
364
|
label: '知识库范围(wikiRoots)',
|