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.
- package/.claude-plugin/plugin.json +2 -2
- package/CHANGELOG.md +52 -0
- package/README.md +7 -6
- package/package.json +3 -3
- package/skills/feishu-user-plugin/SKILL.md +1 -1
- package/skills/feishu-user-plugin/references/CLAUDE.md +1 -1
- package/src/cli.js +104 -0
- package/src/index.js +43 -14
- package/src/oauth-auto.js +1 -1
- package/src/official.js +206 -93
- package/src/setup.js +178 -0
- package/src/test-comprehensive.js +1 -1
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "feishu-user-plugin",
|
|
3
|
-
"version": "1.
|
|
4
|
-
"description": "All-in-one Feishu plugin for Claude Code — send messages as yourself, read chats, manage docs/tables/wiki. 33 tools +
|
|
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-33-total)
|
|
7
7
|
[](CONTRIBUTING.md)
|
|
8
8
|
|
|
9
|
-
**All-in-one Feishu/Lark MCP Server -- 33 tools,
|
|
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
|
-
- **
|
|
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.
|
|
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
|
|
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 (
|
|
391
|
+
## Claude Code Slash Commands (9 skills)
|
|
392
392
|
|
|
393
|
-
This plugin includes
|
|
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
|
|
4
|
-
"description": "All-in-one Feishu plugin for Claude Code — send messages as yourself, read chats, manage docs/tables/wiki. 33 tools +
|
|
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/
|
|
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.
|
|
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
|
|
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
|
-
//
|
|
52
|
-
|
|
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.
|
|
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-
|
|
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.
|
|
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
|
-
|
|
587
|
-
//
|
|
588
|
-
|
|
589
|
-
|
|
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.
|
|
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
|
|
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 =
|
|
72
|
-
|
|
73
|
-
|
|
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
|
-
|
|
127
|
-
|
|
128
|
-
|
|
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.
|
|
137
|
-
|
|
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
|
-
|
|
148
|
-
|
|
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.
|
|
153
|
-
|
|
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.
|
|
160
|
-
path: { message_id: messageId },
|
|
161
|
-
|
|
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.
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
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.
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
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.
|
|
191
|
-
|
|
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.
|
|
197
|
-
|
|
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.
|
|
203
|
-
|
|
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.
|
|
228
|
-
path: { app_token: appToken, table_id: tableId },
|
|
229
|
-
|
|
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.
|
|
237
|
-
path: { app_token: appToken, table_id: tableId },
|
|
238
|
-
|
|
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.
|
|
246
|
-
path: { app_token: appToken, table_id: tableId, record_id: recordId },
|
|
247
|
-
|
|
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.
|
|
263
|
-
method: 'POST',
|
|
264
|
-
|
|
265
|
-
|
|
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.
|
|
284
|
-
path: { space_id: spaceId },
|
|
285
|
-
|
|
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.
|
|
303
|
-
data: { name, folder_token: parentToken || '' },
|
|
304
|
-
|
|
305
|
-
|
|
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.
|
|
316
|
-
data,
|
|
317
|
-
|
|
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.
|
|
331
|
-
|
|
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.
|
|
277
|
+
console.log('=== feishu-user-plugin v1.1.0 — Comprehensive Test ===\n');
|
|
278
278
|
|
|
279
279
|
await testUserIdentity();
|
|
280
280
|
console.log('');
|