feishu-user-plugin 1.0.1 → 1.1.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,7 +1,7 @@
1
1
  {
2
2
  "name": "feishu-user-plugin",
3
- "version": "1.0.0",
4
- "description": "All-in-one Feishu plugin for Claude Code — send messages as yourself, read chats, manage docs/tables/wiki. 33 tools + 8 skills, 3 auth layers.",
3
+ "version": "1.1.0",
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"
7
7
  },
package/CHANGELOG.md CHANGED
@@ -4,6 +4,58 @@ 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.0] - 2026-03-11
8
+
9
+ ### Fixed
10
+ - **read_messages 400 error hidden**: Now shows actual Feishu error code and description instead of just "Request failed with status code 400"
11
+ - **Messages returned oldest first**: Default sort is now `ByCreateTimeDesc` (newest messages first) for both `read_messages` and `read_p2p_messages`
12
+ - **Chat name resolution**: Added `im.v1.chat.search` API as fallback when bot's group list doesn't contain the target chat
13
+ - **get_user_info fails for external users**: Added official contact API fallback (`contact.user.get`) for cross-tenant user lookup
14
+ - **Messages lack sender names**: `read_messages` and `read_p2p_messages` now auto-resolve sender IDs to display names
15
+ - **UAT persistence writes to npx temp dir**: Now persists refreshed tokens to `~/.claude.json` MCP config instead
16
+ - **oauth-auto.js missing offline_access scope**: Added `offline_access` to SCOPES (was missing, causing no refresh_token)
17
+ - **README "8 slash commands"**: Corrected to "9 slash commands" (was missing /drive)
18
+ - **CLAUDE.md false "type: stdio" warning**: Removed — `"type": "stdio"` is standard and harmless in Claude Code
19
+
20
+ ### Added
21
+ - `sort_type` parameter for `read_messages` and `read_p2p_messages` (`ByCreateTimeDesc` / `ByCreateTimeAsc`)
22
+ - `senderName` field in message results (auto-resolved from sender ID)
23
+ - CLI subcommands: `npx feishu-user-plugin setup` (wizard), `oauth`, `status`
24
+ - `src/cli.js` — CLI dispatcher for subcommands
25
+ - `src/setup.js` — Interactive setup wizard (writes MCP config, validates credentials)
26
+ - `chatSearch()` method in official client (uses `im.v1.chat.search`)
27
+ - `getUserById()` method with caching for user name resolution
28
+ - `_safeSDKCall()` wrapper that extracts real Feishu errors from Lark SDK AxiosErrors
29
+ - `_populateSenderNames()` for batch sender name resolution in message lists
30
+
31
+ ### Changed
32
+ - `package.json` bin entry points to `src/cli.js` (supports subcommands, default still starts MCP server)
33
+ - team-skills README rewritten for pure npm flow (no clone needed)
34
+ - CLAUDE.md OAuth instructions updated to use `npx feishu-user-plugin oauth`
35
+ - Error messages across all 33 tools now include actual Feishu error codes
36
+
37
+ ## [1.0.2] - 2026-03-10
38
+
39
+ ### Fixed
40
+ - `list_user_chats` description incorrectly claimed "including P2P" — actually only returns groups
41
+ - OAuth scope `contact:user.id:readonly` → `contact:user.base:readonly` in README
42
+ - Cookie length validation range (500-5000, was 1000-5000)
43
+ - Version inconsistency across `server.json`, `plugin.json`, `SKILL.md`, `src/index.js`
44
+ - Skill count: 8 → 9 (was missing `/drive`)
45
+ - README_CN.md Claude Desktop config missing `env` block
46
+
47
+ ### Added
48
+ - Startup auth diagnostics in `src/index.js` (Cookie/App/UAT status logging)
49
+ - `LARK_USER_REFRESH_TOKEN` to all MCP config examples
50
+ - Troubleshooting for `invalid_grant` errors (28003/20003/20005)
51
+ - Troubleshooting for `oauth.js` requiring APP_ID/SECRET in `.env`
52
+ - Playwright cookie setup: two-step extraction, `clearCookies()`, ASCII validation
53
+ - `LARK_USER_REFRESH_TOKEN` to `server.json` environment_variables
54
+
55
+ ### Changed
56
+ - All 5 env vars marked as required for full functionality
57
+ - Improved `read_p2p_messages` chat_id description (numeric + oc_xxx both accepted)
58
+
7
59
  ## [1.0.0] - 2026-03-09
8
60
 
9
61
  ### Changed
package/README.md CHANGED
@@ -6,7 +6,7 @@
6
6
  [![Tools](https://img.shields.io/badge/Tools-33-orange.svg)](#tools-33-total)
7
7
  [![PRs Welcome](https://img.shields.io/badge/PRs-welcome-brightgreen.svg)](CONTRIBUTING.md)
8
8
 
9
- **All-in-one Feishu/Lark MCP Server -- 33 tools, 8 skills, 3 auth layers for messaging, docs, tables, wiki, and drive.**
9
+ **All-in-one Feishu/Lark MCP Server -- 33 tools, 9 skills, 3 auth layers for messaging, docs, tables, wiki, and drive.**
10
10
 
11
11
  The only MCP server that lets you send messages as your **personal identity** (not a bot), while also integrating the full official Feishu API for documents, spreadsheets, wikis, and more.
12
12
 
@@ -16,7 +16,7 @@ The only MCP server that lets you send messages as your **personal identity** (n
16
16
  - **Read everything** -- Group chats via bot API, P2P (direct messages) via OAuth UAT.
17
17
  - **Full Feishu suite** -- Docs, Bitable (spreadsheets), Wiki, Drive, Contacts -- all in one plugin.
18
18
  - **3 auth layers** -- Cookie-based user identity, app credentials (Official API), and OAuth UAT (P2P reading). All three are needed for full functionality.
19
- - **8 slash commands** for Claude Code -- `/send`, `/reply`, `/search`, `/digest`, `/doc`, `/table`, `/wiki`, `/status`
19
+ - **9 slash commands** for Claude Code -- `/send`, `/reply`, `/search`, `/digest`, `/doc`, `/table`, `/wiki`, `/drive`, `/status`
20
20
  - **Auto session management** -- Cookie heartbeat every 4h, UAT auto-refresh with token rotation.
21
21
  - **Chat name resolution** -- Pass a group name instead of `oc_xxx` ID; it resolves automatically.
22
22
 
@@ -82,7 +82,7 @@ Go to **Permissions & Scopes** (权限管理) and add the following scopes:
82
82
  | `bitable:record` | Read and write Bitable records |
83
83
  | `wiki:wiki:readonly` | Read wiki spaces and nodes |
84
84
  | `drive:drive:readonly` | List Drive files and folders |
85
- | `contact:user.id:readonly` | Look up users by email/mobile |
85
+ | `contact:user.base:readonly` | Look up users by email/mobile |
86
86
 
87
87
  > Add more scopes as needed depending on which tools you use.
88
88
 
@@ -338,7 +338,7 @@ Send messages as yourself, not as a bot.
338
338
  | Tool | Description |
339
339
  |------|-------------|
340
340
  | `read_p2p_messages` | Read P2P (direct message) history. Works for chats the bot cannot access. |
341
- | `list_user_chats` | List all chats the user is in, including P2P. |
341
+ | `list_user_chats` | List group chats the user is in. Note: only returns groups, not P2P. |
342
342
 
343
343
  ### Official API -- IM (Bot Identity)
344
344
 
@@ -388,9 +388,9 @@ Send messages as yourself, not as a bot.
388
388
  |------|-------------|
389
389
  | `find_user` | Find user by email or mobile number |
390
390
 
391
- ## Claude Code Slash Commands (8 skills)
391
+ ## Claude Code Slash Commands (9 skills)
392
392
 
393
- This plugin includes 8 built-in skills in `skills/feishu-user-plugin/`:
393
+ This plugin includes 9 built-in skills in `skills/feishu-user-plugin/`:
394
394
 
395
395
  | Skill | Usage | Description |
396
396
  |-------|-------|-------------|
@@ -401,6 +401,7 @@ This plugin includes 8 built-in skills in `skills/feishu-user-plugin/`:
401
401
  | `/doc` | `/doc search MCP` | Search, read, or create documents |
402
402
  | `/table` | `/table query appXxx` | Query or create Bitable records |
403
403
  | `/wiki` | `/wiki search protocol` | Search and browse wiki |
404
+ | `/drive` | `/drive list folderToken` | List files or create folders in Drive |
404
405
  | `/status` | `/status` | Check login and auth status |
405
406
 
406
407
  Skills are automatically available when the plugin is installed.
package/package.json CHANGED
@@ -1,10 +1,10 @@
1
1
  {
2
2
  "name": "feishu-user-plugin",
3
- "version": "1.0.1",
4
- "description": "All-in-one Feishu plugin for Claude Code — send messages as yourself, read chats, manage docs/tables/wiki. 33 tools + 8 skills, 3 auth layers.",
3
+ "version": "1.1.0",
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": {
7
- "feishu-user-plugin": "src/index.js"
7
+ "feishu-user-plugin": "src/cli.js"
8
8
  },
9
9
  "scripts": {
10
10
  "start": "node src/index.js",
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: feishu-user-plugin
3
- version: "1.0.0"
3
+ version: "1.1.0"
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
@@ -93,7 +93,7 @@ window.__COOKIE__
93
93
 
94
94
  **Step 4: Validate** — Must be pure ASCII, contain `session=` and `sl_session=`, length 500-5000. If >10000, it's contaminated.
95
95
 
96
- **Step 5: Write config** using exact format (NO `"type": "stdio"`):
96
+ **Step 5: Write config** using exact format:
97
97
  ```json
98
98
  {
99
99
  "feishu-user-plugin": {
package/src/cli.js ADDED
@@ -0,0 +1,104 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * CLI entry point for feishu-user-plugin
4
+ *
5
+ * Usage:
6
+ * npx feishu-user-plugin → Start MCP server (default, used by Claude Code)
7
+ * npx feishu-user-plugin setup → Interactive setup wizard
8
+ * npx feishu-user-plugin oauth → Run OAuth flow for UAT
9
+ * npx feishu-user-plugin status → Check auth status
10
+ */
11
+
12
+ const cmd = process.argv[2];
13
+
14
+ switch (cmd) {
15
+ case 'setup':
16
+ require('./setup');
17
+ break;
18
+ case 'oauth':
19
+ require('./oauth');
20
+ break;
21
+ case 'status':
22
+ checkStatus();
23
+ break;
24
+ case 'help':
25
+ case '--help':
26
+ case '-h':
27
+ printHelp();
28
+ break;
29
+ default:
30
+ // Default: start MCP server (used by Claude Code / MCP clients)
31
+ require('./index');
32
+ break;
33
+ }
34
+
35
+ function printHelp() {
36
+ console.log(`
37
+ feishu-user-plugin — All-in-one Feishu MCP Server
38
+
39
+ Commands:
40
+ (default) Start MCP server (used by Claude Code)
41
+ setup Interactive setup wizard — writes MCP config
42
+ oauth Run OAuth flow to obtain user_access_token
43
+ status Check authentication status
44
+ help Show this help
45
+
46
+ Quick Start (team members):
47
+ 1. npx feishu-user-plugin setup
48
+ 2. Follow the prompts to configure credentials
49
+ 3. Restart Claude Code
50
+
51
+ Quick Start (external users):
52
+ 1. Create a Feishu app at https://open.feishu.cn/app
53
+ 2. npx feishu-user-plugin setup
54
+ 3. npx feishu-user-plugin oauth
55
+ 4. Restart Claude Code
56
+ `);
57
+ }
58
+
59
+ async function checkStatus() {
60
+ const { LarkUserClient } = require('./client');
61
+ const { LarkOfficialClient } = require('./official');
62
+ const path = require('path');
63
+ require('dotenv').config({ path: path.join(__dirname, '..', '.env') });
64
+
65
+ console.log('=== feishu-user-plugin Auth Status ===\n');
66
+
67
+ // Cookie
68
+ const cookie = process.env.LARK_COOKIE;
69
+ if (cookie) {
70
+ try {
71
+ const client = new LarkUserClient(cookie);
72
+ await client.init();
73
+ console.log(`Cookie: OK (user: ${client.userName || client.userId})`);
74
+ } catch (e) {
75
+ console.log(`Cookie: FAILED — ${e.message}`);
76
+ }
77
+ } else {
78
+ console.log('Cookie: NOT SET');
79
+ }
80
+
81
+ // App credentials
82
+ const appId = process.env.LARK_APP_ID;
83
+ const appSecret = process.env.LARK_APP_SECRET;
84
+ console.log(`App credentials: ${appId && appSecret ? 'OK' : 'NOT SET'}`);
85
+
86
+ // UAT
87
+ const uat = process.env.LARK_USER_ACCESS_TOKEN;
88
+ const rt = process.env.LARK_USER_REFRESH_TOKEN;
89
+ if (uat) {
90
+ console.log(`UAT: SET (refresh_token: ${rt ? 'YES' : 'NO'})`);
91
+ if (appId && appSecret) {
92
+ const official = new LarkOfficialClient(appId, appSecret);
93
+ official.loadUAT();
94
+ try {
95
+ const chats = await official.listChatsAsUser({ pageSize: 1 });
96
+ console.log(` UAT test: OK (can list chats)`);
97
+ } catch (e) {
98
+ console.log(` UAT test: FAILED — ${e.message}`);
99
+ }
100
+ }
101
+ } else {
102
+ console.log('UAT: NOT SET (run: npx feishu-user-plugin oauth)');
103
+ }
104
+ }
package/src/index.js CHANGED
@@ -48,8 +48,24 @@ class ChatIdMapper {
48
48
 
49
49
  async resolveToOcId(chatIdOrName, official) {
50
50
  if (chatIdOrName.startsWith('oc_')) return chatIdOrName;
51
- // Try as chat name
52
- return this.findByName(chatIdOrName, official);
51
+ // Strategy 1: Search in bot's group list cache
52
+ const cached = await this.findByName(chatIdOrName, official);
53
+ if (cached) return cached;
54
+ // Strategy 2: Use im.v1.chat.search API (finds groups even if not in cache)
55
+ try {
56
+ const results = await official.chatSearch(chatIdOrName);
57
+ for (const chat of results) {
58
+ this.nameCache.set(chat.chat_id, chat.name || '');
59
+ if (chat.name === chatIdOrName) return chat.chat_id;
60
+ }
61
+ // Partial match on search results
62
+ for (const chat of results) {
63
+ if (chat.name && chat.name.includes(chatIdOrName)) return chat.chat_id;
64
+ }
65
+ } catch (e) {
66
+ console.error('[feishu-user-plugin] chatSearch fallback failed:', e.message);
67
+ }
68
+ return null;
53
69
  }
54
70
  }
55
71
 
@@ -251,7 +267,7 @@ const TOOLS = [
251
267
  // ========== IM — Official API (User Identity via UAT) ==========
252
268
  {
253
269
  name: 'read_p2p_messages',
254
- description: '[User UAT] Read P2P (direct message) chat history using user_access_token. Works for chats the bot cannot access. Requires OAuth setup: run "node src/oauth.js" first.',
270
+ description: '[User UAT] Read P2P (direct message) chat history using user_access_token. Works for chats the bot cannot access. Returns newest messages first by default. Requires OAuth setup.',
255
271
  inputSchema: {
256
272
  type: 'object',
257
273
  properties: {
@@ -259,6 +275,7 @@ const TOOLS = [
259
275
  page_size: { type: 'number', description: 'Messages to fetch (default 20, max 50)' },
260
276
  start_time: { type: 'string', description: 'Start timestamp in seconds (optional)' },
261
277
  end_time: { type: 'string', description: 'End timestamp in seconds (optional)' },
278
+ sort_type: { type: 'string', enum: ['ByCreateTimeDesc', 'ByCreateTimeAsc'], description: 'Sort order (default: ByCreateTimeDesc = newest first)' },
262
279
  },
263
280
  required: ['chat_id'],
264
281
  },
@@ -289,7 +306,7 @@ const TOOLS = [
289
306
  },
290
307
  {
291
308
  name: 'read_messages',
292
- description: '[Official API] Read message history. Accepts oc_xxx ID or chat name (auto-resolved).',
309
+ description: '[Official API] Read message history. Accepts oc_xxx ID or chat name (auto-searched via bot group list + im.chat.search). Returns newest messages first by default, with sender names resolved.',
293
310
  inputSchema: {
294
311
  type: 'object',
295
312
  properties: {
@@ -297,6 +314,7 @@ const TOOLS = [
297
314
  page_size: { type: 'number', description: 'Messages to fetch (default 20, max 50)' },
298
315
  start_time: { type: 'string', description: 'Start timestamp in seconds (optional)' },
299
316
  end_time: { type: 'string', description: 'End timestamp in seconds (optional)' },
317
+ sort_type: { type: 'string', enum: ['ByCreateTimeDesc', 'ByCreateTimeAsc'], description: 'Sort order (default: ByCreateTimeDesc = newest first)' },
300
318
  },
301
319
  required: ['chat_id'],
302
320
  },
@@ -490,7 +508,7 @@ const TOOLS = [
490
508
  // --- Server ---
491
509
 
492
510
  const server = new Server(
493
- { name: 'feishu-user-plugin', version: '1.0.0' },
511
+ { name: 'feishu-user-plugin', version: '1.1.0' },
494
512
  { capabilities: { tools: {} } }
495
513
  );
496
514
 
@@ -583,15 +601,24 @@ async function handleTool(name, args) {
583
601
  return info ? json(info) : text(`No info for chat ${args.chat_id}`);
584
602
  }
585
603
  case 'get_user_info': {
586
- const c = await getUserClient();
587
- // Try name cache first; if miss, do a search to populate cache
588
- let n = await c.getUserName(args.user_id);
589
- if (!n && args.user_id) {
590
- // Try searching to populate the cache
591
- await c.search(args.user_id);
604
+ let n = null;
605
+ // Strategy 1: User identity client cache
606
+ try {
607
+ const c = await getUserClient();
592
608
  n = await c.getUserName(args.user_id);
609
+ if (!n && args.user_id) {
610
+ await c.search(args.user_id);
611
+ n = await c.getUserName(args.user_id);
612
+ }
613
+ } catch {}
614
+ // Strategy 2: Official API contact lookup (works for same-tenant users)
615
+ if (!n) {
616
+ try {
617
+ const official = getOfficialClient();
618
+ n = await official.getUserById(args.user_id, 'open_id');
619
+ } catch {}
593
620
  }
594
- return text(n ? `User ${args.user_id}: ${n}` : `Could not resolve user ${args.user_id}. Try search_contacts with the user's name instead.`);
621
+ 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.`);
595
622
  }
596
623
  case 'get_login_status': {
597
624
  const parts = [];
@@ -614,6 +641,7 @@ async function handleTool(name, args) {
614
641
  const official = getOfficialClient();
615
642
  return json(await official.readMessagesAsUser(args.chat_id, {
616
643
  pageSize: args.page_size, startTime: args.start_time, endTime: args.end_time,
644
+ sortType: args.sort_type,
617
645
  }));
618
646
  }
619
647
  case 'list_user_chats':
@@ -627,10 +655,11 @@ async function handleTool(name, args) {
627
655
  const official = getOfficialClient();
628
656
  const resolvedChatId = await chatIdMapper.resolveToOcId(args.chat_id, official);
629
657
  if (!resolvedChatId) {
630
- return text(`Cannot resolve "${args.chat_id}" to oc_ ID. Use list_chats to find the correct ID, or provide chat name.`);
658
+ return text(`Cannot resolve "${args.chat_id}" to oc_ ID. Searched bot's group list and used im.chat.search API — no match found.\nTry: list_chats to see all bot groups, or provide the oc_xxx ID directly.\nIf the bot is not in this group, use read_p2p_messages with UAT instead.`);
631
659
  }
632
660
  return json(await official.readMessages(resolvedChatId, {
633
661
  pageSize: args.page_size, startTime: args.start_time, endTime: args.end_time,
662
+ sortType: args.sort_type,
634
663
  }));
635
664
  }
636
665
  case 'reply_message':
@@ -696,7 +725,7 @@ async function main() {
696
725
  const hasCookie = !!process.env.LARK_COOKIE;
697
726
  const hasApp = !!(process.env.LARK_APP_ID && process.env.LARK_APP_SECRET);
698
727
  const hasUAT = !!process.env.LARK_USER_ACCESS_TOKEN;
699
- console.error(`[feishu-user-plugin] MCP Server v1.0.1 — ${TOOLS.length} tools`);
728
+ console.error(`[feishu-user-plugin] MCP Server v1.1.0 — ${TOOLS.length} tools`);
700
729
  console.error(`[feishu-user-plugin] Auth: Cookie=${hasCookie ? 'YES' : 'NO'} App=${hasApp ? 'YES' : 'NO'} UAT=${hasUAT ? 'YES' : 'NO'}`);
701
730
  if (!hasCookie) console.error('[feishu-user-plugin] WARNING: LARK_COOKIE not set — user identity tools (send_to_user, etc.) will fail');
702
731
  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
@@ -13,7 +13,7 @@ const APP_SECRET = process.env.LARK_APP_SECRET;
13
13
  const COOKIE_STR = process.env.LARK_COOKIE;
14
14
  const PORT = 9997;
15
15
  const REDIRECT_URI = `http://127.0.0.1:${PORT}/callback`;
16
- const SCOPES = 'im:message im:message:readonly im:chat:readonly contact:user.base:readonly';
16
+ const SCOPES = 'offline_access im:message im:message:readonly im:chat:readonly contact:user.base:readonly';
17
17
 
18
18
  function parseCookies(cookieStr) {
19
19
  return cookieStr.split(';').map(c => {
package/src/official.js CHANGED
@@ -8,6 +8,7 @@ class LarkOfficialClient {
8
8
  this._uat = null;
9
9
  this._uatRefresh = null;
10
10
  this._uatExpires = 0;
11
+ this._userNameCache = new Map(); // open_id → display name
11
12
  }
12
13
 
13
14
  // --- UAT (User Access Token) Management ---
@@ -66,14 +67,60 @@ class LarkOfficialClient {
66
67
  _persistUAT() {
67
68
  const fs = require('fs');
68
69
  const path = require('path');
70
+ const updates = {
71
+ LARK_USER_ACCESS_TOKEN: this._uat,
72
+ LARK_USER_REFRESH_TOKEN: this._uatRefresh,
73
+ 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)
69
119
  const envPath = path.join(__dirname, '..', '.env');
70
120
  try {
71
- let env = fs.readFileSync(envPath, 'utf8');
72
- for (const [key, val] of Object.entries({
73
- LARK_USER_ACCESS_TOKEN: this._uat,
74
- LARK_USER_REFRESH_TOKEN: this._uatRefresh,
75
- LARK_UAT_EXPIRES: String(this._uatExpires),
76
- })) {
121
+ let env = '';
122
+ try { env = fs.readFileSync(envPath, 'utf8'); } catch {}
123
+ for (const [key, val] of Object.entries(updates)) {
77
124
  const regex = new RegExp(`^${key}=.*$`, 'm');
78
125
  if (regex.test(env)) env = env.replace(regex, `${key}=${val}`);
79
126
  else env += `\n${key}=${val}`;
@@ -109,9 +156,10 @@ class LarkOfficialClient {
109
156
  return { items: data.data.items || [], pageToken: data.data.page_token, hasMore: data.data.has_more };
110
157
  }
111
158
 
112
- async readMessagesAsUser(chatId, { pageSize = 20, startTime, endTime, pageToken } = {}) {
159
+ async readMessagesAsUser(chatId, { pageSize = 20, startTime, endTime, pageToken, sortType = 'ByCreateTimeDesc' } = {}) {
113
160
  const params = new URLSearchParams({
114
161
  container_id_type: 'chat', container_id: chatId, page_size: String(pageSize),
162
+ sort_type: sortType,
115
163
  });
116
164
  if (startTime) params.set('start_time', startTime);
117
165
  if (endTime) params.set('end_time', endTime);
@@ -123,98 +171,107 @@ class LarkOfficialClient {
123
171
  return res.json();
124
172
  });
125
173
  if (data.code !== 0) throw new Error(`readMessagesAsUser failed (${data.code}): ${data.msg}`);
126
- return {
127
- items: (data.data.items || []).map(m => this._formatMessage(m)),
128
- hasMore: data.data.has_more,
129
- pageToken: data.data.page_token,
130
- };
174
+ const items = (data.data.items || []).map(m => this._formatMessage(m));
175
+ await this._populateSenderNames(items);
176
+ return { items, hasMore: data.data.has_more, pageToken: data.data.page_token };
131
177
  }
132
178
 
133
179
  // --- IM ---
134
180
 
135
181
  async listChats({ pageSize = 20, pageToken } = {}) {
136
- const res = await this.client.im.chat.list({ params: { page_size: pageSize, page_token: pageToken } });
137
- if (res.code !== 0) throw new Error(`listChats failed (${res.code}): ${res.msg}`);
182
+ const res = await this._safeSDKCall(
183
+ () => this.client.im.chat.list({ params: { page_size: pageSize, page_token: pageToken } }),
184
+ 'listChats'
185
+ );
138
186
  return { items: res.data.items || [], pageToken: res.data.page_token, hasMore: res.data.has_more };
139
187
  }
140
188
 
141
- async readMessages(chatId, { pageSize = 20, startTime, endTime, pageToken } = {}) {
142
- const params = { container_id_type: 'chat', container_id: chatId, page_size: pageSize };
189
+ async readMessages(chatId, { pageSize = 20, startTime, endTime, pageToken, sortType = 'ByCreateTimeDesc' } = {}) {
190
+ const params = { container_id_type: 'chat', container_id: chatId, page_size: pageSize, sort_type: sortType };
143
191
  if (startTime) params.start_time = startTime;
144
192
  if (endTime) params.end_time = endTime;
145
193
  if (pageToken) params.page_token = pageToken;
146
- const res = await this.client.im.message.list({ params });
147
- if (res.code !== 0) throw new Error(`readMessages failed (${res.code}): ${res.msg}`);
148
- return { items: (res.data.items || []).map(m => this._formatMessage(m)), hasMore: res.data.has_more, pageToken: res.data.page_token };
194
+ const res = await this._safeSDKCall(() => this.client.im.message.list({ params }), 'readMessages');
195
+ const items = (res.data.items || []).map(m => this._formatMessage(m));
196
+ await this._populateSenderNames(items);
197
+ return { items, hasMore: res.data.has_more, pageToken: res.data.page_token };
149
198
  }
150
199
 
151
200
  async getMessage(messageId) {
152
- const res = await this.client.im.message.get({ path: { message_id: messageId } });
153
- if (res.code !== 0) throw new Error(`getMessage failed (${res.code}): ${res.msg}`);
201
+ const res = await this._safeSDKCall(
202
+ () => this.client.im.message.get({ path: { message_id: messageId } }),
203
+ 'getMessage'
204
+ );
154
205
  return this._formatMessage(res.data);
155
206
  }
156
207
 
157
208
  async replyMessage(messageId, text, msgType = 'text') {
158
209
  const content = msgType === 'text' ? JSON.stringify({ text }) : text;
159
- const res = await this.client.im.message.reply({
160
- path: { message_id: messageId },
161
- data: { content, msg_type: msgType },
162
- });
163
- if (res.code !== 0) throw new Error(`replyMessage failed (${res.code}): ${res.msg}`);
210
+ const res = await this._safeSDKCall(
211
+ () => this.client.im.message.reply({ path: { message_id: messageId }, data: { content, msg_type: msgType } }),
212
+ 'replyMessage'
213
+ );
164
214
  return { messageId: res.data.message_id };
165
215
  }
166
216
 
167
217
  async forwardMessage(messageId, receiverId, receiveIdType = 'chat_id') {
168
- const res = await this.client.im.message.forward({
169
- path: { message_id: messageId },
170
- data: { receive_id: receiverId },
171
- params: { receive_id_type: receiveIdType },
172
- });
173
- if (res.code !== 0) throw new Error(`forwardMessage failed (${res.code}): ${res.msg}`);
218
+ const res = await this._safeSDKCall(
219
+ () => this.client.im.message.forward({
220
+ path: { message_id: messageId },
221
+ data: { receive_id: receiverId },
222
+ params: { receive_id_type: receiveIdType },
223
+ }),
224
+ 'forwardMessage'
225
+ );
174
226
  return { messageId: res.data.message_id };
175
227
  }
176
228
 
177
229
  // --- Docs ---
178
230
 
179
231
  async searchDocs(query, { pageSize = 10, pageToken } = {}) {
180
- const res = await this.client.request({
181
- method: 'POST',
182
- url: '/open-apis/suite/docs-api/search/object',
183
- data: { search_key: query, count: pageSize, offset: pageToken ? parseInt(pageToken) : 0, owner_ids: [], chat_ids: [], docs_types: [] },
184
- });
185
- if (res.code !== 0) throw new Error(`searchDocs failed (${res.code}): ${res.msg}`);
232
+ const res = await this._safeSDKCall(
233
+ () => this.client.request({
234
+ method: 'POST', url: '/open-apis/suite/docs-api/search/object',
235
+ data: { search_key: query, count: pageSize, offset: pageToken ? parseInt(pageToken) : 0, owner_ids: [], chat_ids: [], docs_types: [] },
236
+ }),
237
+ 'searchDocs'
238
+ );
186
239
  return { items: res.data.docs_entities || [], hasMore: res.data.has_more };
187
240
  }
188
241
 
189
242
  async readDoc(documentId) {
190
- const res = await this.client.docx.document.rawContent({ path: { document_id: documentId }, params: { lang: 0 } });
191
- if (res.code !== 0) throw new Error(`readDoc failed (${res.code}): ${res.msg}`);
243
+ const res = await this._safeSDKCall(
244
+ () => this.client.docx.document.rawContent({ path: { document_id: documentId }, params: { lang: 0 } }),
245
+ 'readDoc'
246
+ );
192
247
  return { content: res.data.content };
193
248
  }
194
249
 
195
250
  async createDoc(title, folderId) {
196
- const res = await this.client.docx.document.create({ data: { title, folder_token: folderId || '' } });
197
- if (res.code !== 0) throw new Error(`createDoc failed (${res.code}): ${res.msg}`);
251
+ const res = await this._safeSDKCall(
252
+ () => this.client.docx.document.create({ data: { title, folder_token: folderId || '' } }),
253
+ 'createDoc'
254
+ );
198
255
  return { documentId: res.data.document?.document_id };
199
256
  }
200
257
 
201
258
  async getDocBlocks(documentId) {
202
- const res = await this.client.docx.documentBlock.list({ path: { document_id: documentId }, params: { page_size: 500 } });
203
- if (res.code !== 0) throw new Error(`getDocBlocks failed (${res.code}): ${res.msg}`);
259
+ const res = await this._safeSDKCall(
260
+ () => this.client.docx.documentBlock.list({ path: { document_id: documentId }, params: { page_size: 500 } }),
261
+ 'getDocBlocks'
262
+ );
204
263
  return { items: res.data.items || [] };
205
264
  }
206
265
 
207
266
  // --- Bitable ---
208
267
 
209
268
  async listBitableTables(appToken) {
210
- const res = await this.client.bitable.appTable.list({ path: { app_token: appToken } });
211
- if (res.code !== 0) throw new Error(`listTables failed (${res.code}): ${res.msg}`);
269
+ const res = await this._safeSDKCall(() => this.client.bitable.appTable.list({ path: { app_token: appToken } }), 'listTables');
212
270
  return { items: res.data.items || [] };
213
271
  }
214
272
 
215
273
  async listBitableFields(appToken, tableId) {
216
- const res = await this.client.bitable.appTableField.list({ path: { app_token: appToken, table_id: tableId } });
217
- if (res.code !== 0) throw new Error(`listFields failed (${res.code}): ${res.msg}`);
274
+ const res = await this._safeSDKCall(() => this.client.bitable.appTableField.list({ path: { app_token: appToken, table_id: tableId } }), 'listFields');
218
275
  return { items: res.data.items || [] };
219
276
  }
220
277
 
@@ -224,55 +281,46 @@ class LarkOfficialClient {
224
281
  if (sort) data.sort = sort;
225
282
  if (pageSize) data.page_size = pageSize;
226
283
  if (pageToken) data.page_token = pageToken;
227
- const res = await this.client.bitable.appTableRecord.search({
228
- path: { app_token: appToken, table_id: tableId },
229
- data,
230
- });
231
- if (res.code !== 0) throw new Error(`searchRecords failed (${res.code}): ${res.msg}`);
284
+ const res = await this._safeSDKCall(
285
+ () => this.client.bitable.appTableRecord.search({ path: { app_token: appToken, table_id: tableId }, data }),
286
+ 'searchRecords'
287
+ );
232
288
  return { items: res.data.items || [], total: res.data.total, hasMore: res.data.has_more };
233
289
  }
234
290
 
235
291
  async createBitableRecord(appToken, tableId, fields) {
236
- const res = await this.client.bitable.appTableRecord.create({
237
- path: { app_token: appToken, table_id: tableId },
238
- data: { fields },
239
- });
240
- if (res.code !== 0) throw new Error(`createRecord failed (${res.code}): ${res.msg}`);
292
+ const res = await this._safeSDKCall(
293
+ () => this.client.bitable.appTableRecord.create({ path: { app_token: appToken, table_id: tableId }, data: { fields } }),
294
+ 'createRecord'
295
+ );
241
296
  return { recordId: res.data.record?.record_id };
242
297
  }
243
298
 
244
299
  async updateBitableRecord(appToken, tableId, recordId, fields) {
245
- const res = await this.client.bitable.appTableRecord.update({
246
- path: { app_token: appToken, table_id: tableId, record_id: recordId },
247
- data: { fields },
248
- });
249
- if (res.code !== 0) throw new Error(`updateRecord failed (${res.code}): ${res.msg}`);
300
+ const res = await this._safeSDKCall(
301
+ () => this.client.bitable.appTableRecord.update({ path: { app_token: appToken, table_id: tableId, record_id: recordId }, data: { fields } }),
302
+ 'updateRecord'
303
+ );
250
304
  return { recordId: res.data.record?.record_id };
251
305
  }
252
306
 
253
307
  // --- Wiki ---
254
308
 
255
309
  async listWikiSpaces() {
256
- const res = await this.client.wiki.space.list({ params: { page_size: 50 } });
257
- if (res.code !== 0) throw new Error(`listSpaces failed (${res.code}): ${res.msg}`);
310
+ const res = await this._safeSDKCall(() => this.client.wiki.space.list({ params: { page_size: 50 } }), 'listSpaces');
258
311
  return { items: res.data.items || [] };
259
312
  }
260
313
 
261
314
  async searchWiki(query) {
262
- const res = await this.client.request({
263
- method: 'POST',
264
- url: '/open-apis/suite/docs-api/search/object',
265
- data: { search_key: query, count: 20, offset: 0, owner_ids: [], chat_ids: [], docs_types: ['wiki'] },
266
- });
267
- if (res.code !== 0) throw new Error(`searchWiki failed (${res.code}): ${res.msg}`);
315
+ const res = await this._safeSDKCall(
316
+ () => this.client.request({ method: 'POST', url: '/open-apis/suite/docs-api/search/object', data: { search_key: query, count: 20, offset: 0, owner_ids: [], chat_ids: [], docs_types: ['wiki'] } }),
317
+ 'searchWiki'
318
+ );
268
319
  return { items: res.data.docs_entities || [] };
269
320
  }
270
321
 
271
322
  async getWikiNode(spaceId, nodeToken) {
272
- const res = await this.client.wiki.space.getNode({
273
- params: { token: nodeToken },
274
- });
275
- if (res.code !== 0) throw new Error(`getNode failed (${res.code}): ${res.msg}`);
323
+ const res = await this._safeSDKCall(() => this.client.wiki.space.getNode({ params: { token: nodeToken } }), 'getNode');
276
324
  return res.data.node;
277
325
  }
278
326
 
@@ -280,11 +328,10 @@ class LarkOfficialClient {
280
328
  const params = { page_size: 50 };
281
329
  if (parentNodeToken) params.parent_node_token = parentNodeToken;
282
330
  if (pageToken) params.page_token = pageToken;
283
- const res = await this.client.wiki.spaceNode.list({
284
- path: { space_id: spaceId },
285
- params,
286
- });
287
- if (res.code !== 0) throw new Error(`listNodes failed (${res.code}): ${res.msg}`);
331
+ const res = await this._safeSDKCall(
332
+ () => this.client.wiki.spaceNode.list({ path: { space_id: spaceId }, params }),
333
+ 'listNodes'
334
+ );
288
335
  return { items: res.data.items || [], hasMore: res.data.has_more };
289
336
  }
290
337
 
@@ -293,16 +340,15 @@ class LarkOfficialClient {
293
340
  async listFiles(folderToken, { pageSize = 50, pageToken } = {}) {
294
341
  const params = { page_size: pageSize, folder_token: folderToken || '' };
295
342
  if (pageToken) params.page_token = pageToken;
296
- const res = await this.client.drive.file.list({ params });
297
- if (res.code !== 0) throw new Error(`listFiles failed (${res.code}): ${res.msg}`);
343
+ const res = await this._safeSDKCall(() => this.client.drive.file.list({ params }), 'listFiles');
298
344
  return { items: res.data.files || [], hasMore: res.data.has_more };
299
345
  }
300
346
 
301
347
  async createFolder(name, parentToken) {
302
- const res = await this.client.drive.file.createFolder({
303
- data: { name, folder_token: parentToken || '' },
304
- });
305
- if (res.code !== 0) throw new Error(`createFolder failed (${res.code}): ${res.msg}`);
348
+ const res = await this._safeSDKCall(
349
+ () => this.client.drive.file.createFolder({ data: { name, folder_token: parentToken || '' } }),
350
+ 'createFolder'
351
+ );
306
352
  return { token: res.data.token };
307
353
  }
308
354
 
@@ -312,11 +358,10 @@ class LarkOfficialClient {
312
358
  const data = {};
313
359
  if (emails) data.emails = Array.isArray(emails) ? emails : [emails];
314
360
  if (mobiles) data.mobiles = Array.isArray(mobiles) ? mobiles : [mobiles];
315
- const res = await this.client.contact.user.batchGetId({
316
- data,
317
- params: { user_id_type: 'open_id' },
318
- });
319
- if (res.code !== 0) throw new Error(`findUser failed (${res.code}): ${res.msg}`);
361
+ const res = await this._safeSDKCall(
362
+ () => this.client.contact.user.batchGetId({ data, params: { user_id_type: 'open_id' } }),
363
+ 'findUser'
364
+ );
320
365
  return { userList: res.data.user_list || [] };
321
366
  }
322
367
 
@@ -327,8 +372,10 @@ class LarkOfficialClient {
327
372
  let pageToken;
328
373
  let hasMore = true;
329
374
  while (hasMore) {
330
- const res = await this.client.im.chat.list({ params: { page_size: 100, page_token: pageToken } });
331
- if (res.code !== 0) throw new Error(`listAllChats failed (${res.code}): ${res.msg}`);
375
+ const res = await this._safeSDKCall(
376
+ () => this.client.im.chat.list({ params: { page_size: 100, page_token: pageToken } }),
377
+ 'listAllChats'
378
+ );
332
379
  allChats.push(...(res.data.items || []));
333
380
  pageToken = res.data.page_token;
334
381
  hasMore = res.data.has_more && !!pageToken;
@@ -336,6 +383,72 @@ class LarkOfficialClient {
336
383
  return allChats;
337
384
  }
338
385
 
386
+ // --- Safe SDK Call (extracts real Feishu error from AxiosError) ---
387
+
388
+ async _safeSDKCall(fn, label = 'API') {
389
+ try {
390
+ const res = await fn();
391
+ if (res.code !== 0) throw new Error(`${label} failed (${res.code}): ${res.msg}`);
392
+ return res;
393
+ } catch (err) {
394
+ // Lark SDK uses axios; extract actual Feishu error from response body
395
+ if (err.response?.data) {
396
+ const d = err.response.data;
397
+ const code = d.code ?? d.error ?? 'unknown';
398
+ const msg = d.msg ?? d.error_description ?? d.message ?? JSON.stringify(d);
399
+ throw new Error(`${label} failed (HTTP ${err.response.status}, code=${code}): ${msg}`);
400
+ }
401
+ throw err;
402
+ }
403
+ }
404
+
405
+ // --- Chat Search (keyword-based, works even if bot isn't in the group's list) ---
406
+
407
+ async chatSearch(query) {
408
+ const res = await this._safeSDKCall(
409
+ () => this.client.im.chat.search({ params: { query, page_size: 20 } }),
410
+ 'chatSearch'
411
+ );
412
+ return res.data.items || [];
413
+ }
414
+
415
+ // --- User Name Resolution ---
416
+
417
+ async getUserById(userId, userIdType = 'open_id') {
418
+ if (this._userNameCache.has(userId)) return this._userNameCache.get(userId);
419
+ try {
420
+ const res = await this.client.contact.user.get({
421
+ path: { user_id: userId },
422
+ params: { user_id_type: userIdType },
423
+ });
424
+ if (res.code === 0 && res.data?.user?.name) {
425
+ this._userNameCache.set(userId, res.data.user.name);
426
+ return res.data.user.name;
427
+ }
428
+ } catch {}
429
+ return null;
430
+ }
431
+
432
+ async _populateSenderNames(items) {
433
+ // Collect unique sender IDs that aren't cached
434
+ const unknownIds = new Set();
435
+ for (const item of items) {
436
+ if (item.senderId && !this._userNameCache.has(item.senderId)) {
437
+ unknownIds.add(item.senderId);
438
+ }
439
+ }
440
+ // Batch resolve (sequential, with caching to avoid duplicate calls)
441
+ for (const id of unknownIds) {
442
+ await this.getUserById(id);
443
+ }
444
+ // Populate senderName field
445
+ for (const item of items) {
446
+ if (item.senderId) {
447
+ item.senderName = this._userNameCache.get(item.senderId) || null;
448
+ }
449
+ }
450
+ }
451
+
339
452
  // --- Helpers ---
340
453
 
341
454
  _formatMessage(m) {
package/src/setup.js ADDED
@@ -0,0 +1,178 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Interactive setup wizard for feishu-user-plugin
4
+ *
5
+ * Writes MCP config to ~/.claude.json (or .mcp.json) with credentials.
6
+ * Does NOT require cloning the repo.
7
+ */
8
+
9
+ const fs = require('fs');
10
+ const path = require('path');
11
+ const readline = require('readline');
12
+
13
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
14
+ const ask = (q) => new Promise(resolve => rl.question(q, resolve));
15
+
16
+ const CLAUDE_JSON_PATH = path.join(process.env.HOME || '', '.claude.json');
17
+ const SERVER_NAME = 'feishu-user-plugin';
18
+
19
+ async function main() {
20
+ console.log('='.repeat(60));
21
+ console.log(' feishu-user-plugin Setup Wizard');
22
+ console.log('='.repeat(60));
23
+ console.log('');
24
+
25
+ // Check existing config
26
+ let config = {};
27
+ 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): ');
35
+ if (update.toLowerCase() === 'n') {
36
+ console.log('Cancelled.');
37
+ rl.close();
38
+ return;
39
+ }
40
+ }
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
+
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);
65
+ }
66
+
67
+ // Validate app credentials
68
+ console.log('\nValidating app credentials...');
69
+ try {
70
+ const res = await fetch('https://open.feishu.cn/open-apis/auth/v3/app_access_token/internal', {
71
+ method: 'POST',
72
+ headers: { 'content-type': 'application/json' },
73
+ body: JSON.stringify({ app_id: appId, app_secret: appSecret }),
74
+ });
75
+ const data = await res.json();
76
+ if (data.app_access_token) {
77
+ console.log('App credentials: VALID');
78
+ } else {
79
+ console.error(`App credentials: INVALID — ${data.msg || JSON.stringify(data)}`);
80
+ console.error('Please check your LARK_APP_ID and LARK_APP_SECRET.');
81
+ rl.close();
82
+ process.exit(1);
83
+ }
84
+ } catch (e) {
85
+ console.warn(`Could not validate: ${e.message}. Continuing anyway.`);
86
+ }
87
+
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
+ }
101
+ } 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');
105
+ }
106
+
107
+ const cookie = hasCookie ? existingCookie : 'SETUP_NEEDED';
108
+
109
+ // UAT
110
+ const existingUAT = existingEnv.LARK_USER_ACCESS_TOKEN;
111
+ 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
+ }
120
+
121
+ // Write config
122
+ console.log('\n--- Writing Config ---');
123
+
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
+ },
135
+ };
136
+
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);
155
+
156
+ // Summary
157
+ console.log('\n' + '='.repeat(60));
158
+ console.log(' Setup Complete!');
159
+ console.log('='.repeat(60));
160
+ console.log('');
161
+
162
+ const todo = [];
163
+ if (cookie === 'SETUP_NEEDED') todo.push('Get Cookie: tell Claude Code "帮我设置飞书 Cookie"');
164
+ if (!hasUAT) todo.push('Get UAT: run "npx feishu-user-plugin oauth"');
165
+ todo.push('Restart Claude Code');
166
+
167
+ console.log('Next steps:');
168
+ todo.forEach((t, i) => console.log(` ${i + 1}. ${t}`));
169
+ console.log('');
170
+
171
+ rl.close();
172
+ }
173
+
174
+ main().catch(e => {
175
+ console.error('Setup failed:', e.message);
176
+ rl.close();
177
+ process.exit(1);
178
+ });
@@ -274,7 +274,7 @@ async function testUAT() {
274
274
  }
275
275
 
276
276
  async function main() {
277
- console.log('=== feishu-user-plugin v1.0.0 — Comprehensive Test ===\n');
277
+ console.log('=== feishu-user-plugin v1.1.0 — Comprehensive Test ===\n');
278
278
 
279
279
  await testUserIdentity();
280
280
  console.log('');