feishu-user-plugin 1.1.3 → 1.2.0

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.
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "feishu-user-plugin",
3
- "version": "1.1.3",
3
+ "version": "1.2.0",
4
4
  "description": "All-in-one Feishu plugin for Claude Code — send messages as yourself, read chats, manage docs/tables/wiki. 33 tools + 9 skills, 3 auth layers.",
5
5
  "author": {
6
6
  "name": "EthanQC"
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "feishu-user-plugin",
3
- "version": "1.1.3",
3
+ "version": "1.2.0",
4
4
  "description": "All-in-one Feishu plugin for Claude Code — send messages as yourself, read chats, manage docs/tables/wiki. 33 tools + 9 skills, 3 auth layers.",
5
5
  "main": "src/index.js",
6
6
  "bin": {
package/src/cli.js CHANGED
@@ -7,6 +7,7 @@
7
7
  * npx feishu-user-plugin setup → Interactive setup wizard
8
8
  * npx feishu-user-plugin oauth → Run OAuth flow for UAT
9
9
  * npx feishu-user-plugin status → Check auth status
10
+ * npx feishu-user-plugin keepalive → Refresh cookie + UAT (for cron)
10
11
  */
11
12
 
12
13
  const cmd = process.argv[2];
@@ -21,6 +22,9 @@ switch (cmd) {
21
22
  case 'status':
22
23
  checkStatus();
23
24
  break;
25
+ case 'keepalive':
26
+ keepalive();
27
+ break;
24
28
  case 'help':
25
29
  case '--help':
26
30
  case '-h':
@@ -41,6 +45,7 @@ Commands:
41
45
  setup Interactive setup wizard — writes MCP config
42
46
  oauth Run OAuth flow to obtain user_access_token
43
47
  status Check authentication status
48
+ keepalive Refresh cookie + UAT to prevent expiration (for cron jobs)
44
49
  help Show this help
45
50
 
46
51
  Quick Start (team members):
@@ -53,20 +58,85 @@ Quick Start (external users):
53
58
  2. npx feishu-user-plugin setup
54
59
  3. npx feishu-user-plugin oauth
55
60
  4. Restart Claude Code
61
+
62
+ Auto-renewal (optional):
63
+ Add to crontab to keep tokens alive even when Claude Code is closed:
64
+ crontab -e → add: 0 */4 * * * npx feishu-user-plugin keepalive >> /tmp/feishu-keepalive.log 2>&1
56
65
  `);
57
66
  }
58
67
 
68
+ async function keepalive() {
69
+ const { LarkUserClient } = require('./client');
70
+ const { LarkOfficialClient } = require('./official');
71
+ const { findMcpConfig, persistToConfig } = require('./config');
72
+
73
+ const found = findMcpConfig();
74
+ if (!found) {
75
+ console.error('[keepalive] No config found. Run: npx feishu-user-plugin setup');
76
+ process.exit(1);
77
+ }
78
+ const creds = found.serverEnv;
79
+ let ok = true;
80
+
81
+ // 1. Refresh Cookie
82
+ const cookie = creds.LARK_COOKIE;
83
+ if (cookie && cookie !== 'SETUP_NEEDED') {
84
+ try {
85
+ const client = new LarkUserClient(cookie);
86
+ await client.init();
87
+ // init() calls _getCsrfToken which refreshes sl_session
88
+ persistToConfig({ LARK_COOKIE: client.cookieStr });
89
+ console.log(`[keepalive] Cookie refreshed (user: ${client.userName})`);
90
+ } catch (e) {
91
+ console.error(`[keepalive] Cookie refresh FAILED: ${e.message}`);
92
+ ok = false;
93
+ }
94
+ }
95
+
96
+ // 2. Refresh UAT
97
+ const appId = creds.LARK_APP_ID;
98
+ const appSecret = creds.LARK_APP_SECRET;
99
+ const uat = creds.LARK_USER_ACCESS_TOKEN;
100
+ const rt = creds.LARK_USER_REFRESH_TOKEN;
101
+ if (appId && appSecret && uat && uat !== 'SETUP_NEEDED' && rt) {
102
+ try {
103
+ const official = new LarkOfficialClient(appId, appSecret);
104
+ official._uat = uat;
105
+ official._uatRefresh = rt;
106
+ official._uatExpires = 0; // force refresh
107
+ await official._refreshUAT(); // refreshes + persists automatically
108
+ console.log('[keepalive] UAT refreshed');
109
+ } catch (e) {
110
+ console.error(`[keepalive] UAT refresh FAILED: ${e.message}`);
111
+ ok = false;
112
+ }
113
+ }
114
+
115
+ if (ok) {
116
+ console.log('[keepalive] All tokens refreshed successfully');
117
+ }
118
+ process.exit(ok ? 0 : 1);
119
+ }
120
+
59
121
  async function checkStatus() {
60
122
  const { LarkUserClient } = require('./client');
61
123
  const { LarkOfficialClient } = require('./official');
62
- const path = require('path');
63
- require('dotenv').config({ path: path.join(__dirname, '..', '.env') });
124
+ const { findMcpConfig } = require('./config');
125
+
126
+ const found = findMcpConfig();
127
+ const creds = found ? found.serverEnv : {};
64
128
 
65
129
  console.log('=== feishu-user-plugin Auth Status ===\n');
130
+ if (found) {
131
+ console.log(`Config: ${found.configPath}${found.projectPath ? ` (project: ${found.projectPath})` : ''}`);
132
+ } else {
133
+ console.log('Config: NOT FOUND (run: npx feishu-user-plugin setup)');
134
+ }
135
+ console.log('');
66
136
 
67
137
  // Cookie
68
- const cookie = process.env.LARK_COOKIE;
69
- if (cookie) {
138
+ const cookie = creds.LARK_COOKIE;
139
+ if (cookie && cookie !== 'SETUP_NEEDED') {
70
140
  try {
71
141
  const client = new LarkUserClient(cookie);
72
142
  await client.init();
@@ -79,21 +149,24 @@ async function checkStatus() {
79
149
  }
80
150
 
81
151
  // App credentials
82
- const appId = process.env.LARK_APP_ID;
83
- const appSecret = process.env.LARK_APP_SECRET;
152
+ const appId = creds.LARK_APP_ID;
153
+ const appSecret = creds.LARK_APP_SECRET;
84
154
  console.log(`App credentials: ${appId && appSecret ? 'OK' : 'NOT SET'}`);
85
155
 
86
156
  // UAT
87
- const uat = process.env.LARK_USER_ACCESS_TOKEN;
88
- const rt = process.env.LARK_USER_REFRESH_TOKEN;
89
- if (uat) {
157
+ const uat = creds.LARK_USER_ACCESS_TOKEN;
158
+ const rt = creds.LARK_USER_REFRESH_TOKEN;
159
+ if (uat && uat !== 'SETUP_NEEDED') {
90
160
  console.log(`UAT: SET (refresh_token: ${rt ? 'YES' : 'NO'})`);
91
161
  if (appId && appSecret) {
92
162
  const official = new LarkOfficialClient(appId, appSecret);
93
- official.loadUAT();
163
+ // Set UAT fields directly (bypassing loadUAT which reads from process.env)
164
+ official._uat = uat;
165
+ official._uatRefresh = rt || null;
166
+ official._uatExpires = parseInt(creds.LARK_UAT_EXPIRES || '0');
94
167
  try {
95
- const chats = await official.listChatsAsUser({ pageSize: 1 });
96
- console.log(` UAT test: OK (can list chats)`);
168
+ await official.listChatsAsUser({ pageSize: 1 });
169
+ console.log(' UAT test: OK (can list chats)');
97
170
  } catch (e) {
98
171
  console.log(` UAT test: FAILED — ${e.message}`);
99
172
  }
package/src/client.js CHANGED
@@ -90,7 +90,10 @@ class LarkUserClient {
90
90
  this._heartbeatTimer = setInterval(async () => {
91
91
  try {
92
92
  await this._getCsrfToken();
93
- console.error('[feishu-user-plugin] Cookie heartbeat: session refreshed');
93
+ // Lazy require to avoid circular dependency at module load time
94
+ const { persistToConfig } = require('./config');
95
+ persistToConfig({ LARK_COOKIE: this.cookieStr });
96
+ console.error('[feishu-user-plugin] Cookie heartbeat: session refreshed and persisted');
94
97
  } catch (e) {
95
98
  console.error('[feishu-user-plugin] Cookie heartbeat failed:', e.message);
96
99
  }
@@ -252,7 +255,8 @@ class LarkUserClient {
252
255
  const propBuf = this._encode('TextProperty', { content: elem.userId });
253
256
  dictionary[elemId] = { tag: 5, property: propBuf };
254
257
  } else if (elem.tag === 'a') {
255
- const propBuf = this._encode('TextProperty', { content: elem.text || elem.href });
258
+ // Link element: content stores the URL, display text goes through innerText
259
+ const propBuf = this._encode('TextProperty', { content: elem.href || elem.text || '' });
256
260
  dictionary[elemId] = { tag: 6, property: propBuf };
257
261
  }
258
262
  }
package/src/config.js ADDED
@@ -0,0 +1,188 @@
1
+ const fs = require('fs');
2
+ const path = require('path');
3
+
4
+ const SERVER_NAMES = ['feishu-user-plugin', 'feishu'];
5
+
6
+ /**
7
+ * Search an mcpServers object for a feishu-user-plugin entry.
8
+ * Returns { serverName, serverEnv } or null.
9
+ */
10
+ function _findInServers(servers) {
11
+ if (!servers || typeof servers !== 'object') return null;
12
+ for (const name of SERVER_NAMES) {
13
+ if (servers[name]) {
14
+ if (!servers[name].env) servers[name].env = {};
15
+ return { serverName: name, serverEnv: servers[name].env };
16
+ }
17
+ }
18
+ return null;
19
+ }
20
+
21
+ /**
22
+ * Discover the MCP config file containing feishu-user-plugin server entry.
23
+ *
24
+ * Search order:
25
+ * 1. ~/.claude.json — top-level mcpServers
26
+ * 2. ~/.claude.json — projects[*].mcpServers (Claude Code project-level config)
27
+ * 3. ~/.claude/.claude.json — same two-level search
28
+ * 4. <cwd>/.mcp.json — top-level mcpServers (reliable in CLI mode)
29
+ *
30
+ * Returns { configPath, config, serverName, serverEnv, projectPath? } or null.
31
+ */
32
+ function findMcpConfig() {
33
+ const home = process.env.HOME;
34
+ const candidates = [
35
+ ...(home ? [
36
+ path.join(home, '.claude.json'),
37
+ path.join(home, '.claude', '.claude.json'),
38
+ ] : []),
39
+ path.join(process.cwd(), '.mcp.json'),
40
+ ];
41
+
42
+ for (const configPath of candidates) {
43
+ try {
44
+ const raw = fs.readFileSync(configPath, 'utf8');
45
+ const config = JSON.parse(raw);
46
+
47
+ // Strategy 1: top-level mcpServers
48
+ const topLevel = _findInServers(config.mcpServers);
49
+ if (topLevel) {
50
+ return { configPath, config, ...topLevel, projectPath: null };
51
+ }
52
+
53
+ // Strategy 2: projects[*].mcpServers (Claude Code nests project-level config here)
54
+ if (config.projects) {
55
+ for (const [projPath, projConfig] of Object.entries(config.projects)) {
56
+ const nested = _findInServers(projConfig.mcpServers);
57
+ if (nested) {
58
+ return { configPath, config, ...nested, projectPath: projPath };
59
+ }
60
+ }
61
+ }
62
+
63
+ // Strategy 3: .mcp.json uses top-level keys as server names (no mcpServers wrapper)
64
+ const bare = _findInServers(config);
65
+ if (bare) {
66
+ return { configPath, config, ...bare, projectPath: null };
67
+ }
68
+ } catch (e) {
69
+ // Only warn if the file exists but is invalid (not for missing files)
70
+ if (e.code !== 'ENOENT') {
71
+ console.error(`[feishu-user-plugin] Warning: Failed to parse ${configPath}: ${e.message}`);
72
+ }
73
+ }
74
+ }
75
+ return null;
76
+ }
77
+
78
+ /**
79
+ * Read all LARK_* credentials from the discovered MCP config.
80
+ * Returns an object with all env vars, or {} if no config found.
81
+ */
82
+ function readCredentials() {
83
+ const found = findMcpConfig();
84
+ if (!found) return {};
85
+ return { ...found.serverEnv };
86
+ }
87
+
88
+ /**
89
+ * Persist key-value updates into the MCP config's env block.
90
+ * Uses findMcpConfig() to locate the correct entry, then writes back.
91
+ * Returns true if persisted successfully, false otherwise.
92
+ */
93
+ function persistToConfig(updates) {
94
+ try {
95
+ const found = findMcpConfig();
96
+ if (!found) {
97
+ console.error('[feishu-user-plugin] WARNING: No MCP config found. Update your config manually.');
98
+ return false;
99
+ }
100
+
101
+ const { configPath, config, serverName, projectPath } = found;
102
+
103
+ // Navigate to the correct env object
104
+ let env;
105
+ if (projectPath) {
106
+ env = config.projects[projectPath].mcpServers[serverName].env;
107
+ } else if (config.mcpServers?.[serverName]) {
108
+ env = config.mcpServers[serverName].env;
109
+ } else {
110
+ env = config[serverName].env;
111
+ }
112
+
113
+ Object.assign(env, updates);
114
+ fs.writeFileSync(configPath, JSON.stringify(config, null, 2) + '\n');
115
+ console.error(`[feishu-user-plugin] Config persisted to ${configPath}${projectPath ? ` (project: ${projectPath})` : ''}`);
116
+ return true;
117
+ } catch (e) {
118
+ console.error(`[feishu-user-plugin] Failed to persist config: ${e.message}`);
119
+ return false;
120
+ }
121
+ }
122
+
123
+ /**
124
+ * Write a complete feishu-user-plugin MCP server entry to a config file.
125
+ * Used by the setup wizard.
126
+ *
127
+ * If an existing config is found via findMcpConfig(), updates it in-place
128
+ * (preserving its location — top-level or project-level).
129
+ * Otherwise, writes to ~/.claude.json top-level mcpServers.
130
+ *
131
+ * @param {object} env - The env vars to write
132
+ * @param {string} [configPath] - Override the target config file path
133
+ * @param {string} [projectPath] - If writing to a project-level entry
134
+ * @returns {{ configPath: string }} The path that was written
135
+ */
136
+ function writeNewConfig(env, configPath, projectPath) {
137
+ if (!configPath) {
138
+ configPath = path.join(process.env.HOME || '', '.claude.json');
139
+ }
140
+
141
+ if (projectPath) {
142
+ // Verify the project entry still exists; warn if it was removed between discovery and write
143
+ let existing = {};
144
+ try { existing = JSON.parse(fs.readFileSync(configPath, 'utf8')); } catch {}
145
+ if (!existing.projects?.[projectPath]) {
146
+ console.error(`[feishu-user-plugin] Warning: project entry "${projectPath}" not found in ${configPath}, writing to top-level mcpServers`);
147
+ projectPath = null;
148
+ }
149
+ }
150
+
151
+ let config = {};
152
+ try {
153
+ config = JSON.parse(fs.readFileSync(configPath, 'utf8'));
154
+ } catch {}
155
+
156
+ const serverEntry = {
157
+ command: 'npx',
158
+ args: ['-y', 'feishu-user-plugin'],
159
+ env,
160
+ };
161
+
162
+ if (projectPath && config.projects?.[projectPath]) {
163
+ // Write into existing project-level config
164
+ if (!config.projects[projectPath].mcpServers) config.projects[projectPath].mcpServers = {};
165
+ config.projects[projectPath].mcpServers['feishu-user-plugin'] = serverEntry;
166
+ if (config.projects[projectPath].mcpServers.feishu) {
167
+ delete config.projects[projectPath].mcpServers.feishu;
168
+ }
169
+ } else if (configPath.endsWith('.mcp.json') && !config.mcpServers) {
170
+ // Bare .mcp.json format: server entries at top level (no mcpServers wrapper)
171
+ config['feishu-user-plugin'] = serverEntry;
172
+ if (config.feishu) {
173
+ delete config.feishu;
174
+ }
175
+ } else {
176
+ // Write to top-level mcpServers (default for ~/.claude.json)
177
+ if (!config.mcpServers) config.mcpServers = {};
178
+ config.mcpServers['feishu-user-plugin'] = serverEntry;
179
+ if (config.mcpServers.feishu) {
180
+ delete config.mcpServers.feishu;
181
+ }
182
+ }
183
+
184
+ fs.writeFileSync(configPath, JSON.stringify(config, null, 2) + '\n');
185
+ return { configPath };
186
+ }
187
+
188
+ module.exports = { findMcpConfig, readCredentials, persistToConfig, writeNewConfig, SERVER_NAMES };
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');
@@ -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.',
@@ -522,6 +535,33 @@ const TOOLS = [
522
535
  },
523
536
  },
524
537
 
538
+ // ========== Upload — Official API ==========
539
+ {
540
+ name: 'upload_image',
541
+ description: '[Official API] Upload an image file to Feishu. Returns image_key for use with send_image_as_user.',
542
+ inputSchema: {
543
+ type: 'object',
544
+ properties: {
545
+ image_path: { type: 'string', description: 'Absolute path to the image file on disk' },
546
+ image_type: { type: 'string', enum: ['message', 'avatar'], description: 'Image usage type (default: message)' },
547
+ },
548
+ required: ['image_path'],
549
+ },
550
+ },
551
+ {
552
+ name: 'upload_file',
553
+ description: '[Official API] Upload a file to Feishu. Returns file_key for use with send_file_as_user.',
554
+ inputSchema: {
555
+ type: 'object',
556
+ properties: {
557
+ file_path: { type: 'string', description: 'Absolute path to the file on disk' },
558
+ file_type: { type: 'string', enum: ['opus', 'mp4', 'pdf', 'doc', 'xls', 'ppt', 'stream'], description: 'File type (default: stream for generic files)' },
559
+ file_name: { type: 'string', description: 'Display file name (optional, defaults to basename)' },
560
+ },
561
+ required: ['file_path'],
562
+ },
563
+ },
564
+
525
565
  // ========== Contact — Official API ==========
526
566
  {
527
567
  name: 'find_user',
@@ -539,7 +579,7 @@ const TOOLS = [
539
579
  // --- Server ---
540
580
 
541
581
  const server = new Server(
542
- { name: 'feishu-user-plugin', version: '1.1.3' },
582
+ { name: 'feishu-user-plugin', version: require('../package.json').version },
543
583
  { capabilities: { tools: {} } }
544
584
  );
545
585
 
@@ -571,8 +611,13 @@ async function handleTool(name, args) {
571
611
  case 'send_to_user': {
572
612
  const c = await getUserClient();
573
613
  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)}`);
614
+ const users = results.filter(r => r.type === 'user');
615
+ if (users.length === 0) return text(`User "${args.user_name}" not found. Results: ${JSON.stringify(results)}`);
616
+ if (users.length > 1) {
617
+ const candidates = users.slice(0, 5).map(u => ` - ${u.title} (ID: ${u.id})`).join('\n');
618
+ 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.`);
619
+ }
620
+ const user = users[0];
576
621
  const chatId = await c.createChat(user.id);
577
622
  if (!chatId) return text(`Failed to create chat with ${user.title}`);
578
623
  const r = await c.sendMessage(chatId, args.text);
@@ -581,8 +626,13 @@ async function handleTool(name, args) {
581
626
  case 'send_to_group': {
582
627
  const c = await getUserClient();
583
628
  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)}`);
629
+ const groups = results.filter(r => r.type === 'group');
630
+ if (groups.length === 0) return text(`Group "${args.group_name}" not found. Results: ${JSON.stringify(results)}`);
631
+ if (groups.length > 1) {
632
+ const candidates = groups.slice(0, 5).map(g => ` - ${g.title} (ID: ${g.id})`).join('\n');
633
+ 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.`);
634
+ }
635
+ const group = groups[0];
586
636
  const r = await c.sendMessage(group.id, args.text);
587
637
  return sendResult(r, `Text sent to group "${group.title}" (${group.id})`);
588
638
  }
@@ -633,20 +683,16 @@ async function handleTool(name, args) {
633
683
  }
634
684
  case 'get_user_info': {
635
685
  let n = null;
636
- // Strategy 1: User identity client cache
686
+ // Strategy 1: Official API contact lookup (works for same-tenant users by open_id)
637
687
  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
- }
688
+ const official = getOfficialClient();
689
+ n = await official.getUserById(args.user_id, 'open_id');
644
690
  } catch {}
645
- // Strategy 2: Official API contact lookup (works for same-tenant users)
691
+ // Strategy 2: User identity client cache (populated by previous search/init calls)
646
692
  if (!n) {
647
693
  try {
648
- const official = getOfficialClient();
649
- n = await official.getUserById(args.user_id, 'open_id');
694
+ const c = await getUserClient();
695
+ n = await c.getUserName(args.user_id);
650
696
  } catch {}
651
697
  }
652
698
  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 +718,8 @@ async function handleTool(name, args) {
672
718
  const official = getOfficialClient();
673
719
  let chatId = args.chat_id;
674
720
  let uc = null;
675
- try { uc = await getUserClient(); } catch (_) {}
721
+ let ucError = null;
722
+ try { uc = await getUserClient(); } catch (e) { ucError = e; }
676
723
  // If chat_id is not numeric or oc_, try to resolve as user name → P2P chat
677
724
  if (!/^\d+$/.test(chatId) && !chatId.startsWith('oc_')) {
678
725
  if (uc) {
@@ -689,7 +736,8 @@ async function handleTool(name, args) {
689
736
  else return text(`Cannot resolve "${args.chat_id}" to a chat. Use search_contacts to find the ID first.`);
690
737
  }
691
738
  } 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.`);
739
+ const hint = ucError ? `Cookie auth failed: ${ucError.message}. Fix LARK_COOKIE first, or p` : 'P';
740
+ 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
741
  }
694
742
  }
695
743
  return json(await official.readMessagesAsUser(chatId, {
@@ -752,6 +800,8 @@ async function handleTool(name, args) {
752
800
  return json(await getOfficialClient().searchDocs(args.query));
753
801
  case 'read_doc':
754
802
  return json(await getOfficialClient().readDoc(args.document_id));
803
+ case 'get_doc_blocks':
804
+ return json(await getOfficialClient().getDocBlocks(args.document_id));
755
805
  case 'create_doc':
756
806
  return text(`Document created: ${(await getOfficialClient().createDoc(args.title, args.folder_id)).documentId}`);
757
807
 
@@ -791,6 +841,17 @@ async function handleTool(name, args) {
791
841
  case 'find_user':
792
842
  return json(await getOfficialClient().findUserByIdentity({ emails: args.email, mobiles: args.mobile }));
793
843
 
844
+ // --- Upload ---
845
+
846
+ case 'upload_image': {
847
+ const r = await getOfficialClient().uploadImage(args.image_path, args.image_type);
848
+ return text(`Image uploaded: ${r.imageKey}\nUse this image_key with send_image_as_user to send it.`);
849
+ }
850
+ case 'upload_file': {
851
+ const r = await getOfficialClient().uploadFile(args.file_path, args.file_type, args.file_name);
852
+ return text(`File uploaded: ${r.fileKey}\nUse this file_key with send_file_as_user to send it.`);
853
+ }
854
+
794
855
  default:
795
856
  return text(`Unknown tool: ${name}`);
796
857
  }
@@ -804,7 +865,7 @@ async function main() {
804
865
  const hasCookie = !!process.env.LARK_COOKIE;
805
866
  const hasApp = !!(process.env.LARK_APP_ID && process.env.LARK_APP_SECRET);
806
867
  const hasUAT = !!process.env.LARK_USER_ACCESS_TOKEN;
807
- console.error(`[feishu-user-plugin] MCP Server v1.1.3 — ${TOOLS.length} tools`);
868
+ console.error(`[feishu-user-plugin] MCP Server v${require('../package.json').version} — ${TOOLS.length} tools`);
808
869
  console.error(`[feishu-user-plugin] Auth: Cookie=${hasCookie ? 'YES' : 'NO'} App=${hasApp ? 'YES' : 'NO'} UAT=${hasUAT ? 'YES' : 'NO'}`);
809
870
  if (!hasCookie) console.error('[feishu-user-plugin] WARNING: LARK_COOKIE not set — user identity tools (send_to_user, etc.) will fail');
810
871
  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
  }
package/src/official.js CHANGED
@@ -66,77 +66,23 @@ class LarkOfficialClient {
66
66
  }
67
67
 
68
68
  _persistUAT() {
69
- const fs = require('fs');
70
- const path = require('path');
71
- const updates = {
69
+ // Lazy require to avoid circular dependency at module load time
70
+ const { persistToConfig } = require('./config');
71
+ persistToConfig({
72
72
  LARK_USER_ACCESS_TOKEN: this._uat,
73
73
  LARK_USER_REFRESH_TOKEN: this._uatRefresh,
74
74
  LARK_UAT_EXPIRES: String(this._uatExpires),
75
- };
76
-
77
- // Strategy 1: Update ~/.claude.json MCP config (works for npx users)
78
- const claudeJsonPaths = [
79
- path.join(process.env.HOME || '', '.claude.json'),
80
- path.join(process.env.HOME || '', '.claude', '.claude.json'),
81
- ];
82
- for (const cjPath of claudeJsonPaths) {
83
- try {
84
- const raw = fs.readFileSync(cjPath, 'utf8');
85
- const config = JSON.parse(raw);
86
- const servers = config.mcpServers || {};
87
- // Find our server entry by name
88
- for (const name of ['feishu-user-plugin', 'feishu']) {
89
- if (servers[name]?.env) {
90
- Object.assign(servers[name].env, updates);
91
- fs.writeFileSync(cjPath, JSON.stringify(config, null, 2) + '\n');
92
- console.error('[feishu-user-plugin] UAT persisted to', cjPath);
93
- return;
94
- }
95
- }
96
- } catch {}
97
- }
98
-
99
- // Strategy 2: Update project .mcp.json
100
- const mcpJsonPaths = [
101
- path.join(process.cwd(), '.mcp.json'),
102
- ];
103
- for (const mjPath of mcpJsonPaths) {
104
- try {
105
- const raw = fs.readFileSync(mjPath, 'utf8');
106
- const config = JSON.parse(raw);
107
- const servers = config.mcpServers || config;
108
- for (const name of ['feishu-user-plugin', 'feishu']) {
109
- if (servers[name]?.env) {
110
- Object.assign(servers[name].env, updates);
111
- fs.writeFileSync(mjPath, JSON.stringify(config, null, 2) + '\n');
112
- console.error('[feishu-user-plugin] UAT persisted to', mjPath);
113
- return;
114
- }
115
- }
116
- } catch {}
117
- }
118
-
119
- // Strategy 3: Fallback to .env in project root (for local dev)
120
- const envPath = path.join(__dirname, '..', '.env');
121
- try {
122
- let env = '';
123
- try { env = fs.readFileSync(envPath, 'utf8'); } catch {}
124
- for (const [key, val] of Object.entries(updates)) {
125
- const regex = new RegExp(`^${key}=.*$`, 'm');
126
- if (regex.test(env)) env = env.replace(regex, `${key}=${val}`);
127
- else env += `\n${key}=${val}`;
128
- }
129
- fs.writeFileSync(envPath, env.trim() + '\n');
130
- } catch {}
75
+ });
131
76
  }
132
77
 
133
78
  // --- UAT-based IM operations (for P2P chats) ---
134
79
 
135
- // Wrapper: call fn with UAT, retry once after refresh if auth fails (code 99991668/99991663)
80
+ // Wrapper: call fn with UAT, retry once after refresh if auth fails
136
81
  async _withUAT(fn) {
137
82
  let uat = await this._getValidUAT();
138
83
  const data = await fn(uat);
139
- if (data.code === 99991668 || data.code === 99991663) {
84
+ // Known auth error codes: 99991668 (invalid), 99991663 (expired), 99991677 (auth_expired)
85
+ if (data.code === 99991668 || data.code === 99991663 || data.code === 99991677) {
140
86
  // Token invalid/expired — try refresh once
141
87
  uat = await this._refreshUAT();
142
88
  return fn(uat);
@@ -231,6 +177,36 @@ class LarkOfficialClient {
231
177
  return { messageId: res.data.message_id };
232
178
  }
233
179
 
180
+ // --- Upload ---
181
+
182
+ async uploadImage(imagePath, imageType = 'message') {
183
+ const fs = require('fs');
184
+ const res = await this._safeSDKCall(
185
+ () => this.client.im.image.create({
186
+ data: { image_type: imageType, image: fs.createReadStream(imagePath) },
187
+ }),
188
+ 'uploadImage'
189
+ );
190
+ return { imageKey: res.data.image_key };
191
+ }
192
+
193
+ async uploadFile(filePath, fileType = 'stream', fileName) {
194
+ const fs = require('fs');
195
+ const path = require('path');
196
+ if (!fileName) fileName = path.basename(filePath);
197
+ const res = await this._safeSDKCall(
198
+ () => this.client.im.file.create({
199
+ data: {
200
+ file_type: fileType,
201
+ file_name: fileName,
202
+ file: fs.createReadStream(filePath),
203
+ },
204
+ }),
205
+ 'uploadFile'
206
+ );
207
+ return { fileKey: res.data.file_key };
208
+ }
209
+
234
210
  // --- Docs ---
235
211
 
236
212
  async searchDocs(query, { pageSize = 10, pageToken } = {}) {
@@ -442,9 +418,9 @@ class LarkOfficialClient {
442
418
  unknownIds.add(item.senderId);
443
419
  }
444
420
  }
445
- // Batch resolve via official contact API
446
- for (const id of unknownIds) {
447
- await this.getUserById(id);
421
+ // Parallel resolve via official contact API (instead of sequential N calls)
422
+ if (unknownIds.size > 0) {
423
+ await Promise.allSettled([...unknownIds].map(id => this.getUserById(id)));
448
424
  }
449
425
  // Fallback: resolve remaining unknowns via cookie-based user identity client
450
426
  if (userClient) {
package/src/setup.js CHANGED
@@ -1,67 +1,92 @@
1
1
  #!/usr/bin/env node
2
2
  /**
3
- * Interactive setup wizard for feishu-user-plugin
3
+ * Setup wizard for feishu-user-plugin
4
4
  *
5
- * Writes MCP config to ~/.claude.json (or .mcp.json) with credentials.
6
- * Does NOT require cloning the repo.
5
+ * Two modes:
6
+ * Interactive: npx feishu-user-plugin setup
7
+ * Non-interactive: npx feishu-user-plugin setup --app-id xxx --app-secret yyy
8
+ *
9
+ * Writes MCP config to ~/.claude.json top-level mcpServers (global).
7
10
  */
8
11
 
9
- const fs = require('fs');
10
- const path = require('path');
11
12
  const readline = require('readline');
13
+ const { findMcpConfig, writeNewConfig } = require('./config');
14
+
15
+ // Parse CLI args: --app-id, --app-secret, --cookie
16
+ function parseArgs() {
17
+ const args = {};
18
+ const argv = process.argv.slice(2);
19
+ for (let i = 0; i < argv.length; i++) {
20
+ if (argv[i] === '--app-id' && argv[i + 1]) args.appId = argv[++i];
21
+ else if (argv[i] === '--app-secret' && argv[i + 1]) args.appSecret = argv[++i];
22
+ else if (argv[i] === '--cookie' && argv[i + 1]) args.cookie = argv[++i];
23
+ }
24
+ return args;
25
+ }
12
26
 
13
- const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
14
- const ask = (q) => new Promise(resolve => rl.question(q, resolve));
27
+ async function main() {
28
+ const cliArgs = parseArgs();
29
+ const nonInteractive = !!(cliArgs.appId && cliArgs.appSecret);
15
30
 
16
- const CLAUDE_JSON_PATH = path.join(process.env.HOME || '', '.claude.json');
17
- const SERVER_NAME = 'feishu-user-plugin';
31
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
32
+ const ask = (q) => new Promise(resolve => rl.question(q, resolve));
18
33
 
19
- async function main() {
20
34
  console.log('='.repeat(60));
21
- console.log(' feishu-user-plugin Setup Wizard');
35
+ console.log(' feishu-user-plugin Setup');
22
36
  console.log('='.repeat(60));
23
- console.log('');
24
37
 
25
38
  // Check existing config
26
- let config = {};
27
39
  let existingEnv = {};
28
- try {
29
- config = JSON.parse(fs.readFileSync(CLAUDE_JSON_PATH, 'utf8'));
30
- const existing = config.mcpServers?.[SERVER_NAME]?.env || config.mcpServers?.feishu?.env;
31
- if (existing) {
32
- existingEnv = existing;
33
- console.log('Found existing feishu-user-plugin config in ~/.claude.json');
34
- const update = await ask('Update existing config? (Y/n): ');
40
+ const found = findMcpConfig();
41
+ if (found) {
42
+ existingEnv = found.serverEnv;
43
+ if (found.projectPath) {
44
+ console.log(`\nFound project-level config in ${found.configPath} (project: ${found.projectPath})`);
45
+ console.log('This setup will write to global config instead (recommended).');
46
+ console.log('You can remove the project-level entry later to avoid conflicts.');
47
+ } else {
48
+ console.log(`\nFound existing config in ${found.configPath}`);
49
+ }
50
+ if (!nonInteractive) {
51
+ const update = await ask('Update config? (Y/n): ');
35
52
  if (update.toLowerCase() === 'n') {
36
53
  console.log('Cancelled.');
37
54
  rl.close();
38
55
  return;
39
56
  }
40
57
  }
41
- } catch {}
42
-
43
- // Collect credentials
44
- console.log('\n--- App Credentials ---');
45
- console.log('Team members: press Enter to use the shared defaults.');
46
- console.log('External users: get these from https://open.feishu.cn/app\n');
47
-
48
- const defaultAppId = existingEnv.LARK_APP_ID || '';
49
- const defaultAppSecret = existingEnv.LARK_APP_SECRET || '';
50
-
51
- let appId = await ask(`LARK_APP_ID [${defaultAppId || 'required'}]: `);
52
- appId = appId.trim() || defaultAppId;
53
- if (!appId) {
54
- console.error('Error: LARK_APP_ID is required.');
55
- rl.close();
56
- process.exit(1);
57
58
  }
58
59
 
59
- let appSecret = await ask(`LARK_APP_SECRET [${defaultAppSecret ? '***' : 'required'}]: `);
60
- appSecret = appSecret.trim() || defaultAppSecret;
61
- if (!appSecret) {
62
- console.error('Error: LARK_APP_SECRET is required.');
63
- rl.close();
64
- process.exit(1);
60
+ // Resolve App credentials
61
+ let appId, appSecret;
62
+
63
+ if (nonInteractive) {
64
+ // CLI args provided — no prompting
65
+ appId = cliArgs.appId;
66
+ appSecret = cliArgs.appSecret;
67
+ console.log(`\nApp ID: ${appId}`);
68
+ console.log('App Secret: ***');
69
+ } else {
70
+ // Interactive mode
71
+ console.log('\n--- App Credentials ---');
72
+ console.log('Get these from https://open.feishu.cn/app\n');
73
+
74
+ const defaultAppId = existingEnv.LARK_APP_ID || '';
75
+ const defaultAppSecret = existingEnv.LARK_APP_SECRET || '';
76
+
77
+ appId = (await ask(`LARK_APP_ID [${defaultAppId || 'required'}]: `)).trim() || defaultAppId;
78
+ if (!appId) {
79
+ console.error('Error: LARK_APP_ID is required.');
80
+ rl.close();
81
+ process.exit(1);
82
+ }
83
+
84
+ appSecret = (await ask(`LARK_APP_SECRET [${defaultAppSecret ? '***' : 'required'}]: `)).trim() || defaultAppSecret;
85
+ if (!appSecret) {
86
+ console.error('Error: LARK_APP_SECRET is required.');
87
+ rl.close();
88
+ process.exit(1);
89
+ }
65
90
  }
66
91
 
67
92
  // Validate app credentials
@@ -77,7 +102,6 @@ async function main() {
77
102
  console.log('App credentials: VALID');
78
103
  } else {
79
104
  console.error(`App credentials: INVALID — ${data.msg || JSON.stringify(data)}`);
80
- console.error('Please check your LARK_APP_ID and LARK_APP_SECRET.');
81
105
  rl.close();
82
106
  process.exit(1);
83
107
  }
@@ -85,86 +109,57 @@ async function main() {
85
109
  console.warn(`Could not validate: ${e.message}. Continuing anyway.`);
86
110
  }
87
111
 
88
- // Cookie
89
- console.log('\n--- Cookie ---');
90
- console.log('Get your cookie from feishu.cn (Network tab → first request → Cookie header).');
91
- console.log('Or let Claude Code + Playwright extract it automatically after setup.\n');
92
-
93
- const existingCookie = existingEnv.LARK_COOKIE;
94
- const hasCookie = existingCookie && existingCookie !== 'PLACEHOLDER' && existingCookie.includes('session=');
95
- if (hasCookie) {
96
- console.log('Existing cookie found (has session token).');
97
- const keepCookie = await ask('Keep existing cookie? (Y/n): ');
98
- if (keepCookie.toLowerCase() === 'n') {
99
- console.log('You can update it later or use Playwright extraction.');
100
- }
112
+ // Resolve Cookie
113
+ let cookie;
114
+ if (cliArgs.cookie) {
115
+ cookie = cliArgs.cookie;
101
116
  } else {
102
- console.log('No valid cookie found. You can add it later via:');
103
- console.log(' 1. Tell Claude Code: "帮我设置飞书 Cookie" (with Playwright MCP)');
104
- console.log(' 2. Manual: DevTools → Network → Cookie header → paste into config');
117
+ const existingCookie = existingEnv.LARK_COOKIE;
118
+ const hasCookie = existingCookie && existingCookie !== 'SETUP_NEEDED' && existingCookie.includes('session=');
119
+ if (hasCookie) {
120
+ cookie = existingCookie;
121
+ console.log('\nKeeping existing cookie (has session token).');
122
+ } else {
123
+ cookie = 'SETUP_NEEDED';
124
+ if (!nonInteractive) {
125
+ console.log('\n--- Cookie ---');
126
+ console.log('No valid cookie found. After setup:');
127
+ console.log(' Tell Claude Code: "帮我设置飞书 Cookie" (with Playwright MCP)');
128
+ console.log(' Or manually copy from DevTools → Network → Cookie header');
129
+ }
130
+ }
105
131
  }
106
132
 
107
- const cookie = hasCookie ? existingCookie : 'SETUP_NEEDED';
108
-
109
- // UAT
133
+ // Resolve UAT
110
134
  const existingUAT = existingEnv.LARK_USER_ACCESS_TOKEN;
111
135
  const existingRT = existingEnv.LARK_USER_REFRESH_TOKEN;
112
- const hasUAT = existingUAT && existingUAT !== 'PLACEHOLDER' && existingUAT.length > 20;
113
-
114
- if (!hasUAT) {
115
- console.log('\n--- OAuth UAT ---');
116
- console.log('UAT not configured. After setup, run:');
117
- console.log(' npx feishu-user-plugin oauth');
118
- console.log('This will open a browser for OAuth consent.');
119
- }
136
+ const hasUAT = existingUAT && existingUAT !== 'SETUP_NEEDED' && existingUAT.length > 20;
120
137
 
121
138
  // Write config
122
139
  console.log('\n--- Writing Config ---');
123
140
 
124
- if (!config.mcpServers) config.mcpServers = {};
125
- config.mcpServers[SERVER_NAME] = {
126
- command: 'npx',
127
- args: ['-y', 'feishu-user-plugin'],
128
- env: {
129
- LARK_COOKIE: cookie,
130
- LARK_APP_ID: appId,
131
- LARK_APP_SECRET: appSecret,
132
- LARK_USER_ACCESS_TOKEN: hasUAT ? existingUAT : 'SETUP_NEEDED',
133
- LARK_USER_REFRESH_TOKEN: hasUAT ? (existingRT || '') : '',
134
- },
141
+ const env = {
142
+ LARK_COOKIE: cookie,
143
+ LARK_APP_ID: appId,
144
+ LARK_APP_SECRET: appSecret,
145
+ LARK_USER_ACCESS_TOKEN: hasUAT ? existingUAT : 'SETUP_NEEDED',
146
+ LARK_USER_REFRESH_TOKEN: hasUAT ? (existingRT || '') : '',
135
147
  };
136
148
 
137
- // Remove old 'feishu' entry if exists (consolidate)
138
- if (config.mcpServers.feishu && config.mcpServers[SERVER_NAME]) {
139
- delete config.mcpServers.feishu;
140
- }
141
-
142
- fs.writeFileSync(CLAUDE_JSON_PATH, JSON.stringify(config, null, 2) + '\n');
143
- console.log(`Written to ${CLAUDE_JSON_PATH}`);
144
-
145
- // Also write .env for oauth.js to use
146
- const envPath = path.join(__dirname, '..', '.env');
147
- const envContent = [
148
- `LARK_APP_ID=${appId}`,
149
- `LARK_APP_SECRET=${appSecret}`,
150
- cookie !== 'SETUP_NEEDED' ? `LARK_COOKIE=${cookie}` : '',
151
- hasUAT ? `LARK_USER_ACCESS_TOKEN=${existingUAT}` : '',
152
- hasUAT && existingRT ? `LARK_USER_REFRESH_TOKEN=${existingRT}` : '',
153
- ].filter(Boolean).join('\n') + '\n';
154
- fs.writeFileSync(envPath, envContent);
149
+ const result = writeNewConfig(env);
150
+ console.log(`Written to ${result.configPath} (global)`);
155
151
 
156
152
  // Summary
157
153
  console.log('\n' + '='.repeat(60));
158
154
  console.log(' Setup Complete!');
159
155
  console.log('='.repeat(60));
160
- console.log('');
161
156
 
162
157
  const todo = [];
163
158
  if (cookie === 'SETUP_NEEDED') todo.push('Get Cookie: tell Claude Code "帮我设置飞书 Cookie"');
164
159
  if (!hasUAT) todo.push('Get UAT: run "npx feishu-user-plugin oauth"');
165
160
  todo.push('Restart Claude Code');
166
161
 
167
- console.log('Next steps:');
162
+ console.log('\nNext steps:');
168
163
  todo.forEach((t, i) => console.log(` ${i + 1}. ${t}`));
169
164
  console.log('');
170
165
 
@@ -173,6 +168,5 @@ async function main() {
173
168
 
174
169
  main().catch(e => {
175
170
  console.error('Setup failed:', e.message);
176
- rl.close();
177
171
  process.exit(1);
178
172
  });