@yuuko1410/feishu-bitable 0.0.3 → 0.0.5
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 +100 -0
- package/lib/auth.d.ts +20 -0
- package/lib/client.d.ts +1 -0
- package/lib/index.cjs +278 -50
- package/lib/index.d.ts +3 -1
- package/lib/index.js +281 -53
- package/lib/types.d.ts +41 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -17,6 +17,7 @@
|
|
|
17
17
|
- 支持小文件直接上传
|
|
18
18
|
- 支持大文件自动切换分片上传
|
|
19
19
|
- 支持文件下载
|
|
20
|
+
- 支持 H5 OAuth 登录(生成登录链接、code 换 token、获取用户信息)
|
|
20
21
|
- 支持字段值归一化
|
|
21
22
|
- 支持批量请求自动分片
|
|
22
23
|
- 支持并发控制
|
|
@@ -33,11 +34,13 @@ packages/feishu-bitable/
|
|
|
33
34
|
├── tsconfig.build.json
|
|
34
35
|
├── src/
|
|
35
36
|
│ ├── client.ts
|
|
37
|
+
│ ├── auth.ts
|
|
36
38
|
│ ├── errors.ts
|
|
37
39
|
│ ├── index.ts
|
|
38
40
|
│ ├── types.ts
|
|
39
41
|
│ └── utils.ts
|
|
40
42
|
├── test/
|
|
43
|
+
│ ├── auth.test.ts
|
|
41
44
|
│ └── bitable.test.ts
|
|
42
45
|
└── lib/ # bun run build 后生成
|
|
43
46
|
```
|
|
@@ -81,6 +84,7 @@ npm install @yuuko1410/feishu-bitable
|
|
|
81
84
|
主导出:
|
|
82
85
|
|
|
83
86
|
- `Bitable`
|
|
87
|
+
- `FeishuOAuthClient`
|
|
84
88
|
- `FeishuBitableError`
|
|
85
89
|
- `AppType`
|
|
86
90
|
- `Domain`
|
|
@@ -89,6 +93,11 @@ npm install @yuuko1410/feishu-bitable
|
|
|
89
93
|
类型导出:
|
|
90
94
|
|
|
91
95
|
- `BitableConstructorOptions`
|
|
96
|
+
- `FeishuOAuthConstructorOptions`
|
|
97
|
+
- `BuildOAuthLoginUrlOptions`
|
|
98
|
+
- `OAuthTokenInfo`
|
|
99
|
+
- `OAuthUserInfo`
|
|
100
|
+
- `OAuthCallbackResult`
|
|
92
101
|
- `FetchAllRecordsOptions`
|
|
93
102
|
- `BatchOperationOptions`
|
|
94
103
|
- `UpdateRecordsOptions`
|
|
@@ -141,6 +150,7 @@ const bitable = Bitable.fromEnv();
|
|
|
141
150
|
- `FEISHU_APP_ID`
|
|
142
151
|
- `FEISHU_APP_SECRET`
|
|
143
152
|
- `FEISHU_APP_TOKEN`
|
|
153
|
+
- `FEISHU_OAUTH_REDIRECT_URI`(OAuth 登录回调地址)
|
|
144
154
|
|
|
145
155
|
## 构造参数说明
|
|
146
156
|
|
|
@@ -156,6 +166,15 @@ const bitable = Bitable.fromEnv();
|
|
|
156
166
|
- `defaultConcurrency`:默认并发数,默认 `1`
|
|
157
167
|
- `sdkClient`:可注入已创建的官方 SDK client,便于测试或复用
|
|
158
168
|
|
|
169
|
+
`FeishuOAuthConstructorOptions` 支持:
|
|
170
|
+
|
|
171
|
+
- `appId`:飞书应用 `app_id`
|
|
172
|
+
- `appSecret`:飞书应用 `app_secret`
|
|
173
|
+
- `redirectUri`:OAuth 回调地址
|
|
174
|
+
- `domain`:飞书 SDK `Domain`
|
|
175
|
+
- `sdkClient`:可注入官方 SDK client
|
|
176
|
+
- `logger`:可注入日志器;传 `null` 可禁用日志
|
|
177
|
+
|
|
159
178
|
## 公开 API
|
|
160
179
|
|
|
161
180
|
### 1. `fetchAllRecords`
|
|
@@ -352,6 +371,84 @@ const buffer = await bitable.downloadFile("file_token");
|
|
|
352
371
|
const buffer = await bitable.downLoadFile("file_token");
|
|
353
372
|
```
|
|
354
373
|
|
|
374
|
+
### 9. `FeishuOAuthClient.buildLoginUrl`
|
|
375
|
+
|
|
376
|
+
生成 H5 飞书登录链接。
|
|
377
|
+
|
|
378
|
+
```ts
|
|
379
|
+
import { FeishuOAuthClient } from "@yuuko1410/feishu-bitable";
|
|
380
|
+
|
|
381
|
+
const oauth = FeishuOAuthClient.fromEnv();
|
|
382
|
+
const loginUrl = oauth.buildLoginUrl({
|
|
383
|
+
state: "csrf_state_123",
|
|
384
|
+
scope: ["contact:user.base:readonly"],
|
|
385
|
+
});
|
|
386
|
+
```
|
|
387
|
+
|
|
388
|
+
### 10. `FeishuOAuthClient.exchangeCodeForUserToken`
|
|
389
|
+
|
|
390
|
+
后端使用回调 `code` 换取 `user_access_token`。
|
|
391
|
+
|
|
392
|
+
```ts
|
|
393
|
+
const token = await oauth.exchangeCodeForUserToken(code);
|
|
394
|
+
```
|
|
395
|
+
|
|
396
|
+
### 11. `FeishuOAuthClient.getUserInfo`
|
|
397
|
+
|
|
398
|
+
使用 `user_access_token` 拉取飞书用户信息。
|
|
399
|
+
|
|
400
|
+
```ts
|
|
401
|
+
const user = await oauth.getUserInfo(token.access_token);
|
|
402
|
+
```
|
|
403
|
+
|
|
404
|
+
### 12. `FeishuOAuthClient.handleCallback`
|
|
405
|
+
|
|
406
|
+
最简回调处理:一键完成 `code -> token -> user`。
|
|
407
|
+
|
|
408
|
+
```ts
|
|
409
|
+
const result = await oauth.handleCallback(code);
|
|
410
|
+
// result.token
|
|
411
|
+
// result.user
|
|
412
|
+
```
|
|
413
|
+
|
|
414
|
+
## H5 登录最简接入
|
|
415
|
+
|
|
416
|
+
前端按钮跳转:
|
|
417
|
+
|
|
418
|
+
```ts
|
|
419
|
+
window.location.href = "/api/auth/feishu/login";
|
|
420
|
+
```
|
|
421
|
+
|
|
422
|
+
后端示例(Express/SvelteKit 思路一致):
|
|
423
|
+
|
|
424
|
+
```ts
|
|
425
|
+
import { FeishuOAuthClient } from "@yuuko1410/feishu-bitable";
|
|
426
|
+
|
|
427
|
+
const oauth = FeishuOAuthClient.fromEnv();
|
|
428
|
+
|
|
429
|
+
export async function loginHandler() {
|
|
430
|
+
const state = crypto.randomUUID();
|
|
431
|
+
const loginUrl = oauth.buildLoginUrl({
|
|
432
|
+
state,
|
|
433
|
+
scope: ["contact:user.base:readonly"],
|
|
434
|
+
});
|
|
435
|
+
|
|
436
|
+
return Response.redirect(loginUrl, 302);
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
export async function callbackHandler(code: string) {
|
|
440
|
+
const { token, user } = await oauth.handleCallback(code);
|
|
441
|
+
|
|
442
|
+
// 这里创建你自己的业务登录态(session/JWT)
|
|
443
|
+
// 不建议把 user_access_token 暴露给前端
|
|
444
|
+
return {
|
|
445
|
+
uid: user.open_id ?? user.user_id,
|
|
446
|
+
name: user.name,
|
|
447
|
+
tokenType: token.token_type,
|
|
448
|
+
};
|
|
449
|
+
}
|
|
450
|
+
```
|
|
451
|
+
|
|
355
452
|
## 自动行为说明
|
|
356
453
|
|
|
357
454
|
### 自动分页
|
|
@@ -449,6 +546,9 @@ await bitable.deleteList("table_id", ["recyyy"]);
|
|
|
449
546
|
- 数组式批量更新到官方 payload 的转换
|
|
450
547
|
- 官方批量更新 payload 透传
|
|
451
548
|
- 文件下载
|
|
549
|
+
- OAuth 登录链接生成
|
|
550
|
+
- OAuth code 换 token
|
|
551
|
+
- OAuth 回调获取用户信息
|
|
452
552
|
- 凭证缺失报错
|
|
453
553
|
|
|
454
554
|
## 发布
|
package/lib/auth.d.ts
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import * as lark from "@larksuiteoapi/node-sdk";
|
|
2
|
+
import type { BitableLogger, BuildOAuthLoginUrlOptions, FeishuOAuthConstructorOptions, OAuthCallbackResult, OAuthTokenInfo, OAuthUserInfo } from "./types";
|
|
3
|
+
type OAuthSdkClient = Pick<lark.Client, "authen">;
|
|
4
|
+
export declare class FeishuOAuthClient {
|
|
5
|
+
readonly appId: string;
|
|
6
|
+
readonly appSecret: string;
|
|
7
|
+
readonly redirectUri?: string;
|
|
8
|
+
readonly domain: lark.Domain | string;
|
|
9
|
+
readonly logger: BitableLogger | null;
|
|
10
|
+
readonly client: OAuthSdkClient;
|
|
11
|
+
constructor(options?: FeishuOAuthConstructorOptions);
|
|
12
|
+
static fromEnv(env?: NodeJS.ProcessEnv): FeishuOAuthClient;
|
|
13
|
+
buildLoginUrl(options: BuildOAuthLoginUrlOptions): string;
|
|
14
|
+
exchangeCodeForUserToken(code: string): Promise<OAuthTokenInfo>;
|
|
15
|
+
refreshUserAccessToken(refreshToken: string): Promise<OAuthTokenInfo>;
|
|
16
|
+
getUserInfo(userAccessToken: string): Promise<OAuthUserInfo>;
|
|
17
|
+
handleCallback(code: string): Promise<OAuthCallbackResult>;
|
|
18
|
+
private logInfo;
|
|
19
|
+
}
|
|
20
|
+
export {};
|
package/lib/client.d.ts
CHANGED
package/lib/index.cjs
CHANGED
|
@@ -65,6 +65,7 @@ var exports_src = {};
|
|
|
65
65
|
__export(exports_src, {
|
|
66
66
|
default: () => src_default,
|
|
67
67
|
LoggerLevel: () => import_node_sdk.LoggerLevel,
|
|
68
|
+
FeishuOAuthClient: () => FeishuOAuthClient,
|
|
68
69
|
FeishuBitableError: () => FeishuBitableError,
|
|
69
70
|
Domain: () => import_node_sdk.Domain,
|
|
70
71
|
Bitable: () => Bitable,
|
|
@@ -285,7 +286,7 @@ class Bitable {
|
|
|
285
286
|
}, async () => {
|
|
286
287
|
const token = this.resolveAppToken(appToken);
|
|
287
288
|
const pageSize = Math.max(1, Math.min(options.pageSize ?? FEISHU_BATCH_LIMIT, FEISHU_BATCH_LIMIT));
|
|
288
|
-
const
|
|
289
|
+
const request = {
|
|
289
290
|
path: {
|
|
290
291
|
app_token: token,
|
|
291
292
|
table_id: tableId
|
|
@@ -301,7 +302,8 @@ class Bitable {
|
|
|
301
302
|
sort: options.sort,
|
|
302
303
|
automatic_fields: options.automaticFields
|
|
303
304
|
}
|
|
304
|
-
}
|
|
305
|
+
};
|
|
306
|
+
const iterator = await this.client.bitable.v1.appTableRecord.searchWithIterator(this.logRequest("fetchAllRecords request", request));
|
|
305
307
|
const allRecords = [];
|
|
306
308
|
for await (const page of iterator) {
|
|
307
309
|
const items = page?.items ?? [];
|
|
@@ -326,12 +328,15 @@ class Bitable {
|
|
|
326
328
|
}
|
|
327
329
|
const token = this.resolveAppToken(options.appToken);
|
|
328
330
|
const chunks = chunkArray(records, options.chunkSize ?? FEISHU_BATCH_LIMIT);
|
|
329
|
-
const responses = await runWithConcurrency(chunks, options.concurrency ?? this.defaultConcurrency, async (chunk) => this.withRetry("insert records", async () =>
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
331
|
+
const responses = await runWithConcurrency(chunks, options.concurrency ?? this.defaultConcurrency, async (chunk) => this.withRetry("insert records", async () => {
|
|
332
|
+
const request = {
|
|
333
|
+
path: { app_token: token, table_id: tableId },
|
|
334
|
+
data: {
|
|
335
|
+
records: chunk.map((fields) => ({ fields }))
|
|
336
|
+
}
|
|
337
|
+
};
|
|
338
|
+
return assertFeishuResponse(await this.client.bitable.v1.appTableRecord.batchCreate(this.logRequest("insertList request", request)), "insert records");
|
|
339
|
+
}));
|
|
335
340
|
this.logInfo("insertList completed", {
|
|
336
341
|
tableId,
|
|
337
342
|
chunkCount: chunks.length
|
|
@@ -358,8 +363,18 @@ class Bitable {
|
|
|
358
363
|
const token = this.resolveAppToken(options.appToken);
|
|
359
364
|
const chunks = chunkArray(records, options.chunkSize ?? FEISHU_BATCH_LIMIT);
|
|
360
365
|
const responses = await runWithConcurrency(chunks, options.concurrency ?? this.defaultConcurrency, async (chunk, index) => {
|
|
361
|
-
const batchRecords = chunk.map((record) => {
|
|
366
|
+
const batchRecords = chunk.map((record, recordIndex) => {
|
|
362
367
|
const { recordId, fields } = splitUpdateRecord(record);
|
|
368
|
+
if (!recordId || !recordId.trim()) {
|
|
369
|
+
throw new FeishuBitableError(`updateRecords failed: record_id is required for chunk ${index}, item ${recordIndex}`, {
|
|
370
|
+
details: {
|
|
371
|
+
tableId,
|
|
372
|
+
chunkIndex: index,
|
|
373
|
+
recordIndex,
|
|
374
|
+
record
|
|
375
|
+
}
|
|
376
|
+
});
|
|
377
|
+
}
|
|
363
378
|
if (Object.keys(fields).length === 0) {
|
|
364
379
|
return null;
|
|
365
380
|
}
|
|
@@ -418,12 +433,15 @@ class Bitable {
|
|
|
418
433
|
}
|
|
419
434
|
const token = this.resolveAppToken(options.appToken);
|
|
420
435
|
const chunks = chunkArray(recordIds, options.chunkSize ?? FEISHU_BATCH_LIMIT);
|
|
421
|
-
const responses = await runWithConcurrency(chunks, options.concurrency ?? this.defaultConcurrency, async (chunk) => this.withRetry("delete records", async () =>
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
436
|
+
const responses = await runWithConcurrency(chunks, options.concurrency ?? this.defaultConcurrency, async (chunk) => this.withRetry("delete records", async () => {
|
|
437
|
+
const request = {
|
|
438
|
+
path: { app_token: token, table_id: tableId },
|
|
439
|
+
data: {
|
|
440
|
+
records: chunk
|
|
441
|
+
}
|
|
442
|
+
};
|
|
443
|
+
return assertFeishuResponse(await this.client.bitable.v1.appTableRecord.batchDelete(this.logRequest("deleteList request", request)), "delete records");
|
|
444
|
+
}));
|
|
427
445
|
this.logInfo("deleteList completed", {
|
|
428
446
|
tableId,
|
|
429
447
|
chunkCount: chunks.length
|
|
@@ -445,25 +463,37 @@ class Bitable {
|
|
|
445
463
|
mode: buffer.byteLength <= FEISHU_SIMPLE_UPLOAD_LIMIT ? "simple" : "multipart"
|
|
446
464
|
});
|
|
447
465
|
if (buffer.byteLength <= FEISHU_SIMPLE_UPLOAD_LIMIT) {
|
|
448
|
-
return await this.withRetry("upload file", async () =>
|
|
466
|
+
return await this.withRetry("upload file", async () => {
|
|
467
|
+
const request = {
|
|
468
|
+
data: {
|
|
469
|
+
file_name: fileName,
|
|
470
|
+
parent_type: options.parentType,
|
|
471
|
+
parent_node: options.parentNode,
|
|
472
|
+
size: buffer.byteLength,
|
|
473
|
+
extra: options.extra,
|
|
474
|
+
file: buffer
|
|
475
|
+
}
|
|
476
|
+
};
|
|
477
|
+
this.logRequest("uploadFile request", {
|
|
478
|
+
data: {
|
|
479
|
+
...request.data,
|
|
480
|
+
file: `[Buffer ${buffer.byteLength} bytes]`
|
|
481
|
+
}
|
|
482
|
+
});
|
|
483
|
+
return this.client.drive.v1.media.uploadAll(request);
|
|
484
|
+
}) ?? {};
|
|
485
|
+
}
|
|
486
|
+
const prepare = assertFeishuResponse(await this.withRetry("prepare multipart upload", async () => {
|
|
487
|
+
const request = {
|
|
449
488
|
data: {
|
|
450
489
|
file_name: fileName,
|
|
451
490
|
parent_type: options.parentType,
|
|
452
491
|
parent_node: options.parentNode,
|
|
453
|
-
size: buffer.byteLength
|
|
454
|
-
extra: options.extra,
|
|
455
|
-
file: buffer
|
|
492
|
+
size: buffer.byteLength
|
|
456
493
|
}
|
|
457
|
-
}
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
data: {
|
|
461
|
-
file_name: fileName,
|
|
462
|
-
parent_type: options.parentType,
|
|
463
|
-
parent_node: options.parentNode,
|
|
464
|
-
size: buffer.byteLength
|
|
465
|
-
}
|
|
466
|
-
})), "prepare multipart upload");
|
|
494
|
+
};
|
|
495
|
+
return this.client.drive.v1.media.uploadPrepare(this.logRequest("uploadPrepare request", request));
|
|
496
|
+
}), "prepare multipart upload");
|
|
467
497
|
const uploadId = prepare.data?.upload_id;
|
|
468
498
|
const blockSize = prepare.data?.block_size;
|
|
469
499
|
const blockNum = prepare.data?.block_num;
|
|
@@ -476,21 +506,33 @@ class Bitable {
|
|
|
476
506
|
const start = index * blockSize;
|
|
477
507
|
const end = Math.min(start + blockSize, buffer.byteLength);
|
|
478
508
|
const chunk = buffer.subarray(start, end);
|
|
479
|
-
await this.withRetry(`upload file chunk ${index + 1}/${blockNum}`, async () =>
|
|
509
|
+
await this.withRetry(`upload file chunk ${index + 1}/${blockNum}`, async () => {
|
|
510
|
+
const request = {
|
|
511
|
+
data: {
|
|
512
|
+
upload_id: uploadId,
|
|
513
|
+
seq: index,
|
|
514
|
+
size: chunk.byteLength,
|
|
515
|
+
file: chunk
|
|
516
|
+
}
|
|
517
|
+
};
|
|
518
|
+
this.logRequest("uploadPart request", {
|
|
519
|
+
data: {
|
|
520
|
+
...request.data,
|
|
521
|
+
file: `[Buffer ${chunk.byteLength} bytes]`
|
|
522
|
+
}
|
|
523
|
+
});
|
|
524
|
+
return this.client.drive.v1.media.uploadPart(request);
|
|
525
|
+
});
|
|
526
|
+
}
|
|
527
|
+
const finish = assertFeishuResponse(await this.withRetry("finish multipart upload", async () => {
|
|
528
|
+
const request = {
|
|
480
529
|
data: {
|
|
481
530
|
upload_id: uploadId,
|
|
482
|
-
|
|
483
|
-
size: chunk.byteLength,
|
|
484
|
-
file: chunk
|
|
531
|
+
block_num: blockNum
|
|
485
532
|
}
|
|
486
|
-
}
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
data: {
|
|
490
|
-
upload_id: uploadId,
|
|
491
|
-
block_num: blockNum
|
|
492
|
-
}
|
|
493
|
-
})), "finish multipart upload");
|
|
533
|
+
};
|
|
534
|
+
return this.client.drive.v1.media.uploadFinish(this.logRequest("uploadFinish request", request));
|
|
535
|
+
}), "finish multipart upload");
|
|
494
536
|
return {
|
|
495
537
|
file_token: finish.data?.file_token
|
|
496
538
|
};
|
|
@@ -501,14 +543,17 @@ class Bitable {
|
|
|
501
543
|
fileToken,
|
|
502
544
|
hasExtra: Boolean(extra)
|
|
503
545
|
}, async () => {
|
|
504
|
-
const response = await this.withRetry("download file", async () =>
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
546
|
+
const response = await this.withRetry("download file", async () => {
|
|
547
|
+
const request = {
|
|
548
|
+
path: {
|
|
549
|
+
file_token: fileToken
|
|
550
|
+
},
|
|
551
|
+
params: {
|
|
552
|
+
extra
|
|
553
|
+
}
|
|
554
|
+
};
|
|
555
|
+
return this.client.drive.v1.media.download(this.logRequest("downloadFile request", request));
|
|
556
|
+
});
|
|
512
557
|
const buffer = await readableToBuffer(response.getReadableStream());
|
|
513
558
|
this.logInfo("downloadFile completed", {
|
|
514
559
|
fileToken,
|
|
@@ -550,7 +595,7 @@ class Bitable {
|
|
|
550
595
|
return token;
|
|
551
596
|
}
|
|
552
597
|
async executeBatchUpdateRecords(payload) {
|
|
553
|
-
return this.withRetry("batch update records", async () => assertFeishuResponse(await this.client.bitable.v1.appTableRecord.batchUpdate(payload), "batch update records"));
|
|
598
|
+
return this.withRetry("batch update records", async () => assertFeishuResponse(await this.client.bitable.v1.appTableRecord.batchUpdate(this.logRequest("batchUpdateRecords request", payload)), "batch update records"));
|
|
554
599
|
}
|
|
555
600
|
async withRetry(label, task) {
|
|
556
601
|
let lastError;
|
|
@@ -623,6 +668,189 @@ class Bitable {
|
|
|
623
668
|
errorMessage: String(error)
|
|
624
669
|
};
|
|
625
670
|
}
|
|
671
|
+
logRequest(message, payload) {
|
|
672
|
+
this.logInfo(message, {
|
|
673
|
+
payload
|
|
674
|
+
});
|
|
675
|
+
return payload;
|
|
676
|
+
}
|
|
677
|
+
}
|
|
678
|
+
|
|
679
|
+
// src/auth.ts
|
|
680
|
+
var lark2 = __toESM(require("@larksuiteoapi/node-sdk"));
|
|
681
|
+
var DEFAULT_LOGGER2 = {
|
|
682
|
+
info(message, meta) {
|
|
683
|
+
if (meta) {
|
|
684
|
+
console.info(`[feishu-bitable] ${message}`, meta);
|
|
685
|
+
return;
|
|
686
|
+
}
|
|
687
|
+
console.info(`[feishu-bitable] ${message}`);
|
|
688
|
+
},
|
|
689
|
+
warn(message, meta) {
|
|
690
|
+
if (meta) {
|
|
691
|
+
console.warn(`[feishu-bitable] ${message}`, meta);
|
|
692
|
+
return;
|
|
693
|
+
}
|
|
694
|
+
console.warn(`[feishu-bitable] ${message}`);
|
|
695
|
+
},
|
|
696
|
+
error(message, meta) {
|
|
697
|
+
if (meta) {
|
|
698
|
+
console.error(`[feishu-bitable] ${message}`, meta);
|
|
699
|
+
return;
|
|
700
|
+
}
|
|
701
|
+
console.error(`[feishu-bitable] ${message}`);
|
|
702
|
+
}
|
|
703
|
+
};
|
|
704
|
+
|
|
705
|
+
class FeishuOAuthClient {
|
|
706
|
+
appId;
|
|
707
|
+
appSecret;
|
|
708
|
+
redirectUri;
|
|
709
|
+
domain;
|
|
710
|
+
logger;
|
|
711
|
+
client;
|
|
712
|
+
constructor(options = {}) {
|
|
713
|
+
const appId = options.appId ?? process.env.FEISHU_APP_ID;
|
|
714
|
+
const appSecret = options.appSecret ?? process.env.FEISHU_APP_SECRET;
|
|
715
|
+
if (!appId || !appSecret) {
|
|
716
|
+
throw new FeishuBitableError("appId and appSecret are required. Pass them in constructor or provide FEISHU_APP_ID and FEISHU_APP_SECRET.");
|
|
717
|
+
}
|
|
718
|
+
this.appId = appId;
|
|
719
|
+
this.appSecret = appSecret;
|
|
720
|
+
this.redirectUri = options.redirectUri;
|
|
721
|
+
this.domain = options.domain ?? lark2.Domain.Feishu;
|
|
722
|
+
this.logger = options.logger === null ? null : options.logger ?? DEFAULT_LOGGER2;
|
|
723
|
+
this.client = options.sdkClient ?? new lark2.Client({
|
|
724
|
+
appId: this.appId,
|
|
725
|
+
appSecret: this.appSecret,
|
|
726
|
+
appType: lark2.AppType.SelfBuild,
|
|
727
|
+
domain: this.domain
|
|
728
|
+
});
|
|
729
|
+
}
|
|
730
|
+
static fromEnv(env = process.env) {
|
|
731
|
+
return new FeishuOAuthClient({
|
|
732
|
+
appId: env.FEISHU_APP_ID,
|
|
733
|
+
appSecret: env.FEISHU_APP_SECRET,
|
|
734
|
+
redirectUri: env.FEISHU_OAUTH_REDIRECT_URI
|
|
735
|
+
});
|
|
736
|
+
}
|
|
737
|
+
buildLoginUrl(options) {
|
|
738
|
+
const redirectUri = options.redirectUri ?? this.redirectUri;
|
|
739
|
+
if (!redirectUri) {
|
|
740
|
+
throw new FeishuBitableError("redirectUri is required. Pass it into constructor or buildLoginUrl options.");
|
|
741
|
+
}
|
|
742
|
+
const scope = Array.isArray(options.scope) ? options.scope.join(" ") : options.scope;
|
|
743
|
+
const url = new URL("/open-apis/authen/v1/authorize", resolveOpenDomain(this.domain));
|
|
744
|
+
url.searchParams.set("app_id", this.appId);
|
|
745
|
+
url.searchParams.set("redirect_uri", redirectUri);
|
|
746
|
+
url.searchParams.set("state", options.state);
|
|
747
|
+
if (scope?.trim()) {
|
|
748
|
+
url.searchParams.set("scope", scope.trim());
|
|
749
|
+
}
|
|
750
|
+
this.logInfo("oauth buildLoginUrl", {
|
|
751
|
+
redirectUri,
|
|
752
|
+
hasScope: Boolean(scope),
|
|
753
|
+
state: options.state
|
|
754
|
+
});
|
|
755
|
+
return url.toString();
|
|
756
|
+
}
|
|
757
|
+
async exchangeCodeForUserToken(code) {
|
|
758
|
+
if (!code.trim()) {
|
|
759
|
+
throw new FeishuBitableError("code is required to exchange user token.");
|
|
760
|
+
}
|
|
761
|
+
this.logInfo("oauth exchangeCodeForUserToken started");
|
|
762
|
+
const response = assertFeishuResponse(await this.client.authen.v1.oidcAccessToken.create({
|
|
763
|
+
data: {
|
|
764
|
+
grant_type: "authorization_code",
|
|
765
|
+
code
|
|
766
|
+
}
|
|
767
|
+
}), "oauth exchange code");
|
|
768
|
+
const data = response.data;
|
|
769
|
+
if (!data?.access_token || !data.token_type) {
|
|
770
|
+
throw new FeishuBitableError("oauth exchange code failed: missing token fields.", {
|
|
771
|
+
details: response
|
|
772
|
+
});
|
|
773
|
+
}
|
|
774
|
+
const token = {
|
|
775
|
+
access_token: data.access_token,
|
|
776
|
+
refresh_token: data.refresh_token,
|
|
777
|
+
token_type: data.token_type,
|
|
778
|
+
expires_in: data.expires_in,
|
|
779
|
+
refresh_expires_in: data.refresh_expires_in,
|
|
780
|
+
scope: data.scope
|
|
781
|
+
};
|
|
782
|
+
this.logInfo("oauth exchangeCodeForUserToken succeeded", {
|
|
783
|
+
hasRefreshToken: Boolean(token.refresh_token),
|
|
784
|
+
expiresIn: token.expires_in
|
|
785
|
+
});
|
|
786
|
+
return token;
|
|
787
|
+
}
|
|
788
|
+
async refreshUserAccessToken(refreshToken) {
|
|
789
|
+
if (!refreshToken.trim()) {
|
|
790
|
+
throw new FeishuBitableError("refreshToken is required to refresh user token.");
|
|
791
|
+
}
|
|
792
|
+
this.logInfo("oauth refreshUserAccessToken started");
|
|
793
|
+
const response = assertFeishuResponse(await this.client.authen.v1.oidcRefreshAccessToken.create({
|
|
794
|
+
data: {
|
|
795
|
+
grant_type: "refresh_token",
|
|
796
|
+
refresh_token: refreshToken
|
|
797
|
+
}
|
|
798
|
+
}), "oauth refresh token");
|
|
799
|
+
const data = response.data;
|
|
800
|
+
if (!data?.access_token || !data.token_type) {
|
|
801
|
+
throw new FeishuBitableError("oauth refresh token failed: missing token fields.", {
|
|
802
|
+
details: response
|
|
803
|
+
});
|
|
804
|
+
}
|
|
805
|
+
const token = {
|
|
806
|
+
access_token: data.access_token,
|
|
807
|
+
refresh_token: data.refresh_token,
|
|
808
|
+
token_type: data.token_type,
|
|
809
|
+
expires_in: data.expires_in,
|
|
810
|
+
refresh_expires_in: data.refresh_expires_in,
|
|
811
|
+
scope: data.scope
|
|
812
|
+
};
|
|
813
|
+
this.logInfo("oauth refreshUserAccessToken succeeded", {
|
|
814
|
+
hasRefreshToken: Boolean(token.refresh_token),
|
|
815
|
+
expiresIn: token.expires_in
|
|
816
|
+
});
|
|
817
|
+
return token;
|
|
818
|
+
}
|
|
819
|
+
async getUserInfo(userAccessToken) {
|
|
820
|
+
if (!userAccessToken.trim()) {
|
|
821
|
+
throw new FeishuBitableError("userAccessToken is required to get user info.");
|
|
822
|
+
}
|
|
823
|
+
this.logInfo("oauth getUserInfo started");
|
|
824
|
+
const response = assertFeishuResponse(await this.client.authen.v1.userInfo.get({}, lark2.withUserAccessToken(userAccessToken)), "oauth get user info");
|
|
825
|
+
this.logInfo("oauth getUserInfo succeeded", {
|
|
826
|
+
openId: response.data?.open_id,
|
|
827
|
+
userId: response.data?.user_id
|
|
828
|
+
});
|
|
829
|
+
return response.data ?? {};
|
|
830
|
+
}
|
|
831
|
+
async handleCallback(code) {
|
|
832
|
+
const token = await this.exchangeCodeForUserToken(code);
|
|
833
|
+
const user = await this.getUserInfo(token.access_token);
|
|
834
|
+
return { token, user };
|
|
835
|
+
}
|
|
836
|
+
logInfo(message, meta) {
|
|
837
|
+
this.logger?.info(message, meta);
|
|
838
|
+
}
|
|
839
|
+
}
|
|
840
|
+
function resolveOpenDomain(domain) {
|
|
841
|
+
if (domain === lark2.Domain.Lark) {
|
|
842
|
+
return "https://open.larksuite.com";
|
|
843
|
+
}
|
|
844
|
+
if (domain === lark2.Domain.Feishu) {
|
|
845
|
+
return "https://open.feishu.cn";
|
|
846
|
+
}
|
|
847
|
+
if (typeof domain === "string") {
|
|
848
|
+
if (domain.includes("larksuite")) {
|
|
849
|
+
return "https://open.larksuite.com";
|
|
850
|
+
}
|
|
851
|
+
return "https://open.feishu.cn";
|
|
852
|
+
}
|
|
853
|
+
return "https://open.feishu.cn";
|
|
626
854
|
}
|
|
627
855
|
|
|
628
856
|
// src/index.ts
|
package/lib/index.d.ts
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import { Bitable } from "./client";
|
|
2
|
+
import { FeishuOAuthClient } from "./auth";
|
|
2
3
|
export { Bitable };
|
|
4
|
+
export { FeishuOAuthClient };
|
|
3
5
|
export { FeishuBitableError } from "./errors";
|
|
4
6
|
export { AppType, Domain, LoggerLevel } from "@larksuiteoapi/node-sdk";
|
|
5
|
-
export type { BatchOperationOptions, BitableBatchUpdatePayload, BitableBatchUpdateResponse, BitableConstructorOptions, BitableFieldValue, BitableFilterCondition, BitableFilterGroup, BitableInsertRecord, BitableLocationValue, BitableLogger, BitableMemberValue, BitableRecord, BitableRecordFields, BitableSort, BitableTextValue, BitableUpdateRecord, FetchAllRecordsOptions, MediaParentType, UpdateRecordsOptions, UploadFileOptions, UploadableFile, } from "./types";
|
|
7
|
+
export type { BatchOperationOptions, BitableBatchUpdatePayload, BitableBatchUpdateResponse, BitableConstructorOptions, BitableFieldValue, BitableFilterCondition, BitableFilterGroup, BitableInsertRecord, BitableLocationValue, BitableLogger, BitableMemberValue, BitableRecord, BitableRecordFields, BitableSort, BitableTextValue, BitableUpdateRecord, BuildOAuthLoginUrlOptions, FetchAllRecordsOptions, FeishuOAuthConstructorOptions, MediaParentType, OAuthCallbackResult, OAuthTokenInfo, OAuthUserInfo, UpdateRecordsOptions, UploadFileOptions, UploadableFile, } from "./types";
|
|
6
8
|
export default Bitable;
|
package/lib/index.js
CHANGED
|
@@ -211,7 +211,7 @@ class Bitable {
|
|
|
211
211
|
}, async () => {
|
|
212
212
|
const token = this.resolveAppToken(appToken);
|
|
213
213
|
const pageSize = Math.max(1, Math.min(options.pageSize ?? FEISHU_BATCH_LIMIT, FEISHU_BATCH_LIMIT));
|
|
214
|
-
const
|
|
214
|
+
const request = {
|
|
215
215
|
path: {
|
|
216
216
|
app_token: token,
|
|
217
217
|
table_id: tableId
|
|
@@ -227,7 +227,8 @@ class Bitable {
|
|
|
227
227
|
sort: options.sort,
|
|
228
228
|
automatic_fields: options.automaticFields
|
|
229
229
|
}
|
|
230
|
-
}
|
|
230
|
+
};
|
|
231
|
+
const iterator = await this.client.bitable.v1.appTableRecord.searchWithIterator(this.logRequest("fetchAllRecords request", request));
|
|
231
232
|
const allRecords = [];
|
|
232
233
|
for await (const page of iterator) {
|
|
233
234
|
const items = page?.items ?? [];
|
|
@@ -252,12 +253,15 @@ class Bitable {
|
|
|
252
253
|
}
|
|
253
254
|
const token = this.resolveAppToken(options.appToken);
|
|
254
255
|
const chunks = chunkArray(records, options.chunkSize ?? FEISHU_BATCH_LIMIT);
|
|
255
|
-
const responses = await runWithConcurrency(chunks, options.concurrency ?? this.defaultConcurrency, async (chunk) => this.withRetry("insert records", async () =>
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
256
|
+
const responses = await runWithConcurrency(chunks, options.concurrency ?? this.defaultConcurrency, async (chunk) => this.withRetry("insert records", async () => {
|
|
257
|
+
const request = {
|
|
258
|
+
path: { app_token: token, table_id: tableId },
|
|
259
|
+
data: {
|
|
260
|
+
records: chunk.map((fields) => ({ fields }))
|
|
261
|
+
}
|
|
262
|
+
};
|
|
263
|
+
return assertFeishuResponse(await this.client.bitable.v1.appTableRecord.batchCreate(this.logRequest("insertList request", request)), "insert records");
|
|
264
|
+
}));
|
|
261
265
|
this.logInfo("insertList completed", {
|
|
262
266
|
tableId,
|
|
263
267
|
chunkCount: chunks.length
|
|
@@ -284,8 +288,18 @@ class Bitable {
|
|
|
284
288
|
const token = this.resolveAppToken(options.appToken);
|
|
285
289
|
const chunks = chunkArray(records, options.chunkSize ?? FEISHU_BATCH_LIMIT);
|
|
286
290
|
const responses = await runWithConcurrency(chunks, options.concurrency ?? this.defaultConcurrency, async (chunk, index) => {
|
|
287
|
-
const batchRecords = chunk.map((record) => {
|
|
291
|
+
const batchRecords = chunk.map((record, recordIndex) => {
|
|
288
292
|
const { recordId, fields } = splitUpdateRecord(record);
|
|
293
|
+
if (!recordId || !recordId.trim()) {
|
|
294
|
+
throw new FeishuBitableError(`updateRecords failed: record_id is required for chunk ${index}, item ${recordIndex}`, {
|
|
295
|
+
details: {
|
|
296
|
+
tableId,
|
|
297
|
+
chunkIndex: index,
|
|
298
|
+
recordIndex,
|
|
299
|
+
record
|
|
300
|
+
}
|
|
301
|
+
});
|
|
302
|
+
}
|
|
289
303
|
if (Object.keys(fields).length === 0) {
|
|
290
304
|
return null;
|
|
291
305
|
}
|
|
@@ -344,12 +358,15 @@ class Bitable {
|
|
|
344
358
|
}
|
|
345
359
|
const token = this.resolveAppToken(options.appToken);
|
|
346
360
|
const chunks = chunkArray(recordIds, options.chunkSize ?? FEISHU_BATCH_LIMIT);
|
|
347
|
-
const responses = await runWithConcurrency(chunks, options.concurrency ?? this.defaultConcurrency, async (chunk) => this.withRetry("delete records", async () =>
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
361
|
+
const responses = await runWithConcurrency(chunks, options.concurrency ?? this.defaultConcurrency, async (chunk) => this.withRetry("delete records", async () => {
|
|
362
|
+
const request = {
|
|
363
|
+
path: { app_token: token, table_id: tableId },
|
|
364
|
+
data: {
|
|
365
|
+
records: chunk
|
|
366
|
+
}
|
|
367
|
+
};
|
|
368
|
+
return assertFeishuResponse(await this.client.bitable.v1.appTableRecord.batchDelete(this.logRequest("deleteList request", request)), "delete records");
|
|
369
|
+
}));
|
|
353
370
|
this.logInfo("deleteList completed", {
|
|
354
371
|
tableId,
|
|
355
372
|
chunkCount: chunks.length
|
|
@@ -371,25 +388,37 @@ class Bitable {
|
|
|
371
388
|
mode: buffer.byteLength <= FEISHU_SIMPLE_UPLOAD_LIMIT ? "simple" : "multipart"
|
|
372
389
|
});
|
|
373
390
|
if (buffer.byteLength <= FEISHU_SIMPLE_UPLOAD_LIMIT) {
|
|
374
|
-
return await this.withRetry("upload file", async () =>
|
|
391
|
+
return await this.withRetry("upload file", async () => {
|
|
392
|
+
const request = {
|
|
393
|
+
data: {
|
|
394
|
+
file_name: fileName,
|
|
395
|
+
parent_type: options.parentType,
|
|
396
|
+
parent_node: options.parentNode,
|
|
397
|
+
size: buffer.byteLength,
|
|
398
|
+
extra: options.extra,
|
|
399
|
+
file: buffer
|
|
400
|
+
}
|
|
401
|
+
};
|
|
402
|
+
this.logRequest("uploadFile request", {
|
|
403
|
+
data: {
|
|
404
|
+
...request.data,
|
|
405
|
+
file: `[Buffer ${buffer.byteLength} bytes]`
|
|
406
|
+
}
|
|
407
|
+
});
|
|
408
|
+
return this.client.drive.v1.media.uploadAll(request);
|
|
409
|
+
}) ?? {};
|
|
410
|
+
}
|
|
411
|
+
const prepare = assertFeishuResponse(await this.withRetry("prepare multipart upload", async () => {
|
|
412
|
+
const request = {
|
|
375
413
|
data: {
|
|
376
414
|
file_name: fileName,
|
|
377
415
|
parent_type: options.parentType,
|
|
378
416
|
parent_node: options.parentNode,
|
|
379
|
-
size: buffer.byteLength
|
|
380
|
-
extra: options.extra,
|
|
381
|
-
file: buffer
|
|
417
|
+
size: buffer.byteLength
|
|
382
418
|
}
|
|
383
|
-
}
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
data: {
|
|
387
|
-
file_name: fileName,
|
|
388
|
-
parent_type: options.parentType,
|
|
389
|
-
parent_node: options.parentNode,
|
|
390
|
-
size: buffer.byteLength
|
|
391
|
-
}
|
|
392
|
-
})), "prepare multipart upload");
|
|
419
|
+
};
|
|
420
|
+
return this.client.drive.v1.media.uploadPrepare(this.logRequest("uploadPrepare request", request));
|
|
421
|
+
}), "prepare multipart upload");
|
|
393
422
|
const uploadId = prepare.data?.upload_id;
|
|
394
423
|
const blockSize = prepare.data?.block_size;
|
|
395
424
|
const blockNum = prepare.data?.block_num;
|
|
@@ -402,21 +431,33 @@ class Bitable {
|
|
|
402
431
|
const start = index * blockSize;
|
|
403
432
|
const end = Math.min(start + blockSize, buffer.byteLength);
|
|
404
433
|
const chunk = buffer.subarray(start, end);
|
|
405
|
-
await this.withRetry(`upload file chunk ${index + 1}/${blockNum}`, async () =>
|
|
434
|
+
await this.withRetry(`upload file chunk ${index + 1}/${blockNum}`, async () => {
|
|
435
|
+
const request = {
|
|
436
|
+
data: {
|
|
437
|
+
upload_id: uploadId,
|
|
438
|
+
seq: index,
|
|
439
|
+
size: chunk.byteLength,
|
|
440
|
+
file: chunk
|
|
441
|
+
}
|
|
442
|
+
};
|
|
443
|
+
this.logRequest("uploadPart request", {
|
|
444
|
+
data: {
|
|
445
|
+
...request.data,
|
|
446
|
+
file: `[Buffer ${chunk.byteLength} bytes]`
|
|
447
|
+
}
|
|
448
|
+
});
|
|
449
|
+
return this.client.drive.v1.media.uploadPart(request);
|
|
450
|
+
});
|
|
451
|
+
}
|
|
452
|
+
const finish = assertFeishuResponse(await this.withRetry("finish multipart upload", async () => {
|
|
453
|
+
const request = {
|
|
406
454
|
data: {
|
|
407
455
|
upload_id: uploadId,
|
|
408
|
-
|
|
409
|
-
size: chunk.byteLength,
|
|
410
|
-
file: chunk
|
|
456
|
+
block_num: blockNum
|
|
411
457
|
}
|
|
412
|
-
}
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
data: {
|
|
416
|
-
upload_id: uploadId,
|
|
417
|
-
block_num: blockNum
|
|
418
|
-
}
|
|
419
|
-
})), "finish multipart upload");
|
|
458
|
+
};
|
|
459
|
+
return this.client.drive.v1.media.uploadFinish(this.logRequest("uploadFinish request", request));
|
|
460
|
+
}), "finish multipart upload");
|
|
420
461
|
return {
|
|
421
462
|
file_token: finish.data?.file_token
|
|
422
463
|
};
|
|
@@ -427,14 +468,17 @@ class Bitable {
|
|
|
427
468
|
fileToken,
|
|
428
469
|
hasExtra: Boolean(extra)
|
|
429
470
|
}, async () => {
|
|
430
|
-
const response = await this.withRetry("download file", async () =>
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
471
|
+
const response = await this.withRetry("download file", async () => {
|
|
472
|
+
const request = {
|
|
473
|
+
path: {
|
|
474
|
+
file_token: fileToken
|
|
475
|
+
},
|
|
476
|
+
params: {
|
|
477
|
+
extra
|
|
478
|
+
}
|
|
479
|
+
};
|
|
480
|
+
return this.client.drive.v1.media.download(this.logRequest("downloadFile request", request));
|
|
481
|
+
});
|
|
438
482
|
const buffer = await readableToBuffer(response.getReadableStream());
|
|
439
483
|
this.logInfo("downloadFile completed", {
|
|
440
484
|
fileToken,
|
|
@@ -476,7 +520,7 @@ class Bitable {
|
|
|
476
520
|
return token;
|
|
477
521
|
}
|
|
478
522
|
async executeBatchUpdateRecords(payload) {
|
|
479
|
-
return this.withRetry("batch update records", async () => assertFeishuResponse(await this.client.bitable.v1.appTableRecord.batchUpdate(payload), "batch update records"));
|
|
523
|
+
return this.withRetry("batch update records", async () => assertFeishuResponse(await this.client.bitable.v1.appTableRecord.batchUpdate(this.logRequest("batchUpdateRecords request", payload)), "batch update records"));
|
|
480
524
|
}
|
|
481
525
|
async withRetry(label, task) {
|
|
482
526
|
let lastError;
|
|
@@ -549,16 +593,200 @@ class Bitable {
|
|
|
549
593
|
errorMessage: String(error)
|
|
550
594
|
};
|
|
551
595
|
}
|
|
596
|
+
logRequest(message, payload) {
|
|
597
|
+
this.logInfo(message, {
|
|
598
|
+
payload
|
|
599
|
+
});
|
|
600
|
+
return payload;
|
|
601
|
+
}
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
// src/auth.ts
|
|
605
|
+
import * as lark2 from "@larksuiteoapi/node-sdk";
|
|
606
|
+
var DEFAULT_LOGGER2 = {
|
|
607
|
+
info(message, meta) {
|
|
608
|
+
if (meta) {
|
|
609
|
+
console.info(`[feishu-bitable] ${message}`, meta);
|
|
610
|
+
return;
|
|
611
|
+
}
|
|
612
|
+
console.info(`[feishu-bitable] ${message}`);
|
|
613
|
+
},
|
|
614
|
+
warn(message, meta) {
|
|
615
|
+
if (meta) {
|
|
616
|
+
console.warn(`[feishu-bitable] ${message}`, meta);
|
|
617
|
+
return;
|
|
618
|
+
}
|
|
619
|
+
console.warn(`[feishu-bitable] ${message}`);
|
|
620
|
+
},
|
|
621
|
+
error(message, meta) {
|
|
622
|
+
if (meta) {
|
|
623
|
+
console.error(`[feishu-bitable] ${message}`, meta);
|
|
624
|
+
return;
|
|
625
|
+
}
|
|
626
|
+
console.error(`[feishu-bitable] ${message}`);
|
|
627
|
+
}
|
|
628
|
+
};
|
|
629
|
+
|
|
630
|
+
class FeishuOAuthClient {
|
|
631
|
+
appId;
|
|
632
|
+
appSecret;
|
|
633
|
+
redirectUri;
|
|
634
|
+
domain;
|
|
635
|
+
logger;
|
|
636
|
+
client;
|
|
637
|
+
constructor(options = {}) {
|
|
638
|
+
const appId = options.appId ?? process.env.FEISHU_APP_ID;
|
|
639
|
+
const appSecret = options.appSecret ?? process.env.FEISHU_APP_SECRET;
|
|
640
|
+
if (!appId || !appSecret) {
|
|
641
|
+
throw new FeishuBitableError("appId and appSecret are required. Pass them in constructor or provide FEISHU_APP_ID and FEISHU_APP_SECRET.");
|
|
642
|
+
}
|
|
643
|
+
this.appId = appId;
|
|
644
|
+
this.appSecret = appSecret;
|
|
645
|
+
this.redirectUri = options.redirectUri;
|
|
646
|
+
this.domain = options.domain ?? lark2.Domain.Feishu;
|
|
647
|
+
this.logger = options.logger === null ? null : options.logger ?? DEFAULT_LOGGER2;
|
|
648
|
+
this.client = options.sdkClient ?? new lark2.Client({
|
|
649
|
+
appId: this.appId,
|
|
650
|
+
appSecret: this.appSecret,
|
|
651
|
+
appType: lark2.AppType.SelfBuild,
|
|
652
|
+
domain: this.domain
|
|
653
|
+
});
|
|
654
|
+
}
|
|
655
|
+
static fromEnv(env = process.env) {
|
|
656
|
+
return new FeishuOAuthClient({
|
|
657
|
+
appId: env.FEISHU_APP_ID,
|
|
658
|
+
appSecret: env.FEISHU_APP_SECRET,
|
|
659
|
+
redirectUri: env.FEISHU_OAUTH_REDIRECT_URI
|
|
660
|
+
});
|
|
661
|
+
}
|
|
662
|
+
buildLoginUrl(options) {
|
|
663
|
+
const redirectUri = options.redirectUri ?? this.redirectUri;
|
|
664
|
+
if (!redirectUri) {
|
|
665
|
+
throw new FeishuBitableError("redirectUri is required. Pass it into constructor or buildLoginUrl options.");
|
|
666
|
+
}
|
|
667
|
+
const scope = Array.isArray(options.scope) ? options.scope.join(" ") : options.scope;
|
|
668
|
+
const url = new URL("/open-apis/authen/v1/authorize", resolveOpenDomain(this.domain));
|
|
669
|
+
url.searchParams.set("app_id", this.appId);
|
|
670
|
+
url.searchParams.set("redirect_uri", redirectUri);
|
|
671
|
+
url.searchParams.set("state", options.state);
|
|
672
|
+
if (scope?.trim()) {
|
|
673
|
+
url.searchParams.set("scope", scope.trim());
|
|
674
|
+
}
|
|
675
|
+
this.logInfo("oauth buildLoginUrl", {
|
|
676
|
+
redirectUri,
|
|
677
|
+
hasScope: Boolean(scope),
|
|
678
|
+
state: options.state
|
|
679
|
+
});
|
|
680
|
+
return url.toString();
|
|
681
|
+
}
|
|
682
|
+
async exchangeCodeForUserToken(code) {
|
|
683
|
+
if (!code.trim()) {
|
|
684
|
+
throw new FeishuBitableError("code is required to exchange user token.");
|
|
685
|
+
}
|
|
686
|
+
this.logInfo("oauth exchangeCodeForUserToken started");
|
|
687
|
+
const response = assertFeishuResponse(await this.client.authen.v1.oidcAccessToken.create({
|
|
688
|
+
data: {
|
|
689
|
+
grant_type: "authorization_code",
|
|
690
|
+
code
|
|
691
|
+
}
|
|
692
|
+
}), "oauth exchange code");
|
|
693
|
+
const data = response.data;
|
|
694
|
+
if (!data?.access_token || !data.token_type) {
|
|
695
|
+
throw new FeishuBitableError("oauth exchange code failed: missing token fields.", {
|
|
696
|
+
details: response
|
|
697
|
+
});
|
|
698
|
+
}
|
|
699
|
+
const token = {
|
|
700
|
+
access_token: data.access_token,
|
|
701
|
+
refresh_token: data.refresh_token,
|
|
702
|
+
token_type: data.token_type,
|
|
703
|
+
expires_in: data.expires_in,
|
|
704
|
+
refresh_expires_in: data.refresh_expires_in,
|
|
705
|
+
scope: data.scope
|
|
706
|
+
};
|
|
707
|
+
this.logInfo("oauth exchangeCodeForUserToken succeeded", {
|
|
708
|
+
hasRefreshToken: Boolean(token.refresh_token),
|
|
709
|
+
expiresIn: token.expires_in
|
|
710
|
+
});
|
|
711
|
+
return token;
|
|
712
|
+
}
|
|
713
|
+
async refreshUserAccessToken(refreshToken) {
|
|
714
|
+
if (!refreshToken.trim()) {
|
|
715
|
+
throw new FeishuBitableError("refreshToken is required to refresh user token.");
|
|
716
|
+
}
|
|
717
|
+
this.logInfo("oauth refreshUserAccessToken started");
|
|
718
|
+
const response = assertFeishuResponse(await this.client.authen.v1.oidcRefreshAccessToken.create({
|
|
719
|
+
data: {
|
|
720
|
+
grant_type: "refresh_token",
|
|
721
|
+
refresh_token: refreshToken
|
|
722
|
+
}
|
|
723
|
+
}), "oauth refresh token");
|
|
724
|
+
const data = response.data;
|
|
725
|
+
if (!data?.access_token || !data.token_type) {
|
|
726
|
+
throw new FeishuBitableError("oauth refresh token failed: missing token fields.", {
|
|
727
|
+
details: response
|
|
728
|
+
});
|
|
729
|
+
}
|
|
730
|
+
const token = {
|
|
731
|
+
access_token: data.access_token,
|
|
732
|
+
refresh_token: data.refresh_token,
|
|
733
|
+
token_type: data.token_type,
|
|
734
|
+
expires_in: data.expires_in,
|
|
735
|
+
refresh_expires_in: data.refresh_expires_in,
|
|
736
|
+
scope: data.scope
|
|
737
|
+
};
|
|
738
|
+
this.logInfo("oauth refreshUserAccessToken succeeded", {
|
|
739
|
+
hasRefreshToken: Boolean(token.refresh_token),
|
|
740
|
+
expiresIn: token.expires_in
|
|
741
|
+
});
|
|
742
|
+
return token;
|
|
743
|
+
}
|
|
744
|
+
async getUserInfo(userAccessToken) {
|
|
745
|
+
if (!userAccessToken.trim()) {
|
|
746
|
+
throw new FeishuBitableError("userAccessToken is required to get user info.");
|
|
747
|
+
}
|
|
748
|
+
this.logInfo("oauth getUserInfo started");
|
|
749
|
+
const response = assertFeishuResponse(await this.client.authen.v1.userInfo.get({}, lark2.withUserAccessToken(userAccessToken)), "oauth get user info");
|
|
750
|
+
this.logInfo("oauth getUserInfo succeeded", {
|
|
751
|
+
openId: response.data?.open_id,
|
|
752
|
+
userId: response.data?.user_id
|
|
753
|
+
});
|
|
754
|
+
return response.data ?? {};
|
|
755
|
+
}
|
|
756
|
+
async handleCallback(code) {
|
|
757
|
+
const token = await this.exchangeCodeForUserToken(code);
|
|
758
|
+
const user = await this.getUserInfo(token.access_token);
|
|
759
|
+
return { token, user };
|
|
760
|
+
}
|
|
761
|
+
logInfo(message, meta) {
|
|
762
|
+
this.logger?.info(message, meta);
|
|
763
|
+
}
|
|
764
|
+
}
|
|
765
|
+
function resolveOpenDomain(domain) {
|
|
766
|
+
if (domain === lark2.Domain.Lark) {
|
|
767
|
+
return "https://open.larksuite.com";
|
|
768
|
+
}
|
|
769
|
+
if (domain === lark2.Domain.Feishu) {
|
|
770
|
+
return "https://open.feishu.cn";
|
|
771
|
+
}
|
|
772
|
+
if (typeof domain === "string") {
|
|
773
|
+
if (domain.includes("larksuite")) {
|
|
774
|
+
return "https://open.larksuite.com";
|
|
775
|
+
}
|
|
776
|
+
return "https://open.feishu.cn";
|
|
777
|
+
}
|
|
778
|
+
return "https://open.feishu.cn";
|
|
552
779
|
}
|
|
553
780
|
|
|
554
781
|
// src/index.ts
|
|
555
|
-
import { AppType as
|
|
782
|
+
import { AppType as AppType3, Domain as Domain3, LoggerLevel } from "@larksuiteoapi/node-sdk";
|
|
556
783
|
var src_default = Bitable;
|
|
557
784
|
export {
|
|
558
785
|
src_default as default,
|
|
559
786
|
LoggerLevel,
|
|
787
|
+
FeishuOAuthClient,
|
|
560
788
|
FeishuBitableError,
|
|
561
|
-
|
|
789
|
+
Domain3 as Domain,
|
|
562
790
|
Bitable,
|
|
563
|
-
|
|
791
|
+
AppType3 as AppType
|
|
564
792
|
};
|
package/lib/types.d.ts
CHANGED
|
@@ -83,6 +83,47 @@ export interface BitableConstructorOptions {
|
|
|
83
83
|
sdkClient?: lark.Client;
|
|
84
84
|
logger?: BitableLogger | null;
|
|
85
85
|
}
|
|
86
|
+
export interface FeishuOAuthConstructorOptions {
|
|
87
|
+
appId?: string;
|
|
88
|
+
appSecret?: string;
|
|
89
|
+
redirectUri?: string;
|
|
90
|
+
domain?: lark.Domain | string;
|
|
91
|
+
sdkClient?: lark.Client;
|
|
92
|
+
logger?: BitableLogger | null;
|
|
93
|
+
}
|
|
94
|
+
export interface BuildOAuthLoginUrlOptions {
|
|
95
|
+
state: string;
|
|
96
|
+
redirectUri?: string;
|
|
97
|
+
scope?: string | string[];
|
|
98
|
+
}
|
|
99
|
+
export interface OAuthTokenInfo {
|
|
100
|
+
access_token: string;
|
|
101
|
+
refresh_token?: string;
|
|
102
|
+
token_type: string;
|
|
103
|
+
expires_in?: number;
|
|
104
|
+
refresh_expires_in?: number;
|
|
105
|
+
scope?: string;
|
|
106
|
+
}
|
|
107
|
+
export interface OAuthUserInfo {
|
|
108
|
+
name?: string;
|
|
109
|
+
en_name?: string;
|
|
110
|
+
avatar_url?: string;
|
|
111
|
+
avatar_thumb?: string;
|
|
112
|
+
avatar_middle?: string;
|
|
113
|
+
avatar_big?: string;
|
|
114
|
+
open_id?: string;
|
|
115
|
+
union_id?: string;
|
|
116
|
+
email?: string;
|
|
117
|
+
enterprise_email?: string;
|
|
118
|
+
user_id?: string;
|
|
119
|
+
mobile?: string;
|
|
120
|
+
tenant_key?: string;
|
|
121
|
+
employee_no?: string;
|
|
122
|
+
}
|
|
123
|
+
export interface OAuthCallbackResult {
|
|
124
|
+
token: OAuthTokenInfo;
|
|
125
|
+
user: OAuthUserInfo;
|
|
126
|
+
}
|
|
86
127
|
export interface FetchAllRecordsOptions {
|
|
87
128
|
viewId?: string;
|
|
88
129
|
fieldNames?: string[];
|