@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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@modesto_ma/dsh-channels",
3
- "version": "0.5.0-beta.2",
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 library, so the
269
- * limit is enforced while streaming rather than buffered first.
270
- * - `attachmentFetchTimeoutMs`: whole-call timeout (default 120 s). On expiry
271
- * the in-flight download is cancelled and reported per attachment.
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` and
292
- * `feishu_wiki_read` so the model can browse the wiki nodes under
293
- * `wikiRoots` and read docx documents there. Needs `wiki:wiki` (or
294
- * `wiki:wiki:readonly`) and `docx:document` (or `docx:document:readonly`).
295
- * - `wikiWrite` (default off): register `feishu_wiki_create` and
296
- * `feishu_wiki_edit` so the model can create docx documents under
297
- * `wikiRoots` and edit documents this app owns. Needs `wiki:wiki` and
298
- * `docx:document`.
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).max(1024 * 1024 * 1024)
324
+ attachmentFetchMaxBytes: z.number().int().min(1)
313
325
  .default(DEFAULT_ATTACHMENT_FETCH_MAX_BYTES),
314
- attachmentFetchTimeoutMs: z.number().int().min(1_000).max(600_000)
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)
@@ -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
- /** Wiki read needs (`feishu_wiki_list` / `feishu_wiki_read`). */
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
+ }
@@ -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
- createDocxNode(spaceId: string, parentNodeToken: string | undefined, title: string): Promise<WikiNode>
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
- async function call<T>(request: WikiRawRequest, input: Parameters<WikiRawRequest>[0]): Promise<T> {
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
- function segment(value: string): string {
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 createDocxNode(spaceId, parentNodeToken, title) {
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: 'docx',
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
+ }