@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/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@modesto_ma/dsh-channels",
|
|
3
|
-
"version": "0.5.0-beta.
|
|
3
|
+
"version": "0.5.0-beta.3",
|
|
4
4
|
"description": "DeepSeek Harness (DSH) 飞书渠道插件:一个 dsh 实例对应一个飞书机器人,私聊/群聊/话题群 @ 触发会话,话题绑定 session,卡片 2.0 流式回复;Web 设置里提供「飞书渠道」状态与常用配置面板",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
package/src/config.ts
CHANGED
|
@@ -48,6 +48,9 @@ export const DEFAULT_ATTACHMENT_FETCH_MAX_BYTES = 100 * 1024 * 1024
|
|
|
48
48
|
/** Default whole-call timeout of `feishu_attachment_fetch` (120 s). */
|
|
49
49
|
export const DEFAULT_ATTACHMENT_FETCH_TIMEOUT_MS = 120_000
|
|
50
50
|
|
|
51
|
+
/** Largest delay Node's `setTimeout` honors (2^31 − 1 ms, about 24.8 days); longer delays fire at once. */
|
|
52
|
+
export const MAX_TIMER_DELAY_MS = 2_147_483_647
|
|
53
|
+
|
|
51
54
|
/** Default per-image byte cap for trigger-message prefetch (20 MiB). */
|
|
52
55
|
export const DEFAULT_PREFETCH_IMAGE_MAX_BYTES = 20 * 1024 * 1024
|
|
53
56
|
|
|
@@ -265,10 +268,12 @@ export const configSchema = z.object({
|
|
|
265
268
|
* fetch (1–50). A message with more attachments is refused with a readable
|
|
266
269
|
* reason so the model can retry per `file_key`.
|
|
267
270
|
* - `attachmentFetchMaxBytes`: total download budget of one tool call
|
|
268
|
-
* (default 100 MiB). Files stream to the native attachment
|
|
269
|
-
* limit is enforced while streaming rather than buffered
|
|
270
|
-
* -
|
|
271
|
-
*
|
|
271
|
+
* (default 100 MiB, no upper bound). Files stream to the native attachment
|
|
272
|
+
* library, so the limit is enforced while streaming rather than buffered
|
|
273
|
+
* first, and multi-GiB files need only disk space.
|
|
274
|
+
* - `attachmentFetchTimeoutMs`: whole-call timeout (default 120 s, at most
|
|
275
|
+
* {@link MAX_TIMER_DELAY_MS}). On expiry the in-flight download is
|
|
276
|
+
* cancelled and reported per attachment.
|
|
272
277
|
* - `topicMessages` (default on): register `feishu_topic_messages` in
|
|
273
278
|
* channel sessions so the model can read the quoted message / earlier
|
|
274
279
|
* topic messages on demand and authorize their attachments for fetch.
|
|
@@ -288,14 +293,21 @@ export const configSchema = z.object({
|
|
|
288
293
|
* `im:resource` (or `im:resource:upload`) scope; without it a call returns
|
|
289
294
|
* a readable failure and the turn continues.
|
|
290
295
|
*
|
|
291
|
-
* - `wikiRead` (default off): register `feishu_wiki_list
|
|
292
|
-
* `feishu_wiki_read`
|
|
293
|
-
* `
|
|
294
|
-
* `
|
|
295
|
-
*
|
|
296
|
-
* `
|
|
297
|
-
* `
|
|
298
|
-
*
|
|
296
|
+
* - `wikiRead` (default off): register `feishu_wiki_list`,
|
|
297
|
+
* `feishu_wiki_read`, `feishu_wiki_sheet_read`, and
|
|
298
|
+
* `feishu_wiki_base_read` so the model can browse the wiki nodes under
|
|
299
|
+
* `wikiRoots` and read docx documents, spreadsheets, and Bases there.
|
|
300
|
+
* Needs `wiki:wiki` (or `wiki:wiki:readonly`), `docx:document` (or
|
|
301
|
+
* `docx:document:readonly`), `sheets:spreadsheet` (or a read-only
|
|
302
|
+
* spreadsheet scope), and `bitable:app` (or a read-only Base scope).
|
|
303
|
+
* - `wikiWrite` (default off): register `feishu_wiki_create`,
|
|
304
|
+
* `feishu_wiki_edit`, `feishu_wiki_sheet_edit`, and
|
|
305
|
+
* `feishu_wiki_base_edit` so the model can create docx documents,
|
|
306
|
+
* spreadsheets, and Bases under `wikiRoots`, edit documents and
|
|
307
|
+
* spreadsheets this app owns, and change records of Bases this app owns
|
|
308
|
+
* (other Bases accept new records only). Needs `wiki:wiki`,
|
|
309
|
+
* `docx:document`, `sheets:spreadsheet`, and `bitable:app` (or the
|
|
310
|
+
* matching write-only scopes).
|
|
299
311
|
*
|
|
300
312
|
* The wiki tools act with the app identity and are mounted only while
|
|
301
313
|
* `wikiRoots` is non-empty. Each call is limited to topics whose owner is
|
|
@@ -309,9 +321,9 @@ export const configSchema = z.object({
|
|
|
309
321
|
attachmentFetch: z.boolean().default(true),
|
|
310
322
|
attachmentFetchMaxResources: z.number().int().min(1).max(50)
|
|
311
323
|
.default(DEFAULT_ATTACHMENT_FETCH_MAX_RESOURCES),
|
|
312
|
-
attachmentFetchMaxBytes: z.number().int().min(1)
|
|
324
|
+
attachmentFetchMaxBytes: z.number().int().min(1)
|
|
313
325
|
.default(DEFAULT_ATTACHMENT_FETCH_MAX_BYTES),
|
|
314
|
-
attachmentFetchTimeoutMs: z.number().int().min(1_000).max(
|
|
326
|
+
attachmentFetchTimeoutMs: z.number().int().min(1_000).max(MAX_TIMER_DELAY_MS)
|
|
315
327
|
.default(DEFAULT_ATTACHMENT_FETCH_TIMEOUT_MS),
|
|
316
328
|
topicMessages: z.boolean().default(true),
|
|
317
329
|
topicMessagesTimeoutMs: z.number().int().min(1_000).max(120_000)
|
package/src/feishu/scopes.ts
CHANGED
|
@@ -91,16 +91,28 @@ const SEND_FILE_REQUIREMENT: ScopeRequirement = {
|
|
|
91
91
|
anyOf: ['im:resource', 'im:resource:upload'], level: 'optional', purpose: '上传图片/文件,让模型用 feishu_send_file 把生成的文件发给用户',
|
|
92
92
|
}
|
|
93
93
|
|
|
94
|
-
/**
|
|
94
|
+
/** Spreadsheet and Base read needs, shared by the wiki read and write sets. */
|
|
95
|
+
const SHEET_BASE_READ_REQUIREMENTS: readonly ScopeRequirement[] = [
|
|
96
|
+
{ anyOf: ['sheets:spreadsheet', 'sheets:spreadsheet:readonly', 'sheets:spreadsheet:read'], level: 'optional', purpose: '读取知识库电子表格(feishu_wiki_sheet_read)' },
|
|
97
|
+
{ anyOf: ['bitable:app', 'bitable:app:readonly', 'base:record:read'], level: 'optional', purpose: '读取知识库多维表格(feishu_wiki_base_read)' },
|
|
98
|
+
]
|
|
99
|
+
|
|
100
|
+
/** Wiki read needs (`feishu_wiki_list` / `feishu_wiki_read` and the sheet/base read tools). */
|
|
95
101
|
const WIKI_READ_REQUIREMENTS: readonly ScopeRequirement[] = [
|
|
96
102
|
{ anyOf: ['wiki:wiki', 'wiki:wiki:readonly'], level: 'optional', purpose: '浏览知识库节点(feishu_wiki_list / feishu_wiki_read)' },
|
|
97
103
|
{ anyOf: ['docx:document', 'docx:document:readonly'], level: 'optional', purpose: '读取知识库中的文档内容(feishu_wiki_read)' },
|
|
104
|
+
...SHEET_BASE_READ_REQUIREMENTS,
|
|
98
105
|
]
|
|
99
106
|
|
|
100
107
|
/** Wiki write needs (`feishu_wiki_create` / `feishu_wiki_edit`). */
|
|
101
108
|
const WIKI_WRITE_REQUIREMENTS: readonly ScopeRequirement[] = [
|
|
102
109
|
{ anyOf: ['wiki:wiki'], level: 'optional', purpose: '在知识库中新建节点(feishu_wiki_create)' },
|
|
103
110
|
{ anyOf: ['docx:document'], level: 'optional', purpose: '写入与修改知识库文档内容(feishu_wiki_create / feishu_wiki_edit)' },
|
|
111
|
+
{ anyOf: ['sheets:spreadsheet', 'sheets:spreadsheet:write_only'], level: 'optional', purpose: '写入知识库电子表格(feishu_wiki_sheet_edit)' },
|
|
112
|
+
{ anyOf: ['bitable:app', 'base:record:create'], level: 'optional', purpose: '新增知识库多维表格记录(feishu_wiki_base_edit)' },
|
|
113
|
+
{ anyOf: ['bitable:app', 'base:record:update'], level: 'optional', purpose: '修改本应用创建的多维表格中的记录(feishu_wiki_base_edit)' },
|
|
114
|
+
{ anyOf: ['bitable:app', 'base:record:delete'], level: 'optional', purpose: '删除本应用创建的多维表格中的记录(feishu_wiki_base_edit)' },
|
|
115
|
+
...SHEET_BASE_READ_REQUIREMENTS,
|
|
104
116
|
]
|
|
105
117
|
|
|
106
118
|
/** Group receive needs while ordinary group messages require an @ mention. */
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A1 notation helpers for the wiki sheet tools: column letters, single cells,
|
|
3
|
+
* and rectangle end rows. Ranges never carry a sheet prefix (`Sheet1!`); the
|
|
4
|
+
* sub-sheet is selected separately.
|
|
5
|
+
*
|
|
6
|
+
* @module dsh-channels/feishu/wiki/a1
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
/** Largest column the helpers accept (`ZZZ`). */
|
|
10
|
+
const MAX_COLUMN = 18_278
|
|
11
|
+
|
|
12
|
+
/** Largest row the helpers accept. */
|
|
13
|
+
const MAX_ROW = 10_000_000
|
|
14
|
+
|
|
15
|
+
/** One cell position, 1-based. */
|
|
16
|
+
export interface A1Cell {
|
|
17
|
+
readonly column: number
|
|
18
|
+
readonly row: number
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Column letters to a 1-based index (`A` → 1, `AA` → 27).
|
|
23
|
+
*
|
|
24
|
+
* @param letters Column letters, any case.
|
|
25
|
+
* @returns The index, or undefined when invalid or beyond `ZZZ`.
|
|
26
|
+
*/
|
|
27
|
+
export function columnIndex(letters: string): number | undefined {
|
|
28
|
+
if (!/^[A-Za-z]{1,3}$/u.test(letters)) return undefined
|
|
29
|
+
let index = 0
|
|
30
|
+
for (const char of letters.toUpperCase()) index = index * 26 + (char.charCodeAt(0) - 64)
|
|
31
|
+
return index <= MAX_COLUMN ? index : undefined
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* A 1-based column index to letters (1 → `A`, 27 → `AA`).
|
|
36
|
+
*
|
|
37
|
+
* @param index Column index, at least 1.
|
|
38
|
+
* @returns The letters.
|
|
39
|
+
*/
|
|
40
|
+
export function columnLetters(index: number): string {
|
|
41
|
+
let letters = ''
|
|
42
|
+
let rest = index
|
|
43
|
+
while (rest > 0) {
|
|
44
|
+
const digit = (rest - 1) % 26
|
|
45
|
+
letters = String.fromCharCode(65 + digit) + letters
|
|
46
|
+
rest = Math.floor((rest - 1) / 26)
|
|
47
|
+
}
|
|
48
|
+
return letters
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Parse one cell (`B3`).
|
|
53
|
+
*
|
|
54
|
+
* @param value Cell reference.
|
|
55
|
+
* @returns The position, or undefined when it is not a single valid cell.
|
|
56
|
+
*/
|
|
57
|
+
export function parseCell(value: string): A1Cell | undefined {
|
|
58
|
+
const match = /^([A-Za-z]{1,3})([1-9]\d{0,7})$/u.exec(value.trim())
|
|
59
|
+
if (match === null) return undefined
|
|
60
|
+
const column = columnIndex(match[1] ?? '')
|
|
61
|
+
const row = Number(match[2])
|
|
62
|
+
return column === undefined || row > MAX_ROW ? undefined : { column, row }
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* The rectangle that starts at `start` and spans `rows` × `columns` cells.
|
|
67
|
+
*
|
|
68
|
+
* @param start Top-left cell.
|
|
69
|
+
* @param rows Row count, at least 1.
|
|
70
|
+
* @param columns Column count, at least 1.
|
|
71
|
+
* @returns The range (`B3:D10`).
|
|
72
|
+
*/
|
|
73
|
+
export function rectangle(start: A1Cell, rows: number, columns: number): string {
|
|
74
|
+
return `${columnLetters(start.column)}${start.row}:${columnLetters(start.column + columns - 1)}${start.row + rows - 1}`
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Last row of a range (`A1:B5` → 5, `C7` → 7); undefined for whole-column ranges (`A:C`).
|
|
79
|
+
*
|
|
80
|
+
* @param range A1 range.
|
|
81
|
+
* @returns The row, or undefined.
|
|
82
|
+
*/
|
|
83
|
+
export function rangeEndRow(range: string): number | undefined {
|
|
84
|
+
const end = range.split(':').at(-1) ?? ''
|
|
85
|
+
const match = /(\d+)$/u.exec(end.trim())
|
|
86
|
+
return match === null ? undefined : Number(match[1])
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Whether a range is plain A1 (`A1`, `A1:F30`, `A:C`, `3:5`) without a sheet prefix.
|
|
91
|
+
*
|
|
92
|
+
* @param range Candidate range.
|
|
93
|
+
* @returns True when acceptable.
|
|
94
|
+
*/
|
|
95
|
+
export function isA1Range(range: string): boolean {
|
|
96
|
+
return /^(?:[A-Za-z]{1,3}\d{0,8}|\d{1,8})(?::(?:[A-Za-z]{1,3}\d{0,8}|\d{1,8}))?$/u.test(range)
|
|
97
|
+
}
|
package/src/feishu/wiki/api.ts
CHANGED
|
@@ -123,13 +123,14 @@ export interface WikiApi {
|
|
|
123
123
|
*/
|
|
124
124
|
listChildren(spaceId: string, parentNodeToken: string | undefined, pageToken?: string): Promise<WikiNodePage>
|
|
125
125
|
/**
|
|
126
|
-
* Create one empty docx node.
|
|
126
|
+
* Create one empty docx, spreadsheet, or Base node.
|
|
127
127
|
* @param spaceId Wiki space id.
|
|
128
128
|
* @param parentNodeToken Parent node, or undefined for the space top level.
|
|
129
129
|
* @param title Node title.
|
|
130
|
+
* @param objType Object kind of the new node.
|
|
130
131
|
* @returns The new node.
|
|
131
132
|
*/
|
|
132
|
-
|
|
133
|
+
createNode(spaceId: string, parentNodeToken: string | undefined, title: string, objType: 'docx' | 'sheet' | 'bitable'): Promise<WikiNode>
|
|
133
134
|
/**
|
|
134
135
|
* Current revision of one docx document.
|
|
135
136
|
* @param documentId Docx document id (the node's `objToken`).
|
|
@@ -193,7 +194,15 @@ function thrownError(error: unknown): FeishuApiError {
|
|
|
193
194
|
return new FeishuApiError([code === undefined ? undefined : `code=${code}`, msg].filter(Boolean).join(' '), code)
|
|
194
195
|
}
|
|
195
196
|
|
|
196
|
-
|
|
197
|
+
/**
|
|
198
|
+
* One Feishu OpenAPI call: unwraps `data` and maps a transport error or a
|
|
199
|
+
* non-zero `code` to {@link FeishuApiError}.
|
|
200
|
+
*
|
|
201
|
+
* @param request The SDK's generic request.
|
|
202
|
+
* @param input Method, URL, query, and body.
|
|
203
|
+
* @returns The response `data`.
|
|
204
|
+
*/
|
|
205
|
+
export async function call<T>(request: WikiRawRequest, input: Parameters<WikiRawRequest>[0]): Promise<T> {
|
|
197
206
|
let response: FeishuEnvelope<T>
|
|
198
207
|
try {
|
|
199
208
|
response = await request<FeishuEnvelope<T>>(input)
|
|
@@ -226,7 +235,13 @@ function wikiNode(raw: RawNode | undefined, where: string): WikiNode {
|
|
|
226
235
|
}
|
|
227
236
|
}
|
|
228
237
|
|
|
229
|
-
|
|
238
|
+
/**
|
|
239
|
+
* Encode one URL path segment.
|
|
240
|
+
*
|
|
241
|
+
* @param value Raw segment.
|
|
242
|
+
* @returns The encoded segment.
|
|
243
|
+
*/
|
|
244
|
+
export function segment(value: string): string {
|
|
230
245
|
return encodeURIComponent(value)
|
|
231
246
|
}
|
|
232
247
|
|
|
@@ -265,13 +280,13 @@ export function createWikiApi(request: WikiRawRequest): WikiApi {
|
|
|
265
280
|
...(data.has_more === true && data.page_token !== undefined && data.page_token !== '' ? { pageToken: data.page_token } : {}),
|
|
266
281
|
}
|
|
267
282
|
},
|
|
268
|
-
async
|
|
283
|
+
async createNode(spaceId, parentNodeToken, title, objType) {
|
|
269
284
|
const url = `/open-apis/wiki/v2/spaces/${segment(spaceId)}/nodes`
|
|
270
285
|
const data = await call<{ node?: RawNode }>(request, {
|
|
271
286
|
method: 'POST',
|
|
272
287
|
url,
|
|
273
288
|
data: {
|
|
274
|
-
obj_type:
|
|
289
|
+
obj_type: objType,
|
|
275
290
|
node_type: 'origin',
|
|
276
291
|
title,
|
|
277
292
|
...(parentNodeToken === undefined ? {} : { parent_node_token: parentNodeToken }),
|
|
@@ -0,0 +1,267 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Feishu Base (多维表格) calls used by the wiki base tools, made with the app
|
|
3
|
+
* identity (tenant access token).
|
|
4
|
+
*
|
|
5
|
+
* Everything goes through `base/v3/bases/:base_token/...` (the legacy
|
|
6
|
+
* `bitable/v1` API is not used). Record reads return a column matrix that
|
|
7
|
+
* this module turns into one field map per record; record writes take flat
|
|
8
|
+
* maps keyed by field name or field id. Writes carry no precondition, are
|
|
9
|
+
* applied asynchronously (a read straight after a write may not show it), and
|
|
10
|
+
* must not run in parallel on one table.
|
|
11
|
+
*
|
|
12
|
+
* @module dsh-channels/feishu/wiki/base-api
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { FeishuApiError } from '../../feishu-channel.ts'
|
|
16
|
+
import { call, segment } from './api.ts'
|
|
17
|
+
import type { WikiRawRequest } from './api.ts'
|
|
18
|
+
|
|
19
|
+
/** Most records one batch write may carry (Feishu limit). */
|
|
20
|
+
export const BASE_BATCH_MAX_RECORDS = 200
|
|
21
|
+
|
|
22
|
+
/** One table of a base. */
|
|
23
|
+
export interface BaseTable {
|
|
24
|
+
readonly tableId: string
|
|
25
|
+
readonly name: string
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** One field (column) of a table, as Feishu describes it. */
|
|
29
|
+
export interface BaseField {
|
|
30
|
+
readonly fieldId: string
|
|
31
|
+
readonly name: string
|
|
32
|
+
/** `text`, `number`, `select`, `datetime`, `user`, `link`, `checkbox`, … */
|
|
33
|
+
readonly type: string
|
|
34
|
+
/** Remaining Feishu field properties (style, options, multiple, …), passed through. */
|
|
35
|
+
readonly properties: Readonly<Record<string, unknown>>
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** One cell value as Feishu reads or writes it (JSON). */
|
|
39
|
+
export type BaseCellValue = unknown
|
|
40
|
+
|
|
41
|
+
/** One record: its id and a map from field name to value. */
|
|
42
|
+
export interface BaseRecord {
|
|
43
|
+
readonly recordId: string
|
|
44
|
+
readonly fields: Readonly<Record<string, BaseCellValue>>
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Filter, sort, projection, and paging of a record query. */
|
|
48
|
+
export interface BaseRecordQuery {
|
|
49
|
+
/** `{logic, conditions: [[field, operator, value?], …]}`. */
|
|
50
|
+
readonly filter?: object
|
|
51
|
+
/** `[{field, desc}]`. */
|
|
52
|
+
readonly sort?: readonly object[]
|
|
53
|
+
/** Field names or ids to return; all view-visible fields when absent. */
|
|
54
|
+
readonly fields?: readonly string[]
|
|
55
|
+
readonly viewId?: string
|
|
56
|
+
readonly offset: number
|
|
57
|
+
readonly limit: number
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** A keyword search over some fields. */
|
|
61
|
+
export interface BaseRecordSearch extends BaseRecordQuery {
|
|
62
|
+
readonly keyword: string
|
|
63
|
+
readonly searchFields: readonly string[]
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** One page of records. */
|
|
67
|
+
export interface BaseRecordPage {
|
|
68
|
+
readonly records: readonly BaseRecord[]
|
|
69
|
+
readonly hasMore: boolean
|
|
70
|
+
/** Table data snapshot version; changes when the data changes. */
|
|
71
|
+
readonly rev?: number
|
|
72
|
+
readonly timezone?: string
|
|
73
|
+
/** Fields Feishu skipped, with its reason. */
|
|
74
|
+
readonly ignoredFields: readonly string[]
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** Field values of one record write, keyed by field name or id. */
|
|
78
|
+
export type BaseRecordFields = Readonly<Record<string, BaseCellValue>>
|
|
79
|
+
|
|
80
|
+
/** The `base/v3` endpoints the wiki base tools consume. */
|
|
81
|
+
export interface BaseApi {
|
|
82
|
+
/**
|
|
83
|
+
* Tables of one base.
|
|
84
|
+
* @param baseToken Base token (the wiki node's `objToken`).
|
|
85
|
+
* @returns The tables.
|
|
86
|
+
*/
|
|
87
|
+
listTables(baseToken: string): Promise<readonly BaseTable[]>
|
|
88
|
+
/**
|
|
89
|
+
* Fields of one table.
|
|
90
|
+
* @param baseToken Base token.
|
|
91
|
+
* @param tableId Table id.
|
|
92
|
+
* @returns The fields.
|
|
93
|
+
*/
|
|
94
|
+
listFields(baseToken: string, tableId: string): Promise<readonly BaseField[]>
|
|
95
|
+
/**
|
|
96
|
+
* One page of records, filtered and sorted.
|
|
97
|
+
* @param baseToken Base token.
|
|
98
|
+
* @param tableId Table id.
|
|
99
|
+
* @param query Filter, sort, projection, paging.
|
|
100
|
+
* @returns The page.
|
|
101
|
+
*/
|
|
102
|
+
listRecords(baseToken: string, tableId: string, query: BaseRecordQuery): Promise<BaseRecordPage>
|
|
103
|
+
/**
|
|
104
|
+
* One page of records matching a keyword.
|
|
105
|
+
* @param baseToken Base token.
|
|
106
|
+
* @param tableId Table id.
|
|
107
|
+
* @param search Keyword, searched fields, and query options.
|
|
108
|
+
* @returns The page.
|
|
109
|
+
*/
|
|
110
|
+
searchRecords(baseToken: string, tableId: string, search: BaseRecordSearch): Promise<BaseRecordPage>
|
|
111
|
+
/**
|
|
112
|
+
* Create records (at most {@link BASE_BATCH_MAX_RECORDS}).
|
|
113
|
+
* @param baseToken Base token.
|
|
114
|
+
* @param tableId Table id.
|
|
115
|
+
* @param records Field maps.
|
|
116
|
+
* @returns The new record ids, in input order.
|
|
117
|
+
*/
|
|
118
|
+
createRecords(baseToken: string, tableId: string, records: readonly BaseRecordFields[]): Promise<readonly string[]>
|
|
119
|
+
/**
|
|
120
|
+
* Update records (at most {@link BASE_BATCH_MAX_RECORDS}); only the given fields change.
|
|
121
|
+
* @param baseToken Base token.
|
|
122
|
+
* @param tableId Table id.
|
|
123
|
+
* @param updates Record id → changed fields.
|
|
124
|
+
* @returns Fields Feishu ignored (read-only fields), with its reason.
|
|
125
|
+
*/
|
|
126
|
+
updateRecords(baseToken: string, tableId: string, updates: Readonly<Record<string, BaseRecordFields>>): Promise<readonly string[]>
|
|
127
|
+
/**
|
|
128
|
+
* Delete records (at most {@link BASE_BATCH_MAX_RECORDS}).
|
|
129
|
+
* @param baseToken Base token.
|
|
130
|
+
* @param tableId Table id.
|
|
131
|
+
* @param recordIds Records to delete.
|
|
132
|
+
*/
|
|
133
|
+
deleteRecords(baseToken: string, tableId: string, recordIds: readonly string[]): Promise<void>
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
interface RawMatrix {
|
|
137
|
+
fields?: unknown
|
|
138
|
+
record_id_list?: unknown
|
|
139
|
+
data?: unknown
|
|
140
|
+
has_more?: unknown
|
|
141
|
+
rev?: unknown
|
|
142
|
+
timezone?: unknown
|
|
143
|
+
ignored_fields?: unknown
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function stringArray(value: unknown): string[] | undefined {
|
|
147
|
+
return Array.isArray(value) && value.every(item => typeof item === 'string') ? value as string[] : undefined
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function ignoredFieldsOf(value: unknown): string[] {
|
|
151
|
+
if (!Array.isArray(value)) return []
|
|
152
|
+
return value.flatMap((item) => {
|
|
153
|
+
if (typeof item !== 'object' || item === null) return []
|
|
154
|
+
const { name, id, reason } = item as { name?: unknown; id?: unknown; reason?: unknown }
|
|
155
|
+
const label = typeof name === 'string' && name !== '' ? name : typeof id === 'string' ? id : '?'
|
|
156
|
+
return [typeof reason === 'string' && reason !== '' ? `${label}: ${reason}` : label]
|
|
157
|
+
})
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
function recordPage(raw: RawMatrix, where: string): BaseRecordPage {
|
|
161
|
+
const names = stringArray(raw.fields) ?? []
|
|
162
|
+
const ids = stringArray(raw.record_id_list) ?? []
|
|
163
|
+
const rows = Array.isArray(raw.data) ? raw.data : []
|
|
164
|
+
if (rows.length !== ids.length) throw new FeishuApiError(`${where}: record ids and rows differ in length`)
|
|
165
|
+
const records = ids.map((recordId, index) => {
|
|
166
|
+
const row: unknown = rows[index]
|
|
167
|
+
if (!Array.isArray(row) || row.length !== names.length) throw new FeishuApiError(`${where}: row ${index} does not match the field list`)
|
|
168
|
+
return { recordId, fields: Object.fromEntries(names.map((name, column) => [name, row[column] ?? null])) }
|
|
169
|
+
})
|
|
170
|
+
return {
|
|
171
|
+
records,
|
|
172
|
+
hasMore: raw.has_more === true,
|
|
173
|
+
...(typeof raw.rev === 'number' ? { rev: raw.rev } : {}),
|
|
174
|
+
...(typeof raw.timezone === 'string' && raw.timezone !== '' ? { timezone: raw.timezone } : {}),
|
|
175
|
+
ignoredFields: ignoredFieldsOf(raw.ignored_fields),
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* {@link BaseApi} over the SDK's generic authenticated request.
|
|
181
|
+
*
|
|
182
|
+
* @param request `Client.request` of the connected SDK client.
|
|
183
|
+
* @returns The API.
|
|
184
|
+
*/
|
|
185
|
+
export function createBaseApi(request: WikiRawRequest): BaseApi {
|
|
186
|
+
const tablePath = (baseToken: string, tableId: string): string =>
|
|
187
|
+
`/open-apis/base/v3/bases/${segment(baseToken)}/tables/${segment(tableId)}`
|
|
188
|
+
return {
|
|
189
|
+
async listTables(baseToken) {
|
|
190
|
+
const data = await call<{ tables?: unknown; items?: unknown }>(request, {
|
|
191
|
+
method: 'GET',
|
|
192
|
+
url: `/open-apis/base/v3/bases/${segment(baseToken)}/tables`,
|
|
193
|
+
params: { offset: 0, limit: 300 },
|
|
194
|
+
})
|
|
195
|
+
const raw = Array.isArray(data.tables) ? data.tables : Array.isArray(data.items) ? data.items : []
|
|
196
|
+
return raw.flatMap((item: unknown) => {
|
|
197
|
+
if (typeof item !== 'object' || item === null) return []
|
|
198
|
+
const { id, table_id, name, table_name } = item as Record<string, unknown>
|
|
199
|
+
const tableId = typeof id === 'string' ? id : typeof table_id === 'string' ? table_id : undefined
|
|
200
|
+
if (tableId === undefined) return []
|
|
201
|
+
return [{ tableId, name: typeof name === 'string' ? name : typeof table_name === 'string' ? table_name : '' }]
|
|
202
|
+
})
|
|
203
|
+
},
|
|
204
|
+
async listFields(baseToken, tableId) {
|
|
205
|
+
const data = await call<{ fields?: unknown; items?: unknown }>(request, {
|
|
206
|
+
method: 'GET',
|
|
207
|
+
url: `${tablePath(baseToken, tableId)}/fields`,
|
|
208
|
+
params: { offset: 0, limit: 300 },
|
|
209
|
+
})
|
|
210
|
+
const raw = Array.isArray(data.fields) ? data.fields : Array.isArray(data.items) ? data.items : []
|
|
211
|
+
return raw.flatMap((item: unknown) => {
|
|
212
|
+
if (typeof item !== 'object' || item === null) return []
|
|
213
|
+
const { id, field_id, name, field_name, type, ...properties } = item as Record<string, unknown>
|
|
214
|
+
const fieldId = typeof id === 'string' ? id : typeof field_id === 'string' ? field_id : undefined
|
|
215
|
+
if (fieldId === undefined) return []
|
|
216
|
+
return [{
|
|
217
|
+
fieldId,
|
|
218
|
+
name: typeof name === 'string' ? name : typeof field_name === 'string' ? field_name : '',
|
|
219
|
+
type: typeof type === 'string' ? type : 'not_support',
|
|
220
|
+
properties,
|
|
221
|
+
}]
|
|
222
|
+
})
|
|
223
|
+
},
|
|
224
|
+
async listRecords(baseToken, tableId, query) {
|
|
225
|
+
const url = `${tablePath(baseToken, tableId)}/records`
|
|
226
|
+
const params = new URLSearchParams({ offset: String(query.offset), limit: String(query.limit) })
|
|
227
|
+
for (const field of query.fields ?? []) params.append('field_id', field)
|
|
228
|
+
if (query.viewId !== undefined) params.set('view_id', query.viewId)
|
|
229
|
+
if (query.filter !== undefined) params.set('filter', JSON.stringify(query.filter))
|
|
230
|
+
if (query.sort !== undefined) params.set('sort', JSON.stringify(query.sort))
|
|
231
|
+
const data = await call<RawMatrix>(request, { method: 'GET', url: `${url}?${params.toString()}` })
|
|
232
|
+
return recordPage(data, url)
|
|
233
|
+
},
|
|
234
|
+
async searchRecords(baseToken, tableId, search) {
|
|
235
|
+
const url = `${tablePath(baseToken, tableId)}/records/search`
|
|
236
|
+
const data = await call<RawMatrix>(request, {
|
|
237
|
+
method: 'POST',
|
|
238
|
+
url,
|
|
239
|
+
data: {
|
|
240
|
+
keyword: search.keyword,
|
|
241
|
+
search_fields: search.searchFields,
|
|
242
|
+
...(search.fields === undefined ? {} : { select_fields: search.fields }),
|
|
243
|
+
...(search.viewId === undefined ? {} : { view_id: search.viewId }),
|
|
244
|
+
...(search.filter === undefined ? {} : { filter: search.filter }),
|
|
245
|
+
...(search.sort === undefined ? {} : { sort: search.sort }),
|
|
246
|
+
offset: search.offset,
|
|
247
|
+
limit: search.limit,
|
|
248
|
+
},
|
|
249
|
+
})
|
|
250
|
+
return recordPage(data, url)
|
|
251
|
+
},
|
|
252
|
+
async createRecords(baseToken, tableId, records) {
|
|
253
|
+
const url = `${tablePath(baseToken, tableId)}/records/batch_create`
|
|
254
|
+
const data = await call<{ record_id_list?: unknown }>(request, { method: 'POST', url, data: { create_records: records } })
|
|
255
|
+
return stringArray(data.record_id_list) ?? []
|
|
256
|
+
},
|
|
257
|
+
async updateRecords(baseToken, tableId, updates) {
|
|
258
|
+
const url = `${tablePath(baseToken, tableId)}/records/batch_update`
|
|
259
|
+
const data = await call<{ ignored_fields?: unknown }>(request, { method: 'POST', url, data: { update_records: updates } })
|
|
260
|
+
return ignoredFieldsOf(data.ignored_fields)
|
|
261
|
+
},
|
|
262
|
+
async deleteRecords(baseToken, tableId, recordIds) {
|
|
263
|
+
const url = `${tablePath(baseToken, tableId)}/records/batch_delete`
|
|
264
|
+
await call<unknown>(request, { method: 'POST', url, data: { record_id_list: recordIds } })
|
|
265
|
+
},
|
|
266
|
+
}
|
|
267
|
+
}
|