@modesto_ma/dsh-channels 0.5.0-beta.1 → 0.5.0-beta.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +15 -7
- package/docs/access-control-design.md +1 -0
- package/docs/attachment-pipeline-v2.md +2 -2
- package/docs/feishu-onboarding.md +6 -2
- package/lib/client.js +271 -1
- package/lib/client.js.map +1 -1
- package/lib/index.js +3374 -167
- package/lib/index.js.map +1 -1
- package/package.json +1 -1
- package/src/access/store.ts +31 -1
- package/src/cards/picker.ts +9 -16
- package/src/cards/wiki.ts +124 -0
- package/src/client/panel/shared.tsx +96 -1
- package/src/client/string-list.ts +139 -0
- package/src/client/styles.ts +3 -0
- package/src/commands/registry.ts +6 -3
- package/src/config.ts +50 -6
- package/src/domain.ts +10 -0
- package/src/feishu/delivery.ts +34 -1
- package/src/feishu/message-read.ts +78 -2
- package/src/feishu/scopes.ts +35 -0
- package/src/feishu/wiki/a1.ts +97 -0
- package/src/feishu/wiki/api.ts +361 -0
- package/src/feishu/wiki/base-api.ts +267 -0
- package/src/feishu/wiki/base-tools.ts +423 -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/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 +897 -0
- package/src/feishu-channel.ts +23 -0
- package/src/service.ts +87 -7
- package/src/settings/contract.ts +42 -4
- package/src/settings/surface.ts +111 -10
|
@@ -0,0 +1,423 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Wiki Base (多维表格) tools: `feishu_wiki_base_read` (switch
|
|
3
|
+
* `feishuTools.wikiRead`) and `feishu_wiki_base_edit` (switch
|
|
4
|
+
* `feishuTools.wikiWrite`).
|
|
5
|
+
*
|
|
6
|
+
* Both run through the same gates as the docx tools (whitelist, `wikiRoots`
|
|
7
|
+
* containment, consent cards). Reading shares the docx read grant per Session
|
|
8
|
+
* and document. Editing changes records only (no table or field structure):
|
|
9
|
+
* on a base the app owns it may create, update, and delete records; on any
|
|
10
|
+
* other base it may only create records, so existing data written by people
|
|
11
|
+
* is never changed. Every edit asks on a consent card. Feishu offers no
|
|
12
|
+
* revision precondition for record writes, and applies them asynchronously.
|
|
13
|
+
*
|
|
14
|
+
* @module dsh-channels/feishu/wiki/base-tools
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { z } from 'zod'
|
|
18
|
+
import type { WikiNode } from './api.ts'
|
|
19
|
+
import { BASE_BATCH_MAX_RECORDS } from './base-api.ts'
|
|
20
|
+
import type { BaseApi, BaseField, BaseRecordFields, BaseTable } from './base-api.ts'
|
|
21
|
+
import { parseWikiRef } from './links.ts'
|
|
22
|
+
import { display, failure, failureOf, guarded, nodeUrl, ownedByApp, parseArgs, preview } from './shared.ts'
|
|
23
|
+
import type { WikiCallContext, WikiFailure, WikiToolKit, WikiToolSpec } from './shared.ts'
|
|
24
|
+
|
|
25
|
+
/** Model-facing tool names; stable across releases (session history references them). */
|
|
26
|
+
export const WIKI_BASE_READ_TOOL_NAME = 'feishu_wiki_base_read'
|
|
27
|
+
export const WIKI_BASE_EDIT_TOOL_NAME = 'feishu_wiki_base_edit'
|
|
28
|
+
|
|
29
|
+
/** Most operations one `feishu_wiki_base_edit` call may apply. */
|
|
30
|
+
export const WIKI_BASE_EDIT_MAX_OPERATIONS = 10
|
|
31
|
+
|
|
32
|
+
/** Record page size bounds of `feishu_wiki_base_read`. */
|
|
33
|
+
const READ_DEFAULT_LIMIT = 50
|
|
34
|
+
const READ_MAX_LIMIT = 200
|
|
35
|
+
|
|
36
|
+
/** Records of each operation shown on a consent card. */
|
|
37
|
+
const CARD_PREVIEW_RECORDS = 3
|
|
38
|
+
|
|
39
|
+
// ---- Arguments ----------------------------------------------------------
|
|
40
|
+
|
|
41
|
+
const refSchema = z.string().trim().min(1).max(2048)
|
|
42
|
+
const nameSchema = z.string().trim().min(1).max(200)
|
|
43
|
+
const recordIdSchema = z.string().trim().min(1).max(64)
|
|
44
|
+
const fieldMapSchema = z.record(z.string().min(1).max(200), z.unknown())
|
|
45
|
+
.refine(map => Object.keys(map).length > 0, '至少包含一个字段')
|
|
46
|
+
|
|
47
|
+
export const wikiBaseReadArgsSchema = z.object({
|
|
48
|
+
doc: refSchema,
|
|
49
|
+
table: nameSchema.optional(),
|
|
50
|
+
filter: z.object({
|
|
51
|
+
logic: z.enum(['and', 'or']).optional(),
|
|
52
|
+
conditions: z.array(z.array(z.unknown()).min(2).max(3)).min(1).max(50),
|
|
53
|
+
}).strict().optional(),
|
|
54
|
+
sort: z.array(z.object({ field: nameSchema, desc: z.boolean().optional() }).strict()).min(1).max(10).optional(),
|
|
55
|
+
keyword: z.string().trim().min(1).max(200).optional(),
|
|
56
|
+
search_fields: z.array(nameSchema).min(1).max(20).optional(),
|
|
57
|
+
fields: z.array(nameSchema).min(1).max(100).optional(),
|
|
58
|
+
view_id: nameSchema.optional(),
|
|
59
|
+
offset: z.number().int().min(0).optional(),
|
|
60
|
+
limit: z.number().int().min(1).max(READ_MAX_LIMIT).optional(),
|
|
61
|
+
}).strict()
|
|
62
|
+
|
|
63
|
+
const baseOperationSchema = z.discriminatedUnion('command', [
|
|
64
|
+
z.object({ command: z.literal('create'), records: z.array(fieldMapSchema).min(1).max(BASE_BATCH_MAX_RECORDS) }).strict(),
|
|
65
|
+
z.object({
|
|
66
|
+
command: z.literal('update'),
|
|
67
|
+
records: z.array(z.object({ record_id: recordIdSchema, fields: fieldMapSchema }).strict()).min(1).max(BASE_BATCH_MAX_RECORDS),
|
|
68
|
+
}).strict(),
|
|
69
|
+
z.object({ command: z.literal('delete'), record_ids: z.array(recordIdSchema).min(1).max(BASE_BATCH_MAX_RECORDS) }).strict(),
|
|
70
|
+
])
|
|
71
|
+
|
|
72
|
+
export type WikiBaseOperation = z.infer<typeof baseOperationSchema>
|
|
73
|
+
|
|
74
|
+
export const wikiBaseEditArgsSchema = z.object({
|
|
75
|
+
doc: refSchema,
|
|
76
|
+
table: nameSchema,
|
|
77
|
+
operations: z.array(baseOperationSchema).min(1).max(WIKI_BASE_EDIT_MAX_OPERATIONS),
|
|
78
|
+
}).strict()
|
|
79
|
+
|
|
80
|
+
// ---- Results ------------------------------------------------------------
|
|
81
|
+
|
|
82
|
+
/** One field as the tools report it: id, name, type, and Feishu's remaining properties. */
|
|
83
|
+
export type WikiBaseFieldView = Readonly<Record<string, unknown>> & {
|
|
84
|
+
readonly field_id: string
|
|
85
|
+
readonly name: string
|
|
86
|
+
readonly type: string
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** Successful `feishu_wiki_base_read`. */
|
|
90
|
+
export interface WikiBaseReadValue {
|
|
91
|
+
readonly ok: true
|
|
92
|
+
readonly title: string
|
|
93
|
+
readonly node_token: string
|
|
94
|
+
readonly url: string
|
|
95
|
+
readonly tables: ReadonlyArray<{ readonly table_id: string; readonly name: string }>
|
|
96
|
+
/** The table read; absent when the call only listed the tables. */
|
|
97
|
+
readonly table_id?: string
|
|
98
|
+
readonly fields?: readonly WikiBaseFieldView[]
|
|
99
|
+
readonly records?: ReadonlyArray<{ readonly record_id: string; readonly fields: Readonly<Record<string, unknown>> }>
|
|
100
|
+
readonly has_more?: boolean
|
|
101
|
+
/** Offset of the next page when `has_more`. */
|
|
102
|
+
readonly next_offset?: number
|
|
103
|
+
readonly timezone?: string
|
|
104
|
+
readonly ignored_fields?: readonly string[]
|
|
105
|
+
/** What `feishu_wiki_base_edit` may do here: `all` (owned by the app), `create` (add records only), or `none`. */
|
|
106
|
+
readonly writable: 'all' | 'create' | 'none'
|
|
107
|
+
readonly warning?: string
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** Successful `feishu_wiki_base_edit`. */
|
|
111
|
+
export interface WikiBaseEditValue {
|
|
112
|
+
readonly ok: true
|
|
113
|
+
readonly title: string
|
|
114
|
+
readonly node_token: string
|
|
115
|
+
readonly url: string
|
|
116
|
+
readonly table_id: string
|
|
117
|
+
readonly applied: number
|
|
118
|
+
readonly created_record_ids: readonly string[]
|
|
119
|
+
readonly updated: number
|
|
120
|
+
readonly deleted: number
|
|
121
|
+
readonly ignored_fields: readonly string[]
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// ---- Descriptions -------------------------------------------------------
|
|
125
|
+
|
|
126
|
+
const VALUE_NOTE = '单元格取值:文本为字符串;数字为数字;单选/多选为选项名数组(如 ["进行中"],单选也是数组);日期为 "2026-03-24 10:00" 或毫秒时间戳;'
|
|
127
|
+
+ '复选框为布尔;人员为 [{"id":"ou_xxx"}];关联为 [{"id":"rec_xxx"}];null 清空。公式、查找引用、创建时间等字段只读。'
|
|
128
|
+
|
|
129
|
+
const READ_DESCRIPTION =
|
|
130
|
+
'读取知识库中的飞书多维表格(bitable)。doc 传知识库节点链接或 node_token。'
|
|
131
|
+
+ '第一次读取某个多维表格会在话题里弹出授权卡,由话题发起人确认,确认后本话题内再次读取不再询问;等待期间不要重复调用。'
|
|
132
|
+
+ '不传 table 时只返回数据表列表;传 table(table_id 或表名)时返回字段列表和记录。'
|
|
133
|
+
+ 'filter 形如 {"logic":"and","conditions":[["状态","intersects",["进行中"]],["金额",">=",100],["备注","non_empty"]]},'
|
|
134
|
+
+ '运算符有 == != > >= < <= intersects(包含)disjoint(不包含)empty non_empty;日期条件用 "ExactDate(2026-08-07)",日期不支持 >=。'
|
|
135
|
+
+ 'sort 形如 [{"field":"更新时间","desc":true}];keyword 为关键词搜索,search_fields 指定搜索的字段(默认主字段);fields 只返回指定字段。'
|
|
136
|
+
+ `分页用 offset/limit(limit 默认 ${READ_DEFAULT_LIMIT},最多 ${READ_MAX_LIMIT}),has_more 为 true 时用 next_offset 继续。`
|
|
137
|
+
+ 'writable 说明能做哪些修改:all 表示可增改删记录(本应用创建的表),create 表示只能新增记录,none 表示不能修改。'
|
|
138
|
+
|
|
139
|
+
const EDIT_DESCRIPTION =
|
|
140
|
+
'修改知识库中飞书多维表格(bitable)某个数据表的记录。先用 feishu_wiki_base_read 读取字段列表,按字段名写入。'
|
|
141
|
+
+ `operations 按顺序执行(最多 ${WIKI_BASE_EDIT_MAX_OPERATIONS} 个,每个最多 ${BASE_BATCH_MAX_RECORDS} 条记录):`
|
|
142
|
+
+ 'create(records 为字段名→值的对象数组)、update(records 为 {record_id, fields} 数组,只改给出的字段)、delete(record_ids)。'
|
|
143
|
+
+ '本应用创建的多维表格可以增改删;其他多维表格只能新增记录,不能修改或删除已有记录。不会修改数据表和字段结构。'
|
|
144
|
+
+ VALUE_NOTE
|
|
145
|
+
+ '写入是异步生效的,立刻读取可能还看不到。每次修改都会在话题里弹出授权卡,由话题发起人确认后才执行;等待期间不要重复调用。'
|
|
146
|
+
|
|
147
|
+
// ---- Parameters ---------------------------------------------------------
|
|
148
|
+
|
|
149
|
+
const REF_DESCRIPTION = '知识库节点链接 https://<租户>.feishu.cn/wiki/<node_token> 或 node_token'
|
|
150
|
+
const TABLE_PROPERTY = { type: 'string', description: '数据表 table_id 或表名' }
|
|
151
|
+
const FIELD_MAP_PROPERTY = { type: 'object', description: '字段名(或字段 id)→ 值', additionalProperties: true }
|
|
152
|
+
|
|
153
|
+
const READ_PARAMETERS: Record<string, unknown> = {
|
|
154
|
+
type: 'object',
|
|
155
|
+
properties: {
|
|
156
|
+
doc: { type: 'string', description: REF_DESCRIPTION },
|
|
157
|
+
table: { ...TABLE_PROPERTY, description: '数据表 table_id 或表名;不传只列出数据表' },
|
|
158
|
+
filter: {
|
|
159
|
+
type: 'object',
|
|
160
|
+
properties: {
|
|
161
|
+
logic: { type: 'string', enum: ['and', 'or'] },
|
|
162
|
+
conditions: { type: 'array', items: { type: 'array', description: '[字段, 运算符, 值];empty / non_empty 不带值' } },
|
|
163
|
+
},
|
|
164
|
+
required: ['conditions'],
|
|
165
|
+
additionalProperties: false,
|
|
166
|
+
},
|
|
167
|
+
sort: {
|
|
168
|
+
type: 'array',
|
|
169
|
+
items: { type: 'object', properties: { field: { type: 'string' }, desc: { type: 'boolean' } }, required: ['field'], additionalProperties: false },
|
|
170
|
+
},
|
|
171
|
+
keyword: { type: 'string', description: '关键词搜索' },
|
|
172
|
+
search_fields: { type: 'array', items: { type: 'string' }, description: 'keyword 搜索的字段,默认主字段' },
|
|
173
|
+
fields: { type: 'array', items: { type: 'string' }, description: '只返回这些字段' },
|
|
174
|
+
view_id: { type: 'string', description: '按视图读取(应用视图的筛选与排序)' },
|
|
175
|
+
offset: { type: 'integer', minimum: 0 },
|
|
176
|
+
limit: { type: 'integer', minimum: 1, maximum: READ_MAX_LIMIT },
|
|
177
|
+
},
|
|
178
|
+
required: ['doc'],
|
|
179
|
+
additionalProperties: false,
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
const EDIT_PARAMETERS: Record<string, unknown> = {
|
|
183
|
+
type: 'object',
|
|
184
|
+
properties: {
|
|
185
|
+
doc: { type: 'string', description: REF_DESCRIPTION },
|
|
186
|
+
table: TABLE_PROPERTY,
|
|
187
|
+
operations: {
|
|
188
|
+
type: 'array',
|
|
189
|
+
minItems: 1,
|
|
190
|
+
maxItems: WIKI_BASE_EDIT_MAX_OPERATIONS,
|
|
191
|
+
items: {
|
|
192
|
+
type: 'object',
|
|
193
|
+
properties: {
|
|
194
|
+
command: { type: 'string', enum: ['create', 'update', 'delete'] },
|
|
195
|
+
records: {
|
|
196
|
+
type: 'array',
|
|
197
|
+
maxItems: BASE_BATCH_MAX_RECORDS,
|
|
198
|
+
description: 'create:字段映射数组;update:{record_id, fields} 数组',
|
|
199
|
+
items: {
|
|
200
|
+
type: 'object',
|
|
201
|
+
properties: { record_id: { type: 'string' }, fields: FIELD_MAP_PROPERTY },
|
|
202
|
+
additionalProperties: true,
|
|
203
|
+
},
|
|
204
|
+
},
|
|
205
|
+
record_ids: { type: 'array', maxItems: BASE_BATCH_MAX_RECORDS, items: { type: 'string' }, description: 'delete 的记录 id' },
|
|
206
|
+
},
|
|
207
|
+
required: ['command'],
|
|
208
|
+
additionalProperties: false,
|
|
209
|
+
},
|
|
210
|
+
},
|
|
211
|
+
},
|
|
212
|
+
required: ['doc', 'table', 'operations'],
|
|
213
|
+
additionalProperties: false,
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
// ---- Shared steps -------------------------------------------------------
|
|
217
|
+
|
|
218
|
+
interface BaseTarget {
|
|
219
|
+
readonly context: WikiCallContext
|
|
220
|
+
readonly bases: BaseApi
|
|
221
|
+
readonly node: WikiNode
|
|
222
|
+
readonly url: string
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
async function baseTarget(kit: WikiToolKit, context: WikiCallContext, doc: string): Promise<BaseTarget | WikiFailure> {
|
|
226
|
+
const bases = context.bases
|
|
227
|
+
if (bases === undefined) return failure('CHANNEL_UNAVAILABLE')
|
|
228
|
+
const ref = parseWikiRef(doc)
|
|
229
|
+
if (ref === undefined) return failure('INVALID_REFERENCE')
|
|
230
|
+
const node = await kit.containedNode(context, ref)
|
|
231
|
+
if ('ok' in node) return node
|
|
232
|
+
if (node.objType !== 'bitable') return failure('UNSUPPORTED_TYPE', `${failure('UNSUPPORTED_TYPE').reason}(该节点类型:${node.objType})`)
|
|
233
|
+
return { context, bases, node, url: nodeUrl(context.roots, node) }
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
function writableOf(kit: WikiToolKit, target: BaseTarget): 'all' | 'create' | 'none' {
|
|
237
|
+
if (!kit.writeEnabled()) return 'none'
|
|
238
|
+
return ownedByApp(target.node, target.context.botOpenId) ? 'all' : 'create'
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
function pickTable(tables: readonly BaseTable[], wanted: string): BaseTable | string {
|
|
242
|
+
const match = tables.find(table => table.tableId === wanted) ?? tables.find(table => table.name === wanted)
|
|
243
|
+
if (match !== undefined) return match
|
|
244
|
+
const list = tables.map(table => `${table.name}(${table.tableId})`).join('、') || '无'
|
|
245
|
+
return `找不到数据表「${wanted}」;可用数据表:${list}`
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
function fieldView(field: BaseField): WikiBaseFieldView {
|
|
249
|
+
return { ...field.properties, field_id: field.fieldId, name: field.name, type: field.type }
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
// ---- feishu_wiki_base_read ---------------------------------------------
|
|
253
|
+
|
|
254
|
+
async function read(kit: WikiToolKit, sessionId: string, rawArgs: Record<string, unknown>, signal: AbortSignal): Promise<object> {
|
|
255
|
+
const args = parseArgs(WIKI_BASE_READ_TOOL_NAME, wikiBaseReadArgsSchema, rawArgs)
|
|
256
|
+
const gate = kit.gate(sessionId, WIKI_BASE_READ_TOOL_NAME)
|
|
257
|
+
if (!gate.ok) return gate
|
|
258
|
+
return await guarded<WikiBaseReadValue>(kit, WIKI_BASE_READ_TOOL_NAME, async () => {
|
|
259
|
+
const target = await baseTarget(kit, gate.context, args.doc)
|
|
260
|
+
if ('ok' in target) return target
|
|
261
|
+
const denied = await kit.readConsent(sessionId, target.context, target.node, signal)
|
|
262
|
+
if (denied !== undefined) return denied
|
|
263
|
+
if (signal.aborted) return failure('ABORTED')
|
|
264
|
+
const token = target.node.objToken
|
|
265
|
+
const tables = await target.bases.listTables(token)
|
|
266
|
+
const base = {
|
|
267
|
+
ok: true as const,
|
|
268
|
+
title: target.node.title,
|
|
269
|
+
node_token: target.node.nodeToken,
|
|
270
|
+
url: target.url,
|
|
271
|
+
tables: tables.map(table => ({ table_id: table.tableId, name: table.name })),
|
|
272
|
+
writable: writableOf(kit, target),
|
|
273
|
+
}
|
|
274
|
+
if (args.table === undefined) return base
|
|
275
|
+
const table = pickTable(tables, args.table)
|
|
276
|
+
if (typeof table === 'string') return failure('INVALID_OPERATION', table)
|
|
277
|
+
const fields = await target.bases.listFields(token, table.tableId)
|
|
278
|
+
const offset = args.offset ?? 0
|
|
279
|
+
const query = {
|
|
280
|
+
...(args.filter === undefined ? {} : { filter: args.filter }),
|
|
281
|
+
...(args.sort === undefined ? {} : { sort: args.sort }),
|
|
282
|
+
...(args.fields === undefined ? {} : { fields: args.fields }),
|
|
283
|
+
...(args.view_id === undefined ? {} : { viewId: args.view_id }),
|
|
284
|
+
offset,
|
|
285
|
+
limit: args.limit ?? READ_DEFAULT_LIMIT,
|
|
286
|
+
}
|
|
287
|
+
let page
|
|
288
|
+
if (args.keyword === undefined) {
|
|
289
|
+
page = await target.bases.listRecords(token, table.tableId, query)
|
|
290
|
+
} else {
|
|
291
|
+
const primary = fields[0]?.name
|
|
292
|
+
const searchFields = args.search_fields ?? (primary === undefined ? [] : [primary])
|
|
293
|
+
if (searchFields.length === 0) return failure('INVALID_OPERATION', 'keyword 搜索需要 search_fields')
|
|
294
|
+
page = await target.bases.searchRecords(token, table.tableId, { ...query, keyword: args.keyword, searchFields })
|
|
295
|
+
}
|
|
296
|
+
return {
|
|
297
|
+
...base,
|
|
298
|
+
table_id: table.tableId,
|
|
299
|
+
fields: fields.map(fieldView),
|
|
300
|
+
records: page.records.map(record => ({ record_id: record.recordId, fields: record.fields })),
|
|
301
|
+
has_more: page.hasMore,
|
|
302
|
+
...(page.hasMore ? { next_offset: offset + page.records.length } : {}),
|
|
303
|
+
...(page.timezone === undefined ? {} : { timezone: page.timezone }),
|
|
304
|
+
...(page.ignoredFields.length === 0 ? {} : { ignored_fields: page.ignoredFields }),
|
|
305
|
+
}
|
|
306
|
+
})
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
// ---- feishu_wiki_base_edit ---------------------------------------------
|
|
310
|
+
|
|
311
|
+
function json(value: unknown): string {
|
|
312
|
+
return preview(JSON.stringify(value) ?? 'null', 120)
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
function cardLines(operations: readonly WikiBaseOperation[], tableName: string): string[] {
|
|
316
|
+
const lines = operations.map((operation, index) => {
|
|
317
|
+
const n = `${index + 1}.`
|
|
318
|
+
switch (operation.command) {
|
|
319
|
+
case 'create': {
|
|
320
|
+
const sample = operation.records.slice(0, CARD_PREVIEW_RECORDS).map(record => ` ${json(record)}`)
|
|
321
|
+
const more = operation.records.length > CARD_PREVIEW_RECORDS ? [` …(共 ${operation.records.length} 条)`] : []
|
|
322
|
+
return [`${n} 新增 ${operation.records.length} 条记录`, ...sample, ...more].join('\n')
|
|
323
|
+
}
|
|
324
|
+
case 'update': {
|
|
325
|
+
const sample = operation.records.slice(0, CARD_PREVIEW_RECORDS).map(record => ` ${preview(record.record_id, 40)}:${json(record.fields)}`)
|
|
326
|
+
const more = operation.records.length > CARD_PREVIEW_RECORDS ? [` …(共 ${operation.records.length} 条)`] : []
|
|
327
|
+
return [`${n} 修改 ${operation.records.length} 条记录`, ...sample, ...more].join('\n')
|
|
328
|
+
}
|
|
329
|
+
case 'delete':
|
|
330
|
+
return `${n} ⚠️ 删除 ${operation.record_ids.length} 条记录:${preview(operation.record_ids.join('、'), 160)}`
|
|
331
|
+
}
|
|
332
|
+
})
|
|
333
|
+
return [`**数据表**:${preview(tableName, 60)}`, `**将执行 ${operations.length} 个操作**`, ...lines]
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
async function edit(kit: WikiToolKit, sessionId: string, rawArgs: Record<string, unknown>, signal: AbortSignal): Promise<object> {
|
|
337
|
+
const args = parseArgs(WIKI_BASE_EDIT_TOOL_NAME, wikiBaseEditArgsSchema, rawArgs)
|
|
338
|
+
const gate = kit.gate(sessionId, WIKI_BASE_EDIT_TOOL_NAME)
|
|
339
|
+
if (!gate.ok) return gate
|
|
340
|
+
return await guarded<WikiBaseEditValue>(kit, WIKI_BASE_EDIT_TOOL_NAME, async () => {
|
|
341
|
+
const target = await baseTarget(kit, gate.context, args.doc)
|
|
342
|
+
if ('ok' in target) return target
|
|
343
|
+
const { node, bases } = target
|
|
344
|
+
if (writableOf(kit, target) !== 'all') {
|
|
345
|
+
const changing = args.operations.find(operation => operation.command !== 'create')
|
|
346
|
+
if (changing !== undefined) {
|
|
347
|
+
return failure('NOT_OWNED', `这个多维表格不是本应用创建的,只能新增记录,不能${changing.command === 'update' ? '修改' : '删除'}已有记录(所有者:${node.owner ?? '未知'})`)
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
const table = pickTable(await bases.listTables(node.objToken), args.table)
|
|
351
|
+
if (typeof table === 'string') return failure('INVALID_OPERATION', table)
|
|
352
|
+
const denied = await kit.consent(sessionId, target.context, 'edit', {
|
|
353
|
+
title: node.title,
|
|
354
|
+
url: target.url,
|
|
355
|
+
location: await kit.locationOf(target.context.api, node),
|
|
356
|
+
details: cardLines(args.operations, table.name),
|
|
357
|
+
}, signal)
|
|
358
|
+
if (denied !== undefined) return denied
|
|
359
|
+
const created: string[] = []
|
|
360
|
+
const ignored: string[] = []
|
|
361
|
+
let updated = 0
|
|
362
|
+
let deleted = 0
|
|
363
|
+
for (const [index, operation] of args.operations.entries()) {
|
|
364
|
+
if (signal.aborted) return { ...failure('ABORTED', `已取消;前 ${index} 个操作已执行`), applied: index }
|
|
365
|
+
try {
|
|
366
|
+
switch (operation.command) {
|
|
367
|
+
case 'create':
|
|
368
|
+
created.push(...await bases.createRecords(node.objToken, table.tableId, operation.records as BaseRecordFields[]))
|
|
369
|
+
break
|
|
370
|
+
case 'update':
|
|
371
|
+
ignored.push(...await bases.updateRecords(
|
|
372
|
+
node.objToken,
|
|
373
|
+
table.tableId,
|
|
374
|
+
Object.fromEntries(operation.records.map(record => [record.record_id, record.fields as BaseRecordFields])),
|
|
375
|
+
))
|
|
376
|
+
updated += operation.records.length
|
|
377
|
+
break
|
|
378
|
+
case 'delete':
|
|
379
|
+
await bases.deleteRecords(node.objToken, table.tableId, operation.record_ids)
|
|
380
|
+
deleted += operation.record_ids.length
|
|
381
|
+
break
|
|
382
|
+
}
|
|
383
|
+
} catch (error) {
|
|
384
|
+
const reason = failureOf(error).reason
|
|
385
|
+
return { ...failure('EDIT_FAILED', `第 ${index + 1} 个操作失败:${reason};前 ${index} 个操作已执行`), applied: index }
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
return {
|
|
389
|
+
ok: true,
|
|
390
|
+
title: node.title,
|
|
391
|
+
node_token: node.nodeToken,
|
|
392
|
+
url: target.url,
|
|
393
|
+
table_id: table.tableId,
|
|
394
|
+
applied: args.operations.length,
|
|
395
|
+
created_record_ids: created,
|
|
396
|
+
updated,
|
|
397
|
+
deleted,
|
|
398
|
+
ignored_fields: [...new Set(ignored)],
|
|
399
|
+
}
|
|
400
|
+
})
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
// ---- Specs --------------------------------------------------------------
|
|
404
|
+
|
|
405
|
+
/** The Base tool family. */
|
|
406
|
+
export const BASE_TOOL_SPECS: readonly WikiToolSpec[] = [
|
|
407
|
+
{
|
|
408
|
+
name: WIKI_BASE_READ_TOOL_NAME,
|
|
409
|
+
access: 'read',
|
|
410
|
+
description: READ_DESCRIPTION,
|
|
411
|
+
parameters: READ_PARAMETERS,
|
|
412
|
+
execute: read,
|
|
413
|
+
presentCall: args => `读取知识库多维表格:${display(args.doc)}`,
|
|
414
|
+
},
|
|
415
|
+
{
|
|
416
|
+
name: WIKI_BASE_EDIT_TOOL_NAME,
|
|
417
|
+
access: 'write',
|
|
418
|
+
description: EDIT_DESCRIPTION,
|
|
419
|
+
parameters: EDIT_PARAMETERS,
|
|
420
|
+
execute: edit,
|
|
421
|
+
presentCall: args => `修改知识库多维表格:${display(args.doc)}`,
|
|
422
|
+
},
|
|
423
|
+
]
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Topic-owner consent for wiki tool calls.
|
|
3
|
+
*
|
|
4
|
+
* A wiki tool that opens, creates, or edits a document asks the topic owner
|
|
5
|
+
* on a consent card in the topic and waits for the answer. Only the topic
|
|
6
|
+
* owner (`route.senderUserId`) can decide; a click by anyone else adds the
|
|
7
|
+
* owner-only notice once. An unanswered card is denied after the configured
|
|
8
|
+
* window (`approvalTimeoutMs`). A read consent is remembered per Session and
|
|
9
|
+
* document, in memory only: it ends with the Session (`/renew`) or the
|
|
10
|
+
* process. Create and edit consents are never remembered.
|
|
11
|
+
*
|
|
12
|
+
* @module dsh-channels/feishu/wiki/consent
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { randomUUID } from 'node:crypto'
|
|
16
|
+
import { buildWikiConsentCard, buildWikiConsentSettledCard } from '../../cards/wiki.ts'
|
|
17
|
+
import type { WikiConsentAction, WikiConsentCardState, WikiConsentQuestion } from '../../cards/wiki.ts'
|
|
18
|
+
import type { CardActionEvent, FeishuChannel } from '../../feishu-channel.ts'
|
|
19
|
+
import { PendingCardTable } from '../../interactions/pending.ts'
|
|
20
|
+
import type { InteractionRoute, PendingCardEntry } from '../../interactions/pending.ts'
|
|
21
|
+
|
|
22
|
+
/** How one consent request ended. */
|
|
23
|
+
export type WikiConsentOutcome = 'allowed' | 'denied' | 'expired' | 'cancelled' | 'unavailable'
|
|
24
|
+
|
|
25
|
+
/** One consent to ask for. */
|
|
26
|
+
export interface WikiConsentRequest {
|
|
27
|
+
readonly sessionId: string
|
|
28
|
+
readonly route: InteractionRoute
|
|
29
|
+
readonly action: WikiConsentAction
|
|
30
|
+
readonly title: string
|
|
31
|
+
readonly url?: string
|
|
32
|
+
readonly location: string
|
|
33
|
+
readonly details: readonly string[]
|
|
34
|
+
/** The tool call's abort signal; aborting withdraws the card. */
|
|
35
|
+
readonly signal: AbortSignal
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
interface Pending extends PendingCardEntry {
|
|
39
|
+
readonly question: WikiConsentQuestion
|
|
40
|
+
resolve(outcome: WikiConsentOutcome): void
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export interface WikiConsentDeps {
|
|
44
|
+
/** Live Feishu channel; undefined while disconnected. */
|
|
45
|
+
channel(): FeishuChannel | undefined
|
|
46
|
+
/** Decision window per card, read per request (`approvalTimeoutMs`). */
|
|
47
|
+
timeoutMs(): number
|
|
48
|
+
/** Log a non-fatal delivery/update failure. */
|
|
49
|
+
onError(sessionId: string, error: unknown): void
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** Consent cards for the wiki tools. */
|
|
53
|
+
export class WikiConsent {
|
|
54
|
+
/** One table per channel instance (cards are patched through their channel). */
|
|
55
|
+
private table: { channel: FeishuChannel; entries: PendingCardTable<Pending> } | undefined
|
|
56
|
+
/** sessionId → document ids with a standing read consent. */
|
|
57
|
+
private readonly readGrants = new Map<string, Set<string>>()
|
|
58
|
+
|
|
59
|
+
constructor(private readonly deps: WikiConsentDeps) {}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Whether the Session already holds a read consent for one document.
|
|
63
|
+
* @param sessionId The Session.
|
|
64
|
+
* @param documentId Docx document id.
|
|
65
|
+
* @returns True when reading needs no new card.
|
|
66
|
+
*/
|
|
67
|
+
hasReadGrant(sessionId: string, documentId: string): boolean {
|
|
68
|
+
return this.readGrants.get(sessionId)?.has(documentId) === true
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Record a standing read consent (after an allowed read, or for a document
|
|
73
|
+
* the topic owner just approved creating).
|
|
74
|
+
* @param sessionId The Session.
|
|
75
|
+
* @param documentId Docx document id.
|
|
76
|
+
*/
|
|
77
|
+
grantRead(sessionId: string, documentId: string): void {
|
|
78
|
+
let grants = this.readGrants.get(sessionId)
|
|
79
|
+
if (grants === undefined) {
|
|
80
|
+
grants = new Set()
|
|
81
|
+
this.readGrants.set(sessionId, grants)
|
|
82
|
+
}
|
|
83
|
+
grants.add(documentId)
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Ask the topic owner and wait for the decision. Never rejects.
|
|
88
|
+
* @param request What to ask.
|
|
89
|
+
* @returns The outcome; `unavailable` when no card could be delivered.
|
|
90
|
+
*/
|
|
91
|
+
request(request: WikiConsentRequest): Promise<WikiConsentOutcome> {
|
|
92
|
+
if (request.signal.aborted) return Promise.resolve('cancelled')
|
|
93
|
+
const channel = this.deps.channel()
|
|
94
|
+
if (channel === undefined) return Promise.resolve('unavailable')
|
|
95
|
+
const table = this.tableFor(channel)
|
|
96
|
+
const timeoutMs = this.deps.timeoutMs()
|
|
97
|
+
const question: WikiConsentQuestion = {
|
|
98
|
+
token: randomUUID(),
|
|
99
|
+
action: request.action,
|
|
100
|
+
title: request.title,
|
|
101
|
+
...(request.url === undefined ? {} : { url: request.url }),
|
|
102
|
+
location: request.location,
|
|
103
|
+
details: request.details,
|
|
104
|
+
timeoutMs,
|
|
105
|
+
}
|
|
106
|
+
return new Promise<WikiConsentOutcome>((resolve) => {
|
|
107
|
+
const entry: Pending = {
|
|
108
|
+
token: question.token,
|
|
109
|
+
sessionId: request.sessionId,
|
|
110
|
+
route: request.route,
|
|
111
|
+
messageId: undefined,
|
|
112
|
+
settled: false,
|
|
113
|
+
timer: undefined,
|
|
114
|
+
signal: request.signal,
|
|
115
|
+
onAbort: undefined,
|
|
116
|
+
unauthorizedNoted: false,
|
|
117
|
+
question,
|
|
118
|
+
resolve,
|
|
119
|
+
}
|
|
120
|
+
const onAbort = (): void => this.settle(table, entry, 'cancelled', 'cancelled')
|
|
121
|
+
entry.onAbort = onAbort
|
|
122
|
+
request.signal.addEventListener('abort', onAbort, { once: true })
|
|
123
|
+
table.add(entry)
|
|
124
|
+
void table.deliver(entry, {
|
|
125
|
+
card: buildWikiConsentCard(question),
|
|
126
|
+
onWithdrawn: e => table.patch(e, buildWikiConsentSettledCard(e.question, 'cancelled')),
|
|
127
|
+
onDelivered: e => table.armTimeout(e, timeoutMs, () => this.settle(table, e, 'expired', 'expired')),
|
|
128
|
+
onFailure: e => this.settle(table, e, 'unavailable'),
|
|
129
|
+
})
|
|
130
|
+
})
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Route one card button callback to its pending consent; unknown cards are
|
|
135
|
+
* ignored.
|
|
136
|
+
* @param event The card action.
|
|
137
|
+
*/
|
|
138
|
+
onCardAction(event: CardActionEvent): void {
|
|
139
|
+
const table = this.table?.entries
|
|
140
|
+
if (table === undefined) return
|
|
141
|
+
const entry = table.resolveAction(event, (candidate) => {
|
|
142
|
+
if (candidate.unauthorizedNoted) return
|
|
143
|
+
candidate.unauthorizedNoted = true
|
|
144
|
+
table.patch(candidate, buildWikiConsentCard(candidate.question, { unauthorizedNote: true }))
|
|
145
|
+
})
|
|
146
|
+
if (entry === undefined) return
|
|
147
|
+
const decision = (event.action.value as { decision?: unknown } | undefined)?.decision
|
|
148
|
+
if (decision === 'allow') this.settle(table, entry, 'allowed', 'allowed')
|
|
149
|
+
else if (decision === 'deny') this.settle(table, entry, 'denied', 'denied')
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/** Withdraw every pending card and forget every read consent (shutdown). */
|
|
153
|
+
dispose(): void {
|
|
154
|
+
const table = this.table?.entries
|
|
155
|
+
table?.withdrawAll(entry => this.settle(table, entry, 'cancelled', 'cancelled'))
|
|
156
|
+
this.table = undefined
|
|
157
|
+
this.readGrants.clear()
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/** Pending consent count (diagnostics/tests). */
|
|
161
|
+
pendingCount(): number {
|
|
162
|
+
return this.table?.entries.count() ?? 0
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
private tableFor(channel: FeishuChannel): PendingCardTable<Pending> {
|
|
166
|
+
if (this.table?.channel !== channel) {
|
|
167
|
+
this.table = { channel, entries: new PendingCardTable<Pending>(channel, this.deps.onError) }
|
|
168
|
+
}
|
|
169
|
+
return this.table.entries
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
private settle(
|
|
173
|
+
table: PendingCardTable<Pending>,
|
|
174
|
+
entry: Pending,
|
|
175
|
+
outcome: WikiConsentOutcome,
|
|
176
|
+
cardState?: WikiConsentCardState,
|
|
177
|
+
): void {
|
|
178
|
+
const closed = table.close(entry, () => {
|
|
179
|
+
if (cardState !== undefined) table.patch(entry, buildWikiConsentSettledCard(entry.question, cardState))
|
|
180
|
+
})
|
|
181
|
+
if (closed) entry.resolve(outcome)
|
|
182
|
+
}
|
|
183
|
+
}
|