feishu-user-plugin 1.1.2 → 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.2",
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/CHANGELOG.md CHANGED
@@ -4,6 +4,15 @@ All notable changes to this project will be documented in this file.
4
4
 
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/), and this project adheres to [Semantic Versioning](https://semver.org/).
6
6
 
7
+ ## [1.1.3] - 2026-03-11
8
+
9
+ ### Fixed
10
+ - **Case-insensitive chat name matching**: All name resolution strategies (bot group list, im.chat.search, search_contacts) now use case-insensitive matching. "ai技术解决" now correctly matches "AI技术解决(内部)".
11
+ - **expires_in NaN bug**: UAT token refresh and OAuth now validate `expires_in` field, defaulting to 7200s if missing/invalid, preventing NaN corruption in config.
12
+ - **_populateSenderNames inefficiency**: Fixed redundant condition in cookie-based name fallback.
13
+ - **OAuth silent persistence failure**: Now logs warnings when token persistence to `~/.claude.json` fails, instead of silently swallowing errors.
14
+ - **Null safety**: Added null check in `resolveToOcId` for undefined chat_id.
15
+
7
16
  ## [1.1.2] - 2026-03-11
8
17
 
9
18
  ### Fixed
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "feishu-user-plugin",
3
- "version": "1.1.2",
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": {
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: feishu-user-plugin
3
- version: "1.1.2"
3
+ version: "1.1.3"
4
4
  description: "All-in-one Feishu plugin — send messages as yourself, read group/P2P chats, manage docs/tables/wiki. Replaces and extends the official Feishu MCP."
5
5
  allowed-tools: send_to_user, send_to_group, send_as_user, send_image_as_user, send_file_as_user, send_post_as_user, send_sticker_as_user, send_audio_as_user, search_contacts, create_p2p_chat, get_chat_info, get_user_info, get_login_status, read_p2p_messages, list_user_chats, list_chats, read_messages, reply_message, forward_message, search_docs, read_doc, create_doc, list_bitable_tables, list_bitable_fields, search_bitable_records, create_bitable_record, update_bitable_record, list_wiki_spaces, search_wiki, list_wiki_nodes, list_files, create_folder, find_user
6
6
  user_invocable: true
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');
@@ -33,20 +35,28 @@ class ChatIdMapper {
33
35
  }
34
36
  }
35
37
 
38
+ // Case-insensitive name matching helper
39
+ static _nameMatch(haystack, needle, exact = false) {
40
+ if (!haystack || !needle) return false;
41
+ const h = haystack.toLowerCase(), n = needle.toLowerCase();
42
+ return exact ? h === n : h.includes(n);
43
+ }
44
+
36
45
  async findByName(name, official) {
37
46
  await this._refresh(official);
38
- // Exact match first
47
+ // Exact match first (case-insensitive)
39
48
  for (const [ocId, chatName] of this.nameCache) {
40
- if (chatName === name) return ocId;
49
+ if (ChatIdMapper._nameMatch(chatName, name, true)) return ocId;
41
50
  }
42
- // Partial match
51
+ // Partial match (case-insensitive)
43
52
  for (const [ocId, chatName] of this.nameCache) {
44
- if (chatName && chatName.includes(name)) return ocId;
53
+ if (ChatIdMapper._nameMatch(chatName, name)) return ocId;
45
54
  }
46
55
  return null;
47
56
  }
48
57
 
49
58
  async resolveToOcId(chatIdOrName, official) {
59
+ if (!chatIdOrName) return null;
50
60
  if (chatIdOrName.startsWith('oc_')) return chatIdOrName;
51
61
  // Also accept raw numeric IDs (from search_contacts)
52
62
  if (/^\d+$/.test(chatIdOrName)) return chatIdOrName;
@@ -58,11 +68,11 @@ class ChatIdMapper {
58
68
  const results = await official.chatSearch(chatIdOrName);
59
69
  for (const chat of results) {
60
70
  this.nameCache.set(chat.chat_id, chat.name || '');
61
- if (chat.name === chatIdOrName) return chat.chat_id;
71
+ if (ChatIdMapper._nameMatch(chat.name, chatIdOrName, true)) return chat.chat_id;
62
72
  }
63
- // Partial match on search results
73
+ // Partial match on search results (case-insensitive)
64
74
  for (const chat of results) {
65
- if (chat.name && chat.name.includes(chatIdOrName)) return chat.chat_id;
75
+ if (ChatIdMapper._nameMatch(chat.name, chatIdOrName)) return chat.chat_id;
66
76
  }
67
77
  } catch (e) {
68
78
  console.error('[feishu-user-plugin] chatSearch fallback failed:', e.message);
@@ -77,13 +87,13 @@ class ChatIdMapper {
77
87
  try {
78
88
  const results = await userClient.search(chatName);
79
89
  const groups = results.filter(r => r.type === 'group');
80
- // Exact match first
90
+ // Exact match first (case-insensitive)
81
91
  for (const g of groups) {
82
- if (g.title === chatName) return String(g.id);
92
+ if (ChatIdMapper._nameMatch(g.title, chatName, true)) return String(g.id);
83
93
  }
84
- // Partial match
94
+ // Partial match (case-insensitive)
85
95
  for (const g of groups) {
86
- if (g.title && g.title.includes(chatName)) return String(g.id);
96
+ if (ChatIdMapper._nameMatch(g.title, chatName)) return String(g.id);
87
97
  }
88
98
  } catch (e) {
89
99
  console.error('[feishu-user-plugin] search_contacts fallback failed:', e.message);
@@ -386,6 +396,17 @@ const TOOLS = [
386
396
  required: ['document_id'],
387
397
  },
388
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
+ },
389
410
  {
390
411
  name: 'create_doc',
391
412
  description: '[Official API] Create a new Feishu document.',
@@ -514,6 +535,33 @@ const TOOLS = [
514
535
  },
515
536
  },
516
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
+
517
565
  // ========== Contact — Official API ==========
518
566
  {
519
567
  name: 'find_user',
@@ -531,7 +579,7 @@ const TOOLS = [
531
579
  // --- Server ---
532
580
 
533
581
  const server = new Server(
534
- { name: 'feishu-user-plugin', version: '1.1.2' },
582
+ { name: 'feishu-user-plugin', version: require('../package.json').version },
535
583
  { capabilities: { tools: {} } }
536
584
  );
537
585
 
@@ -563,8 +611,13 @@ async function handleTool(name, args) {
563
611
  case 'send_to_user': {
564
612
  const c = await getUserClient();
565
613
  const results = await c.search(args.user_name);
566
- const user = results.find(r => r.type === 'user');
567
- 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];
568
621
  const chatId = await c.createChat(user.id);
569
622
  if (!chatId) return text(`Failed to create chat with ${user.title}`);
570
623
  const r = await c.sendMessage(chatId, args.text);
@@ -573,8 +626,13 @@ async function handleTool(name, args) {
573
626
  case 'send_to_group': {
574
627
  const c = await getUserClient();
575
628
  const results = await c.search(args.group_name);
576
- const group = results.find(r => r.type === 'group');
577
- 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];
578
636
  const r = await c.sendMessage(group.id, args.text);
579
637
  return sendResult(r, `Text sent to group "${group.title}" (${group.id})`);
580
638
  }
@@ -625,20 +683,16 @@ async function handleTool(name, args) {
625
683
  }
626
684
  case 'get_user_info': {
627
685
  let n = null;
628
- // Strategy 1: User identity client cache
686
+ // Strategy 1: Official API contact lookup (works for same-tenant users by open_id)
629
687
  try {
630
- const c = await getUserClient();
631
- n = await c.getUserName(args.user_id);
632
- if (!n && args.user_id) {
633
- await c.search(args.user_id);
634
- n = await c.getUserName(args.user_id);
635
- }
688
+ const official = getOfficialClient();
689
+ n = await official.getUserById(args.user_id, 'open_id');
636
690
  } catch {}
637
- // Strategy 2: Official API contact lookup (works for same-tenant users)
691
+ // Strategy 2: User identity client cache (populated by previous search/init calls)
638
692
  if (!n) {
639
693
  try {
640
- const official = getOfficialClient();
641
- n = await official.getUserById(args.user_id, 'open_id');
694
+ const c = await getUserClient();
695
+ n = await c.getUserName(args.user_id);
642
696
  } catch {}
643
697
  }
644
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.`);
@@ -663,10 +717,11 @@ async function handleTool(name, args) {
663
717
  case 'read_p2p_messages': {
664
718
  const official = getOfficialClient();
665
719
  let chatId = args.chat_id;
720
+ let uc = null;
721
+ let ucError = null;
722
+ try { uc = await getUserClient(); } catch (e) { ucError = e; }
666
723
  // If chat_id is not numeric or oc_, try to resolve as user name → P2P chat
667
724
  if (!/^\d+$/.test(chatId) && !chatId.startsWith('oc_')) {
668
- let uc = null;
669
- try { uc = await getUserClient(); } catch (_) {}
670
725
  if (uc) {
671
726
  const results = await uc.search(chatId);
672
727
  const user = results.find(r => r.type === 'user');
@@ -681,11 +736,10 @@ async function handleTool(name, args) {
681
736
  else return text(`Cannot resolve "${args.chat_id}" to a chat. Use search_contacts to find the ID first.`);
682
737
  }
683
738
  } else {
684
- 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.`);
685
741
  }
686
742
  }
687
- let uc = null;
688
- try { uc = await getUserClient(); } catch (_) {}
689
743
  return json(await official.readMessagesAsUser(chatId, {
690
744
  pageSize: args.page_size, startTime: args.start_time, endTime: args.end_time,
691
745
  sortType: args.sort_type,
@@ -746,6 +800,8 @@ async function handleTool(name, args) {
746
800
  return json(await getOfficialClient().searchDocs(args.query));
747
801
  case 'read_doc':
748
802
  return json(await getOfficialClient().readDoc(args.document_id));
803
+ case 'get_doc_blocks':
804
+ return json(await getOfficialClient().getDocBlocks(args.document_id));
749
805
  case 'create_doc':
750
806
  return text(`Document created: ${(await getOfficialClient().createDoc(args.title, args.folder_id)).documentId}`);
751
807
 
@@ -785,6 +841,17 @@ async function handleTool(name, args) {
785
841
  case 'find_user':
786
842
  return json(await getOfficialClient().findUserByIdentity({ emails: args.email, mobiles: args.mobile }));
787
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
+
788
855
  default:
789
856
  return text(`Unknown tool: ${name}`);
790
857
  }
@@ -798,7 +865,7 @@ async function main() {
798
865
  const hasCookie = !!process.env.LARK_COOKIE;
799
866
  const hasApp = !!(process.env.LARK_APP_ID && process.env.LARK_APP_SECRET);
800
867
  const hasUAT = !!process.env.LARK_USER_ACCESS_TOKEN;
801
- console.error(`[feishu-user-plugin] MCP Server v1.1.2 — ${TOOLS.length} tools`);
868
+ console.error(`[feishu-user-plugin] MCP Server v${require('../package.json').version} — ${TOOLS.length} tools`);
802
869
  console.error(`[feishu-user-plugin] Auth: Cookie=${hasCookie ? 'YES' : 'NO'} App=${hasApp ? 'YES' : 'NO'} UAT=${hasUAT ? 'YES' : 'NO'}`);
803
870
  if (!hasCookie) console.error('[feishu-user-plugin] WARNING: LARK_COOKIE not set — user identity tools (send_to_user, etc.) will fail');
804
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,52 +98,20 @@ 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 || '',
110
104
  LARK_UAT_SCOPE: tokenData.scope || '',
111
- LARK_UAT_EXPIRES: String(Math.floor(Date.now() / 1000 + tokenData.expires_in)),
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 {}
148
- }
149
115
  }
150
116
 
151
117
  const server = http.createServer(async (req, res) => {
@@ -170,7 +136,7 @@ const server = http.createServer(async (req, res) => {
170
136
  <p>scope: ${tokenData.scope}</p>
171
137
  <p>expires_in: ${tokenData.expires_in}s</p>
172
138
  <p>refresh_token: ${hasRefresh ? '✅ 已获取(30天有效,支持自动续期)' : '❌ 未返回(token 将在 2 小时后过期,需重新授权)'}</p>
173
- <p>已保存到 .env,可以关闭此页面。</p>`);
139
+ <p>已保存到 MCP 配置文件,可以关闭此页面。</p>`);
174
140
 
175
141
  console.log('\n=== OAuth 授权成功 ===');
176
142
  console.log('scope:', tokenData.scope);
@@ -182,7 +148,7 @@ const server = http.createServer(async (req, res) => {
182
148
  console.log(' - 授权时 scope 中未包含 offline_access');
183
149
  console.log(' Token 将在 2 小时后过期,届时需要重新运行此脚本。');
184
150
  }
185
- console.log('token 已保存到 .env');
151
+ console.log('token 已保存到 MCP 配置文件');
186
152
 
187
153
  setTimeout(() => { server.close(); process.exit(0); }, 1000);
188
154
  } catch (e) {
@@ -197,6 +163,16 @@ const server = http.createServer(async (req, res) => {
197
163
  res.end('Not found');
198
164
  });
199
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
+
200
176
  server.listen(PORT, '127.0.0.1', async () => {
201
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)}`;
202
178
 
@@ -226,7 +202,8 @@ server.listen(PORT, '127.0.0.1', async () => {
226
202
  console.log('授权 URL:', authUrl);
227
203
 
228
204
  try {
229
- execSync(`open "${authUrl}"`);
205
+ const openCmd = process.platform === 'win32' ? 'start' : process.platform === 'darwin' ? 'open' : 'xdg-open';
206
+ execSync(`${openCmd} "${authUrl}"`);
230
207
  } catch {
231
208
  console.log('\n请手动在浏览器中打开上面的 URL');
232
209
  }
package/src/official.js CHANGED
@@ -58,84 +58,31 @@ class LarkOfficialClient {
58
58
 
59
59
  this._uat = tokenData.access_token;
60
60
  this._uatRefresh = tokenData.refresh_token || this._uatRefresh;
61
- this._uatExpires = Math.floor(Date.now() / 1000) + tokenData.expires_in;
61
+ const expiresIn = typeof tokenData.expires_in === 'number' && tokenData.expires_in > 0 ? tokenData.expires_in : 7200;
62
+ this._uatExpires = Math.floor(Date.now() / 1000) + expiresIn;
62
63
  this._persistUAT();
63
64
  console.error('[feishu-user-plugin] UAT refreshed successfully');
64
65
  return this._uat;
65
66
  }
66
67
 
67
68
  _persistUAT() {
68
- const fs = require('fs');
69
- const path = require('path');
70
- const updates = {
69
+ // Lazy require to avoid circular dependency at module load time
70
+ const { persistToConfig } = require('./config');
71
+ persistToConfig({
71
72
  LARK_USER_ACCESS_TOKEN: this._uat,
72
73
  LARK_USER_REFRESH_TOKEN: this._uatRefresh,
73
74
  LARK_UAT_EXPIRES: String(this._uatExpires),
74
- };
75
-
76
- // Strategy 1: Update ~/.claude.json MCP config (works for npx users)
77
- const claudeJsonPaths = [
78
- path.join(process.env.HOME || '', '.claude.json'),
79
- path.join(process.env.HOME || '', '.claude', '.claude.json'),
80
- ];
81
- for (const cjPath of claudeJsonPaths) {
82
- try {
83
- const raw = fs.readFileSync(cjPath, 'utf8');
84
- const config = JSON.parse(raw);
85
- const servers = config.mcpServers || {};
86
- // Find our server entry by name
87
- for (const name of ['feishu-user-plugin', 'feishu']) {
88
- if (servers[name]?.env) {
89
- Object.assign(servers[name].env, updates);
90
- fs.writeFileSync(cjPath, JSON.stringify(config, null, 2) + '\n');
91
- console.error('[feishu-user-plugin] UAT persisted to', cjPath);
92
- return;
93
- }
94
- }
95
- } catch {}
96
- }
97
-
98
- // Strategy 2: Update project .mcp.json
99
- const mcpJsonPaths = [
100
- path.join(process.cwd(), '.mcp.json'),
101
- ];
102
- for (const mjPath of mcpJsonPaths) {
103
- try {
104
- const raw = fs.readFileSync(mjPath, 'utf8');
105
- const config = JSON.parse(raw);
106
- const servers = config.mcpServers || config;
107
- for (const name of ['feishu-user-plugin', 'feishu']) {
108
- if (servers[name]?.env) {
109
- Object.assign(servers[name].env, updates);
110
- fs.writeFileSync(mjPath, JSON.stringify(config, null, 2) + '\n');
111
- console.error('[feishu-user-plugin] UAT persisted to', mjPath);
112
- return;
113
- }
114
- }
115
- } catch {}
116
- }
117
-
118
- // Strategy 3: Fallback to .env in project root (for local dev)
119
- const envPath = path.join(__dirname, '..', '.env');
120
- try {
121
- let env = '';
122
- try { env = fs.readFileSync(envPath, 'utf8'); } catch {}
123
- for (const [key, val] of Object.entries(updates)) {
124
- const regex = new RegExp(`^${key}=.*$`, 'm');
125
- if (regex.test(env)) env = env.replace(regex, `${key}=${val}`);
126
- else env += `\n${key}=${val}`;
127
- }
128
- fs.writeFileSync(envPath, env.trim() + '\n');
129
- } catch {}
75
+ });
130
76
  }
131
77
 
132
78
  // --- UAT-based IM operations (for P2P chats) ---
133
79
 
134
- // 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
135
81
  async _withUAT(fn) {
136
82
  let uat = await this._getValidUAT();
137
83
  const data = await fn(uat);
138
- 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) {
139
86
  // Token invalid/expired — try refresh once
140
87
  uat = await this._refreshUAT();
141
88
  return fn(uat);
@@ -230,6 +177,36 @@ class LarkOfficialClient {
230
177
  return { messageId: res.data.message_id };
231
178
  }
232
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
+
233
210
  // --- Docs ---
234
211
 
235
212
  async searchDocs(query, { pageSize = 10, pageToken } = {}) {
@@ -441,14 +418,14 @@ class LarkOfficialClient {
441
418
  unknownIds.add(item.senderId);
442
419
  }
443
420
  }
444
- // Batch resolve via official contact API
445
- for (const id of unknownIds) {
446
- 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)));
447
424
  }
448
425
  // Fallback: resolve remaining unknowns via cookie-based user identity client
449
426
  if (userClient) {
450
427
  for (const id of unknownIds) {
451
- if (!this._userNameCache.has(id) || !this._userNameCache.get(id)) {
428
+ if (!this._userNameCache.has(id)) {
452
429
  try {
453
430
  const name = await userClient.getUserName(id);
454
431
  if (name) this._userNameCache.set(id, name);
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
  });
@@ -274,7 +274,7 @@ async function testUAT() {
274
274
  }
275
275
 
276
276
  async function main() {
277
- console.log('=== feishu-user-plugin v1.1.2 — Comprehensive Test ===\n');
277
+ console.log('=== feishu-user-plugin v1.1.3 — Comprehensive Test ===\n');
278
278
 
279
279
  await testUserIdentity();
280
280
  console.log('');