feishu-user-plugin 1.1.3 → 1.2.1

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/src/index.js CHANGED
@@ -6,6 +6,8 @@ const {
6
6
  ListToolsRequestSchema,
7
7
  } = require('@modelcontextprotocol/sdk/types.js');
8
8
  const path = require('path');
9
+ // Local dev fallback: MCP clients inject env vars from config's env block at spawn time.
10
+ // This dotenv line only matters when running locally with a .env file (e.g. during development).
9
11
  require('dotenv').config({ path: path.join(__dirname, '..', '.env') });
10
12
  const { LarkUserClient } = require('./client');
11
13
  const { LarkOfficialClient } = require('./official');
@@ -270,10 +272,10 @@ const TOOLS = [
270
272
  },
271
273
  {
272
274
  name: 'get_chat_info',
273
- description: '[User Identity] Get chat details: name, description, member count, owner.',
275
+ description: '[Official API + User Identity fallback] Get chat details: name, description, member count, owner. Supports both oc_xxx and numeric chat_id.',
274
276
  inputSchema: {
275
277
  type: 'object',
276
- properties: { chat_id: { type: 'string', description: 'Chat ID' } },
278
+ properties: { chat_id: { type: 'string', description: 'Chat ID (oc_xxx or numeric)' } },
277
279
  required: ['chat_id'],
278
280
  },
279
281
  },
@@ -394,6 +396,17 @@ const TOOLS = [
394
396
  required: ['document_id'],
395
397
  },
396
398
  },
399
+ {
400
+ name: 'get_doc_blocks',
401
+ description: '[Official API] Get structured block tree of a document. Returns block types, content, and hierarchy for precise document analysis.',
402
+ inputSchema: {
403
+ type: 'object',
404
+ properties: {
405
+ document_id: { type: 'string', description: 'Document ID (from search_docs or create_doc)' },
406
+ },
407
+ required: ['document_id'],
408
+ },
409
+ },
397
410
  {
398
411
  name: 'create_doc',
399
412
  description: '[Official API] Create a new Feishu document.',
@@ -408,6 +421,17 @@ const TOOLS = [
408
421
  },
409
422
 
410
423
  // ========== Bitable — Official API ==========
424
+ {
425
+ name: 'create_bitable',
426
+ description: '[Official API] Create a new Bitable (multi-dimensional table) app.',
427
+ inputSchema: {
428
+ type: 'object',
429
+ properties: {
430
+ name: { type: 'string', description: 'Bitable app name' },
431
+ folder_id: { type: 'string', description: 'Parent folder token (optional, defaults to root)' },
432
+ },
433
+ },
434
+ },
411
435
  {
412
436
  name: 'list_bitable_tables',
413
437
  description: '[Official API] List all tables in a Bitable app.',
@@ -417,6 +441,23 @@ const TOOLS = [
417
441
  required: ['app_token'],
418
442
  },
419
443
  },
444
+ {
445
+ name: 'create_bitable_table',
446
+ description: '[Official API] Create a new data table in a Bitable app. Optionally define initial fields.',
447
+ inputSchema: {
448
+ type: 'object',
449
+ properties: {
450
+ app_token: { type: 'string', description: 'Bitable app token' },
451
+ name: { type: 'string', description: 'Table name' },
452
+ fields: {
453
+ type: 'array',
454
+ description: 'Initial field definitions (optional). Each item: {field_name, type} where type is 1=Text, 2=Number, 3=SingleSelect, 4=MultiSelect, 5=DateTime, 7=Checkbox, 11=User, 13=Phone, 15=URL, 17=Attachment, 18=Link, 20=Formula, 21=DuplexLink, 22=Location, 23=GroupChat, 1001=CreateTime, 1002=ModifiedTime, 1003=Creator, 1004=Modifier',
455
+ items: { type: 'object' },
456
+ },
457
+ },
458
+ required: ['app_token', 'name'],
459
+ },
460
+ },
420
461
  {
421
462
  name: 'list_bitable_fields',
422
463
  description: '[Official API] List all fields (columns) in a Bitable table.',
@@ -429,6 +470,62 @@ const TOOLS = [
429
470
  required: ['app_token', 'table_id'],
430
471
  },
431
472
  },
473
+ {
474
+ name: 'create_bitable_field',
475
+ description: '[Official API] Create a new field (column) in a Bitable table.',
476
+ inputSchema: {
477
+ type: 'object',
478
+ properties: {
479
+ app_token: { type: 'string', description: 'Bitable app token' },
480
+ table_id: { type: 'string', description: 'Table ID' },
481
+ field_name: { type: 'string', description: 'Field display name' },
482
+ type: { type: 'number', description: 'Field type: 1=Text, 2=Number, 3=SingleSelect, 4=MultiSelect, 5=DateTime, 7=Checkbox, 11=User, 13=Phone, 15=URL, 17=Attachment, 18=Link, 20=Formula, 21=DuplexLink, 22=Location, 23=GroupChat, 1001=CreateTime, 1002=ModifiedTime, 1003=Creator, 1004=Modifier' },
483
+ property: { type: 'object', description: 'Field-type-specific properties (optional). E.g. for SingleSelect: {options: [{name:"A"},{name:"B"}]}' },
484
+ },
485
+ required: ['app_token', 'table_id', 'field_name', 'type'],
486
+ },
487
+ },
488
+ {
489
+ name: 'update_bitable_field',
490
+ description: '[Official API] Update an existing field (column) in a Bitable table.',
491
+ inputSchema: {
492
+ type: 'object',
493
+ properties: {
494
+ app_token: { type: 'string', description: 'Bitable app token' },
495
+ table_id: { type: 'string', description: 'Table ID' },
496
+ field_id: { type: 'string', description: 'Field ID to update' },
497
+ field_name: { type: 'string', description: 'New field name (optional)' },
498
+ type: { type: 'number', description: 'Field type (REQUIRED by Feishu API, see create_bitable_field for values)' },
499
+ property: { type: 'object', description: 'Field-type-specific properties (optional)' },
500
+ },
501
+ required: ['app_token', 'table_id', 'field_id', 'type'],
502
+ },
503
+ },
504
+ {
505
+ name: 'delete_bitable_field',
506
+ description: '[Official API] Delete a field (column) from a Bitable table.',
507
+ inputSchema: {
508
+ type: 'object',
509
+ properties: {
510
+ app_token: { type: 'string', description: 'Bitable app token' },
511
+ table_id: { type: 'string', description: 'Table ID' },
512
+ field_id: { type: 'string', description: 'Field ID to delete' },
513
+ },
514
+ required: ['app_token', 'table_id', 'field_id'],
515
+ },
516
+ },
517
+ {
518
+ name: 'list_bitable_views',
519
+ description: '[Official API] List all views in a Bitable table.',
520
+ inputSchema: {
521
+ type: 'object',
522
+ properties: {
523
+ app_token: { type: 'string', description: 'Bitable app token' },
524
+ table_id: { type: 'string', description: 'Table ID' },
525
+ },
526
+ required: ['app_token', 'table_id'],
527
+ },
528
+ },
432
529
  {
433
530
  name: 'search_bitable_records',
434
531
  description: '[Official API] Search/query records in a Bitable table.',
@@ -471,6 +568,58 @@ const TOOLS = [
471
568
  required: ['app_token', 'table_id', 'record_id', 'fields'],
472
569
  },
473
570
  },
571
+ {
572
+ name: 'delete_bitable_record',
573
+ description: '[Official API] Delete a record (row) from a Bitable table.',
574
+ inputSchema: {
575
+ type: 'object',
576
+ properties: {
577
+ app_token: { type: 'string', description: 'Bitable app token' },
578
+ table_id: { type: 'string', description: 'Table ID' },
579
+ record_id: { type: 'string', description: 'Record ID to delete' },
580
+ },
581
+ required: ['app_token', 'table_id', 'record_id'],
582
+ },
583
+ },
584
+ {
585
+ name: 'batch_create_bitable_records',
586
+ description: '[Official API] Batch create multiple records (rows) in a Bitable table. Max 500 per call.',
587
+ inputSchema: {
588
+ type: 'object',
589
+ properties: {
590
+ app_token: { type: 'string', description: 'Bitable app token' },
591
+ table_id: { type: 'string', description: 'Table ID' },
592
+ records: { type: 'array', description: 'Array of {fields: {field_name: value}} objects', items: { type: 'object' } },
593
+ },
594
+ required: ['app_token', 'table_id', 'records'],
595
+ },
596
+ },
597
+ {
598
+ name: 'batch_update_bitable_records',
599
+ description: '[Official API] Batch update multiple records in a Bitable table. Max 500 per call.',
600
+ inputSchema: {
601
+ type: 'object',
602
+ properties: {
603
+ app_token: { type: 'string', description: 'Bitable app token' },
604
+ table_id: { type: 'string', description: 'Table ID' },
605
+ records: { type: 'array', description: 'Array of {record_id, fields: {field_name: value}} objects', items: { type: 'object' } },
606
+ },
607
+ required: ['app_token', 'table_id', 'records'],
608
+ },
609
+ },
610
+ {
611
+ name: 'batch_delete_bitable_records',
612
+ description: '[Official API] Batch delete multiple records from a Bitable table. Max 500 per call.',
613
+ inputSchema: {
614
+ type: 'object',
615
+ properties: {
616
+ app_token: { type: 'string', description: 'Bitable app token' },
617
+ table_id: { type: 'string', description: 'Table ID' },
618
+ record_ids: { type: 'array', description: 'Array of record IDs to delete', items: { type: 'string' } },
619
+ },
620
+ required: ['app_token', 'table_id', 'record_ids'],
621
+ },
622
+ },
474
623
 
475
624
  // ========== Wiki — Official API ==========
476
625
  {
@@ -522,6 +671,33 @@ const TOOLS = [
522
671
  },
523
672
  },
524
673
 
674
+ // ========== Upload — Official API ==========
675
+ {
676
+ name: 'upload_image',
677
+ description: '[Official API] Upload an image file to Feishu. Returns image_key for use with send_image_as_user.',
678
+ inputSchema: {
679
+ type: 'object',
680
+ properties: {
681
+ image_path: { type: 'string', description: 'Absolute path to the image file on disk' },
682
+ image_type: { type: 'string', enum: ['message', 'avatar'], description: 'Image usage type (default: message)' },
683
+ },
684
+ required: ['image_path'],
685
+ },
686
+ },
687
+ {
688
+ name: 'upload_file',
689
+ description: '[Official API] Upload a file to Feishu. Returns file_key for use with send_file_as_user.',
690
+ inputSchema: {
691
+ type: 'object',
692
+ properties: {
693
+ file_path: { type: 'string', description: 'Absolute path to the file on disk' },
694
+ file_type: { type: 'string', enum: ['opus', 'mp4', 'pdf', 'doc', 'xls', 'ppt', 'stream'], description: 'File type (default: stream for generic files)' },
695
+ file_name: { type: 'string', description: 'Display file name (optional, defaults to basename)' },
696
+ },
697
+ required: ['file_path'],
698
+ },
699
+ },
700
+
525
701
  // ========== Contact — Official API ==========
526
702
  {
527
703
  name: 'find_user',
@@ -539,7 +715,7 @@ const TOOLS = [
539
715
  // --- Server ---
540
716
 
541
717
  const server = new Server(
542
- { name: 'feishu-user-plugin', version: '1.1.3' },
718
+ { name: 'feishu-user-plugin', version: require('../package.json').version },
543
719
  { capabilities: { tools: {} } }
544
720
  );
545
721
 
@@ -571,8 +747,13 @@ async function handleTool(name, args) {
571
747
  case 'send_to_user': {
572
748
  const c = await getUserClient();
573
749
  const results = await c.search(args.user_name);
574
- const user = results.find(r => r.type === 'user');
575
- if (!user) return text(`User "${args.user_name}" not found. Results: ${JSON.stringify(results)}`);
750
+ const users = results.filter(r => r.type === 'user');
751
+ if (users.length === 0) return text(`User "${args.user_name}" not found. Results: ${JSON.stringify(results)}`);
752
+ if (users.length > 1) {
753
+ const candidates = users.slice(0, 5).map(u => ` - ${u.title} (ID: ${u.id})`).join('\n');
754
+ return text(`Multiple users match "${args.user_name}":\n${candidates}\nUse search_contacts to find the exact user, then create_p2p_chat + send_as_user.`);
755
+ }
756
+ const user = users[0];
576
757
  const chatId = await c.createChat(user.id);
577
758
  if (!chatId) return text(`Failed to create chat with ${user.title}`);
578
759
  const r = await c.sendMessage(chatId, args.text);
@@ -581,8 +762,13 @@ async function handleTool(name, args) {
581
762
  case 'send_to_group': {
582
763
  const c = await getUserClient();
583
764
  const results = await c.search(args.group_name);
584
- const group = results.find(r => r.type === 'group');
585
- if (!group) return text(`Group "${args.group_name}" not found. Results: ${JSON.stringify(results)}`);
765
+ const groups = results.filter(r => r.type === 'group');
766
+ if (groups.length === 0) return text(`Group "${args.group_name}" not found. Results: ${JSON.stringify(results)}`);
767
+ if (groups.length > 1) {
768
+ const candidates = groups.slice(0, 5).map(g => ` - ${g.title} (ID: ${g.id})`).join('\n');
769
+ return text(`Multiple groups match "${args.group_name}":\n${candidates}\nUse search_contacts to find the exact group, then send_as_user with the ID.`);
770
+ }
771
+ const group = groups[0];
586
772
  const r = await c.sendMessage(group.id, args.text);
587
773
  return sendResult(r, `Text sent to group "${group.title}" (${group.id})`);
588
774
  }
@@ -627,26 +813,37 @@ async function handleTool(name, args) {
627
813
  return text(chatId ? `P2P chat: ${chatId}` : 'Failed to create P2P chat');
628
814
  }
629
815
  case 'get_chat_info': {
630
- const c = await getUserClient();
631
- const info = await c.getGroupInfo(args.chat_id);
632
- return info ? json(info) : text(`No info for chat ${args.chat_id}`);
816
+ // Strategy 1: Official API im.chat.get (supports oc_xxx format)
817
+ if (args.chat_id.startsWith('oc_')) {
818
+ try {
819
+ const info = await getOfficialClient().getChatInfo(args.chat_id);
820
+ return info ? json(info) : text(`No info for chat ${args.chat_id}`);
821
+ } catch (e) {
822
+ console.error(`[feishu-user-plugin] Official getChatInfo failed: ${e.message}`);
823
+ }
824
+ }
825
+ // Strategy 2: Protobuf gateway (supports numeric chat_id)
826
+ try {
827
+ const c = await getUserClient();
828
+ const info = await c.getGroupInfo(args.chat_id);
829
+ if (info) return json(info);
830
+ } catch (e) {
831
+ console.error(`[feishu-user-plugin] Protobuf getChatInfo failed: ${e.message}`);
832
+ }
833
+ return text(`No info for chat ${args.chat_id}`);
633
834
  }
634
835
  case 'get_user_info': {
635
836
  let n = null;
636
- // Strategy 1: User identity client cache
837
+ // Strategy 1: Official API contact lookup (works for same-tenant users by open_id)
637
838
  try {
638
- const c = await getUserClient();
639
- n = await c.getUserName(args.user_id);
640
- if (!n && args.user_id) {
641
- await c.search(args.user_id);
642
- n = await c.getUserName(args.user_id);
643
- }
839
+ const official = getOfficialClient();
840
+ n = await official.getUserById(args.user_id, 'open_id');
644
841
  } catch {}
645
- // Strategy 2: Official API contact lookup (works for same-tenant users)
842
+ // Strategy 2: User identity client cache (populated by previous search/init calls)
646
843
  if (!n) {
647
844
  try {
648
- const official = getOfficialClient();
649
- n = await official.getUserById(args.user_id, 'open_id');
845
+ const c = await getUserClient();
846
+ n = await c.getUserName(args.user_id);
650
847
  } catch {}
651
848
  }
652
849
  return text(n ? `User ${args.user_id}: ${n}` : `Could not resolve user ${args.user_id}. This user may be from an external tenant. Try search_contacts with the user's display name instead.`);
@@ -672,7 +869,8 @@ async function handleTool(name, args) {
672
869
  const official = getOfficialClient();
673
870
  let chatId = args.chat_id;
674
871
  let uc = null;
675
- try { uc = await getUserClient(); } catch (_) {}
872
+ let ucError = null;
873
+ try { uc = await getUserClient(); } catch (e) { ucError = e; }
676
874
  // If chat_id is not numeric or oc_, try to resolve as user name → P2P chat
677
875
  if (!/^\d+$/.test(chatId) && !chatId.startsWith('oc_')) {
678
876
  if (uc) {
@@ -689,7 +887,8 @@ async function handleTool(name, args) {
689
887
  else return text(`Cannot resolve "${args.chat_id}" to a chat. Use search_contacts to find the ID first.`);
690
888
  }
691
889
  } else {
692
- return text(`"${args.chat_id}" is not a valid chat ID. Provide a numeric ID or oc_xxx format. Use search_contacts + create_p2p_chat to get the ID.`);
890
+ const hint = ucError ? `Cookie auth failed: ${ucError.message}. Fix LARK_COOKIE first, or p` : 'P';
891
+ return text(`"${args.chat_id}" is not a valid chat ID. ${hint}rovide a numeric ID or oc_xxx format. Use search_contacts + create_p2p_chat to get the ID.`);
693
892
  }
694
893
  }
695
894
  return json(await official.readMessagesAsUser(chatId, {
@@ -752,15 +951,41 @@ async function handleTool(name, args) {
752
951
  return json(await getOfficialClient().searchDocs(args.query));
753
952
  case 'read_doc':
754
953
  return json(await getOfficialClient().readDoc(args.document_id));
954
+ case 'get_doc_blocks':
955
+ return json(await getOfficialClient().getDocBlocks(args.document_id));
755
956
  case 'create_doc':
756
957
  return text(`Document created: ${(await getOfficialClient().createDoc(args.title, args.folder_id)).documentId}`);
757
958
 
758
959
  // --- Official API: Bitable ---
759
960
 
961
+ case 'create_bitable': {
962
+ const r = await getOfficialClient().createBitable(args.name, args.folder_id);
963
+ return text(`Bitable created: ${r.appToken}${r.url ? `\nURL: ${r.url}` : ''}`);
964
+ }
760
965
  case 'list_bitable_tables':
761
966
  return json(await getOfficialClient().listBitableTables(args.app_token));
967
+ case 'create_bitable_table':
968
+ return text(`Table created: ${(await getOfficialClient().createBitableTable(args.app_token, args.name, args.fields)).tableId}`);
762
969
  case 'list_bitable_fields':
763
970
  return json(await getOfficialClient().listBitableFields(args.app_token, args.table_id));
971
+ case 'create_bitable_field': {
972
+ const config = { field_name: args.field_name, type: args.type };
973
+ if (args.property) config.property = args.property;
974
+ return json(await getOfficialClient().createBitableField(args.app_token, args.table_id, config));
975
+ }
976
+ case 'update_bitable_field': {
977
+ const config = {};
978
+ if (args.field_name) config.field_name = args.field_name;
979
+ if (args.type) config.type = args.type;
980
+ if (args.property) config.property = args.property;
981
+ return json(await getOfficialClient().updateBitableField(args.app_token, args.table_id, args.field_id, config));
982
+ }
983
+ case 'delete_bitable_field': {
984
+ const r = await getOfficialClient().deleteBitableField(args.app_token, args.table_id, args.field_id);
985
+ return text(r.deleted ? `Field ${r.fieldId} deleted` : `Field deletion returned deleted=${r.deleted}`);
986
+ }
987
+ case 'list_bitable_views':
988
+ return json(await getOfficialClient().listBitableViews(args.app_token, args.table_id));
764
989
  case 'search_bitable_records':
765
990
  return json(await getOfficialClient().searchBitableRecords(args.app_token, args.table_id, {
766
991
  filter: args.filter, sort: args.sort, pageSize: args.page_size,
@@ -769,6 +994,14 @@ async function handleTool(name, args) {
769
994
  return text(`Record created: ${(await getOfficialClient().createBitableRecord(args.app_token, args.table_id, args.fields)).recordId}`);
770
995
  case 'update_bitable_record':
771
996
  return text(`Record updated: ${(await getOfficialClient().updateBitableRecord(args.app_token, args.table_id, args.record_id, args.fields)).recordId}`);
997
+ case 'delete_bitable_record':
998
+ return text(`Record deleted: ${(await getOfficialClient().deleteBitableRecord(args.app_token, args.table_id, args.record_id)).deleted}`);
999
+ case 'batch_create_bitable_records':
1000
+ return json(await getOfficialClient().batchCreateBitableRecords(args.app_token, args.table_id, args.records));
1001
+ case 'batch_update_bitable_records':
1002
+ return json(await getOfficialClient().batchUpdateBitableRecords(args.app_token, args.table_id, args.records));
1003
+ case 'batch_delete_bitable_records':
1004
+ return json(await getOfficialClient().batchDeleteBitableRecords(args.app_token, args.table_id, args.record_ids));
772
1005
 
773
1006
  // --- Official API: Wiki ---
774
1007
 
@@ -791,6 +1024,17 @@ async function handleTool(name, args) {
791
1024
  case 'find_user':
792
1025
  return json(await getOfficialClient().findUserByIdentity({ emails: args.email, mobiles: args.mobile }));
793
1026
 
1027
+ // --- Upload ---
1028
+
1029
+ case 'upload_image': {
1030
+ const r = await getOfficialClient().uploadImage(args.image_path, args.image_type);
1031
+ return text(`Image uploaded: ${r.imageKey}\nUse this image_key with send_image_as_user to send it.`);
1032
+ }
1033
+ case 'upload_file': {
1034
+ const r = await getOfficialClient().uploadFile(args.file_path, args.file_type, args.file_name);
1035
+ return text(`File uploaded: ${r.fileKey}\nUse this file_key with send_file_as_user to send it.`);
1036
+ }
1037
+
794
1038
  default:
795
1039
  return text(`Unknown tool: ${name}`);
796
1040
  }
@@ -804,7 +1048,7 @@ async function main() {
804
1048
  const hasCookie = !!process.env.LARK_COOKIE;
805
1049
  const hasApp = !!(process.env.LARK_APP_ID && process.env.LARK_APP_SECRET);
806
1050
  const hasUAT = !!process.env.LARK_USER_ACCESS_TOKEN;
807
- console.error(`[feishu-user-plugin] MCP Server v1.1.3 — ${TOOLS.length} tools`);
1051
+ console.error(`[feishu-user-plugin] MCP Server v${require('../package.json').version} — ${TOOLS.length} tools`);
808
1052
  console.error(`[feishu-user-plugin] Auth: Cookie=${hasCookie ? 'YES' : 'NO'} App=${hasApp ? 'YES' : 'NO'} UAT=${hasUAT ? 'YES' : 'NO'}`);
809
1053
  if (!hasCookie) console.error('[feishu-user-plugin] WARNING: LARK_COOKIE not set — user identity tools (send_to_user, etc.) will fail');
810
1054
  if (!hasApp) console.error('[feishu-user-plugin] WARNING: LARK_APP_ID/SECRET not set — official API tools (read_messages, docs, etc.) will fail');
package/src/oauth-auto.js CHANGED
@@ -1,5 +1,7 @@
1
1
  #!/usr/bin/env node
2
- // Automated OAuth flow using Playwright single page, no extra tabs
2
+ // DEV ONLY: Automated OAuth using local Playwright (not used in production).
3
+ // Uses .env directly; not migrated to config module.
4
+ // Requires: npm install playwright (not in package.json dependencies)
3
5
  const http = require('http');
4
6
  const { chromium } = require('playwright');
5
7
  const fs = require('fs');
@@ -30,7 +32,7 @@ function saveToken(tokenData) {
30
32
  LARK_USER_ACCESS_TOKEN: tokenData.access_token,
31
33
  LARK_USER_REFRESH_TOKEN: tokenData.refresh_token || '',
32
34
  LARK_UAT_SCOPE: tokenData.scope || '',
33
- LARK_UAT_EXPIRES: String(Math.floor(Date.now() / 1000 + tokenData.expires_in)),
35
+ LARK_UAT_EXPIRES: String(Math.floor(Date.now() / 1000 + (typeof tokenData.expires_in === 'number' && tokenData.expires_in > 0 ? tokenData.expires_in : 7200))),
34
36
  };
35
37
  for (const [key, val] of Object.entries(updates)) {
36
38
  const regex = new RegExp(`^${key}=.*$`, 'm');
@@ -161,29 +163,6 @@ async function run() {
161
163
  console.log('scope:', tokenData.scope);
162
164
  console.log('expires_in:', tokenData.expires_in, 's');
163
165
 
164
- // Test P2P message reading
165
- console.log('\n[test] Testing P2P message reading...');
166
- const testRes = await fetch('https://open.feishu.cn/open-apis/im/v1/messages?container_id_type=chat&container_id=oc_97a52756ee2c4351a2a86e6aa33e8ca4&page_size=2&sort_type=ByCreateTimeDesc', {
167
- headers: { 'Authorization': `Bearer ${tokenData.access_token}` },
168
- });
169
- const testData = await testRes.json();
170
- if (testData.code === 0) {
171
- console.log('[test] P2P: SUCCESS!', testData.data?.items?.length, 'messages');
172
- } else {
173
- console.log('[test] P2P: Error', testData.code, testData.msg);
174
- }
175
-
176
- // Test group messages
177
- const grpRes = await fetch('https://open.feishu.cn/open-apis/im/v1/messages?container_id_type=chat&container_id=oc_6ae081b457d07e9651d615493b7f1096&page_size=2&sort_type=ByCreateTimeDesc', {
178
- headers: { 'Authorization': `Bearer ${tokenData.access_token}` },
179
- });
180
- const grpData = await grpRes.json();
181
- if (grpData.code === 0) {
182
- console.log('[test] Group: SUCCESS!', grpData.data?.items?.length, 'messages');
183
- } else {
184
- console.log('[test] Group: Error', grpData.code, grpData.msg);
185
- }
186
-
187
166
  } catch (e) {
188
167
  console.error('\nError:', e.message);
189
168
  await page.screenshot({ path: '/tmp/feishu-oauth-error.png' }).catch(() => {});
package/src/oauth.js CHANGED
@@ -2,33 +2,31 @@
2
2
  /**
3
3
  * OAuth 授权脚本 — 获取带 IM 权限的 user_access_token
4
4
  *
5
- * 用法: node src/oauth.js
5
+ * 用法: npx feishu-user-plugin oauth
6
6
  *
7
7
  * 流程 (新版 End User Consent):
8
8
  * 1. 查询应用信息,提示用户选择正确的飞书账号
9
9
  * 2. 启动本地 HTTP 服务器 (端口 9997)
10
10
  * 3. 打开 accounts.feishu.cn 授权页面 (新版 OAuth 2.0)
11
11
  * 4. 用户点击"授权"后,用 /authen/v2/oauth/token 交换 token
12
- * 5. 保存 access_token + refresh_token 到 .env
12
+ * 5. 保存 token MCP 配置文件
13
13
  */
14
14
 
15
15
  const http = require('http');
16
16
  const { execSync } = require('child_process');
17
- const fs = require('fs');
18
- const path = require('path');
19
- const dotenv = require('dotenv');
17
+ const { readCredentials, persistToConfig } = require('./config');
20
18
 
21
- dotenv.config({ path: path.join(__dirname, '..', '.env') });
22
-
23
- const APP_ID = process.env.LARK_APP_ID;
24
- const APP_SECRET = process.env.LARK_APP_SECRET;
19
+ const creds = readCredentials();
20
+ const APP_ID = creds.LARK_APP_ID;
21
+ const APP_SECRET = creds.LARK_APP_SECRET;
25
22
  const PORT = 9997;
26
23
  const REDIRECT_URI = `http://127.0.0.1:${PORT}/callback`;
27
24
  // offline_access is required to get refresh_token for auto-renewal
28
25
  const SCOPES = 'offline_access im:message im:message:readonly im:chat:readonly contact:user.base:readonly';
29
26
 
30
27
  if (!APP_ID || !APP_SECRET) {
31
- console.error('Missing LARK_APP_ID or LARK_APP_SECRET in .env');
28
+ console.error('Missing LARK_APP_ID or LARK_APP_SECRET.');
29
+ console.error('Run "npx feishu-user-plugin setup" first to configure app credentials.');
32
30
  process.exit(1);
33
31
  }
34
32
 
@@ -100,10 +98,6 @@ async function exchangeCode(code) {
100
98
  }
101
99
 
102
100
  function saveToken(tokenData) {
103
- const envPath = path.join(__dirname, '..', '.env');
104
- let envContent = '';
105
- try { envContent = fs.readFileSync(envPath, 'utf8'); } catch {}
106
-
107
101
  const updates = {
108
102
  LARK_USER_ACCESS_TOKEN: tokenData.access_token,
109
103
  LARK_USER_REFRESH_TOKEN: tokenData.refresh_token || '',
@@ -111,44 +105,13 @@ function saveToken(tokenData) {
111
105
  LARK_UAT_EXPIRES: String(Math.floor(Date.now() / 1000 + (typeof tokenData.expires_in === 'number' && tokenData.expires_in > 0 ? tokenData.expires_in : 7200))),
112
106
  };
113
107
 
114
- for (const [key, val] of Object.entries(updates)) {
115
- const regex = new RegExp(`^${key}=.*$`, 'm');
116
- if (regex.test(envContent)) {
117
- envContent = envContent.replace(regex, `${key}=${val}`);
118
- } else {
119
- envContent += `\n${key}=${val}`;
108
+ const ok = persistToConfig(updates);
109
+ if (!ok) {
110
+ console.error('WARNING: Tokens could not be saved to config. Copy them manually:');
111
+ for (const [k, v] of Object.entries(updates)) {
112
+ console.error(` ${k}=${v}`);
120
113
  }
121
114
  }
122
-
123
- fs.writeFileSync(envPath, envContent.trim() + '\n');
124
-
125
- // Also persist to ~/.claude.json MCP config so MCP restart picks up tokens immediately
126
- _persistToClaudeJson(updates);
127
- }
128
-
129
- function _persistToClaudeJson(updates) {
130
- const claudeJsonPaths = [
131
- path.join(process.env.HOME || '', '.claude.json'),
132
- path.join(process.env.HOME || '', '.claude', '.claude.json'),
133
- ];
134
- for (const cjPath of claudeJsonPaths) {
135
- try {
136
- const raw = fs.readFileSync(cjPath, 'utf8');
137
- const config = JSON.parse(raw);
138
- const servers = config.mcpServers || {};
139
- for (const name of ['feishu-user-plugin', 'feishu']) {
140
- if (servers[name]?.env) {
141
- Object.assign(servers[name].env, updates);
142
- fs.writeFileSync(cjPath, JSON.stringify(config, null, 2) + '\n');
143
- console.log(`[feishu-user-plugin] OAuth tokens persisted to ${cjPath}`);
144
- return;
145
- }
146
- }
147
- } catch (e) {
148
- console.error(`[feishu-user-plugin] Failed to persist tokens to ${cjPath}: ${e.message}`);
149
- }
150
- }
151
- console.error('[feishu-user-plugin] WARNING: Could not persist tokens to ~/.claude.json. Tokens saved to .env only — copy them to your MCP config manually.');
152
115
  }
153
116
 
154
117
  const server = http.createServer(async (req, res) => {
@@ -173,7 +136,7 @@ const server = http.createServer(async (req, res) => {
173
136
  <p>scope: ${tokenData.scope}</p>
174
137
  <p>expires_in: ${tokenData.expires_in}s</p>
175
138
  <p>refresh_token: ${hasRefresh ? '✅ 已获取(30天有效,支持自动续期)' : '❌ 未返回(token 将在 2 小时后过期,需重新授权)'}</p>
176
- <p>已保存到 .env,可以关闭此页面。</p>`);
139
+ <p>已保存到 MCP 配置文件,可以关闭此页面。</p>`);
177
140
 
178
141
  console.log('\n=== OAuth 授权成功 ===');
179
142
  console.log('scope:', tokenData.scope);
@@ -185,7 +148,7 @@ const server = http.createServer(async (req, res) => {
185
148
  console.log(' - 授权时 scope 中未包含 offline_access');
186
149
  console.log(' Token 将在 2 小时后过期,届时需要重新运行此脚本。');
187
150
  }
188
- console.log('token 已保存到 .env');
151
+ console.log('token 已保存到 MCP 配置文件');
189
152
 
190
153
  setTimeout(() => { server.close(); process.exit(0); }, 1000);
191
154
  } catch (e) {
@@ -200,6 +163,16 @@ const server = http.createServer(async (req, res) => {
200
163
  res.end('Not found');
201
164
  });
202
165
 
166
+ server.on('error', (e) => {
167
+ if (e.code === 'EADDRINUSE') {
168
+ console.error(`\nPort ${PORT} is already in use. Another OAuth process may be running.`);
169
+ console.error('Wait a minute and try again, or kill the process using the port.');
170
+ } else {
171
+ console.error('Server error:', e.message);
172
+ }
173
+ process.exit(1);
174
+ });
175
+
203
176
  server.listen(PORT, '127.0.0.1', async () => {
204
177
  const authUrl = `https://accounts.feishu.cn/open-apis/authen/v1/authorize?client_id=${APP_ID}&redirect_uri=${encodeURIComponent(REDIRECT_URI)}&response_type=code&scope=${encodeURIComponent(SCOPES)}`;
205
178
 
@@ -229,7 +202,8 @@ server.listen(PORT, '127.0.0.1', async () => {
229
202
  console.log('授权 URL:', authUrl);
230
203
 
231
204
  try {
232
- execSync(`open "${authUrl}"`);
205
+ const openCmd = process.platform === 'win32' ? 'start' : process.platform === 'darwin' ? 'open' : 'xdg-open';
206
+ execSync(`${openCmd} "${authUrl}"`);
233
207
  } catch {
234
208
  console.log('\n请手动在浏览器中打开上面的 URL');
235
209
  }