@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 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
@@ -37,4 +37,5 @@ export declare class Bitable {
37
37
  private logWarn;
38
38
  private logError;
39
39
  private getErrorMeta;
40
+ private logRequest;
40
41
  }
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 iterator = await this.client.bitable.v1.appTableRecord.searchWithIterator({
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 () => assertFeishuResponse(await this.client.bitable.v1.appTableRecord.batchCreate({
330
- path: { app_token: token, table_id: tableId },
331
- data: {
332
- records: chunk.map((fields) => ({ fields }))
333
- }
334
- }), "insert records")));
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 () => assertFeishuResponse(await this.client.bitable.v1.appTableRecord.batchDelete({
422
- path: { app_token: token, table_id: tableId },
423
- data: {
424
- records: chunk
425
- }
426
- }), "delete records")));
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 () => this.client.drive.v1.media.uploadAll({
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
- const prepare = assertFeishuResponse(await this.withRetry("prepare multipart upload", async () => this.client.drive.v1.media.uploadPrepare({
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 () => this.client.drive.v1.media.uploadPart({
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
- seq: index,
483
- size: chunk.byteLength,
484
- file: chunk
531
+ block_num: blockNum
485
532
  }
486
- }));
487
- }
488
- const finish = assertFeishuResponse(await this.withRetry("finish multipart upload", async () => this.client.drive.v1.media.uploadFinish({
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 () => this.client.drive.v1.media.download({
505
- path: {
506
- file_token: fileToken
507
- },
508
- params: {
509
- extra
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 iterator = await this.client.bitable.v1.appTableRecord.searchWithIterator({
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 () => assertFeishuResponse(await this.client.bitable.v1.appTableRecord.batchCreate({
256
- path: { app_token: token, table_id: tableId },
257
- data: {
258
- records: chunk.map((fields) => ({ fields }))
259
- }
260
- }), "insert records")));
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 () => assertFeishuResponse(await this.client.bitable.v1.appTableRecord.batchDelete({
348
- path: { app_token: token, table_id: tableId },
349
- data: {
350
- records: chunk
351
- }
352
- }), "delete records")));
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 () => this.client.drive.v1.media.uploadAll({
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
- const prepare = assertFeishuResponse(await this.withRetry("prepare multipart upload", async () => this.client.drive.v1.media.uploadPrepare({
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 () => this.client.drive.v1.media.uploadPart({
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
- seq: index,
409
- size: chunk.byteLength,
410
- file: chunk
456
+ block_num: blockNum
411
457
  }
412
- }));
413
- }
414
- const finish = assertFeishuResponse(await this.withRetry("finish multipart upload", async () => this.client.drive.v1.media.uploadFinish({
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 () => this.client.drive.v1.media.download({
431
- path: {
432
- file_token: fileToken
433
- },
434
- params: {
435
- extra
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 AppType2, Domain as Domain2, LoggerLevel } from "@larksuiteoapi/node-sdk";
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
- Domain2 as Domain,
789
+ Domain3 as Domain,
562
790
  Bitable,
563
- AppType2 as AppType
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[];
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yuuko1410/feishu-bitable",
3
- "version": "0.0.3",
3
+ "version": "0.0.5",
4
4
  "description": "基于 Bun + TypeScript + 飞书官方 SDK 的多维表格操作库",
5
5
  "type": "module",
6
6
  "main": "./lib/index.cjs",