@modesto_ma/dsh-channels 0.5.0-beta.2 → 0.5.0-beta.4

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.
Files changed (46) hide show
  1. package/README.md +72 -15
  2. package/docs/attachment-pipeline-v2.md +2 -2
  3. package/docs/feishu-onboarding.md +4 -3
  4. package/lib/client.js +369 -94
  5. package/lib/client.js.map +1 -1
  6. package/lib/index.js +7847 -5293
  7. package/lib/index.js.map +1 -1
  8. package/package.json +1 -1
  9. package/src/access/controller.ts +32 -1
  10. package/src/analyze/contract.ts +121 -0
  11. package/src/analyze/launcher.ts +353 -0
  12. package/src/analyze/server.ts +201 -0
  13. package/src/cards/analyze.ts +116 -0
  14. package/src/cards/index.ts +1 -0
  15. package/src/cards/streaming.ts +58 -0
  16. package/src/client/api.ts +15 -0
  17. package/src/client/panel/access-card.tsx +96 -70
  18. package/src/client/panel/analyze-token-card.tsx +70 -0
  19. package/src/client/panel/index.tsx +72 -10
  20. package/src/client/panel/sessions-tab.tsx +7 -4
  21. package/src/client/panel/shared.tsx +12 -0
  22. package/src/client/panel/wiki-tab.tsx +62 -0
  23. package/src/config.ts +52 -14
  24. package/src/domain.ts +55 -0
  25. package/src/feishu/scopes.ts +19 -1
  26. package/src/feishu/tools.ts +3 -1
  27. package/src/feishu/wiki/a1.ts +97 -0
  28. package/src/feishu/wiki/api.ts +21 -6
  29. package/src/feishu/wiki/base-api.ts +267 -0
  30. package/src/feishu/wiki/base-tools.ts +423 -0
  31. package/src/feishu/wiki/shared.ts +284 -0
  32. package/src/feishu/wiki/sheet-api.ts +250 -0
  33. package/src/feishu/wiki/sheet-tools.ts +493 -0
  34. package/src/feishu/wiki/tools.ts +102 -189
  35. package/src/feishu-channel.ts +14 -1
  36. package/src/health.ts +12 -0
  37. package/src/host/agent.ts +5 -1
  38. package/src/index.ts +1 -1
  39. package/src/interactions/pending.ts +11 -2
  40. package/src/routing/inbound.ts +57 -14
  41. package/src/service.ts +161 -28
  42. package/src/settings/contract.ts +37 -6
  43. package/src/settings/routes.ts +66 -14
  44. package/src/settings/surface.ts +69 -20
  45. package/src/streamer.ts +156 -7
  46. package/src/workspaces.ts +31 -0
@@ -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
+ }