@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.
@@ -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 = WikiFailure | WikiListValue | WikiReadValue | WikiCreateValue | WikiEditValue
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
- + '新文档本身也可以作为目录,之后把它的 node_token 作为 parent 在下面继续新建子文档。'
267
- + 'title 为标题;content 为正文(可选,不要在正文里重复标题)。' + FORMAT_NOTE
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
- /** 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
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
- private gate(sessionId: string, tool: string): Gate {
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: { route, api, roots: parseWikiRoots(this.deps.config().wikiRoots), botOpenId: channel.botOpenId() },
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
- private async containedNode(context: CallContext, ref: WikiRef): Promise<WikiNode | WikiFailure> {
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
- private async consent(
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
- private async locationOf(api: WikiApi, node: WikiNode): Promise<string> {
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
- 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
- }
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
- const details = content === undefined
622
- ? ['**内容**:空文档']
623
- : [`**内容预览**(${format === 'xml' ? 'XML' : 'Markdown'},共 ${content.length} 字)\n${previewBlock(content)}`]
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.createDocxNode(spaceId, parentToken, args.title)
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.deps.consent.grantRead(sessionId, node.objToken)
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
- content: { type: 'string', description: '正文(可选)' },
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'],
@@ -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
- this.wiki = createWikiApi(async <T,>(input: Parameters<WikiRawRequest>[0]) => await rawClient.request<T>(input))
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 {
@@ -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,1000–600000);到期取消下载并逐项报告可读原因。',
335
+ help: '一次 feishu_attachment_fetch 的整段超时(默认 120000 ms,最小 1000,最大 2147483647 ≈ 24.8 天);到期取消下载并逐项报告可读原因。大文件请按带宽同步调大。',
336
336
  min: 1_000,
337
- max: 600_000,
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: '让模型浏览「知识库范围」内的知识库节点并读取 docx 文档(feishu_wiki_list / feishu_wiki_read),以应用身份访问。需开通 wiki:wiki(或 wiki:wiki:readonly)与 docx:document(或 docx:document:readonly)权限。只有话题所有者是管理员或在知识库白名单(/auth wiki add @成员)中才能使用;打开文档前会在授权卡上征得话题所有者同意,每个话题一次。知识库范围为空时不挂载。对新会话生效。',
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 文档,并编辑本应用拥有的文档(feishu_wiki_create / feishu_wiki_edit),以应用身份访问;每次新建或编辑都需话题所有者在审批卡上同意。需开通 wiki:wiki 与 docx:document 权限;使用条件同读取工具(管理员或知识库白名单)。知识库范围为空时不挂载。对新会话生效。',
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)',