feishu-user-plugin 1.1.3 → 1.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/plugin.json +1 -1
- package/package.json +1 -1
- package/src/cli.js +85 -12
- package/src/client.js +6 -2
- package/src/config.js +188 -0
- package/src/index.js +79 -18
- package/src/oauth-auto.js +4 -25
- package/src/oauth.js +27 -53
- package/src/official.js +40 -64
- package/src/setup.js +96 -102
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "feishu-user-plugin",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.2.0",
|
|
4
4
|
"description": "All-in-one Feishu plugin for Claude Code — send messages as yourself, read chats, manage docs/tables/wiki. 33 tools + 9 skills, 3 auth layers.",
|
|
5
5
|
"author": {
|
|
6
6
|
"name": "EthanQC"
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "feishu-user-plugin",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.2.0",
|
|
4
4
|
"description": "All-in-one Feishu plugin for Claude Code — send messages as yourself, read chats, manage docs/tables/wiki. 33 tools + 9 skills, 3 auth layers.",
|
|
5
5
|
"main": "src/index.js",
|
|
6
6
|
"bin": {
|
package/src/cli.js
CHANGED
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
* npx feishu-user-plugin setup → Interactive setup wizard
|
|
8
8
|
* npx feishu-user-plugin oauth → Run OAuth flow for UAT
|
|
9
9
|
* npx feishu-user-plugin status → Check auth status
|
|
10
|
+
* npx feishu-user-plugin keepalive → Refresh cookie + UAT (for cron)
|
|
10
11
|
*/
|
|
11
12
|
|
|
12
13
|
const cmd = process.argv[2];
|
|
@@ -21,6 +22,9 @@ switch (cmd) {
|
|
|
21
22
|
case 'status':
|
|
22
23
|
checkStatus();
|
|
23
24
|
break;
|
|
25
|
+
case 'keepalive':
|
|
26
|
+
keepalive();
|
|
27
|
+
break;
|
|
24
28
|
case 'help':
|
|
25
29
|
case '--help':
|
|
26
30
|
case '-h':
|
|
@@ -41,6 +45,7 @@ Commands:
|
|
|
41
45
|
setup Interactive setup wizard — writes MCP config
|
|
42
46
|
oauth Run OAuth flow to obtain user_access_token
|
|
43
47
|
status Check authentication status
|
|
48
|
+
keepalive Refresh cookie + UAT to prevent expiration (for cron jobs)
|
|
44
49
|
help Show this help
|
|
45
50
|
|
|
46
51
|
Quick Start (team members):
|
|
@@ -53,20 +58,85 @@ Quick Start (external users):
|
|
|
53
58
|
2. npx feishu-user-plugin setup
|
|
54
59
|
3. npx feishu-user-plugin oauth
|
|
55
60
|
4. Restart Claude Code
|
|
61
|
+
|
|
62
|
+
Auto-renewal (optional):
|
|
63
|
+
Add to crontab to keep tokens alive even when Claude Code is closed:
|
|
64
|
+
crontab -e → add: 0 */4 * * * npx feishu-user-plugin keepalive >> /tmp/feishu-keepalive.log 2>&1
|
|
56
65
|
`);
|
|
57
66
|
}
|
|
58
67
|
|
|
68
|
+
async function keepalive() {
|
|
69
|
+
const { LarkUserClient } = require('./client');
|
|
70
|
+
const { LarkOfficialClient } = require('./official');
|
|
71
|
+
const { findMcpConfig, persistToConfig } = require('./config');
|
|
72
|
+
|
|
73
|
+
const found = findMcpConfig();
|
|
74
|
+
if (!found) {
|
|
75
|
+
console.error('[keepalive] No config found. Run: npx feishu-user-plugin setup');
|
|
76
|
+
process.exit(1);
|
|
77
|
+
}
|
|
78
|
+
const creds = found.serverEnv;
|
|
79
|
+
let ok = true;
|
|
80
|
+
|
|
81
|
+
// 1. Refresh Cookie
|
|
82
|
+
const cookie = creds.LARK_COOKIE;
|
|
83
|
+
if (cookie && cookie !== 'SETUP_NEEDED') {
|
|
84
|
+
try {
|
|
85
|
+
const client = new LarkUserClient(cookie);
|
|
86
|
+
await client.init();
|
|
87
|
+
// init() calls _getCsrfToken which refreshes sl_session
|
|
88
|
+
persistToConfig({ LARK_COOKIE: client.cookieStr });
|
|
89
|
+
console.log(`[keepalive] Cookie refreshed (user: ${client.userName})`);
|
|
90
|
+
} catch (e) {
|
|
91
|
+
console.error(`[keepalive] Cookie refresh FAILED: ${e.message}`);
|
|
92
|
+
ok = false;
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// 2. Refresh UAT
|
|
97
|
+
const appId = creds.LARK_APP_ID;
|
|
98
|
+
const appSecret = creds.LARK_APP_SECRET;
|
|
99
|
+
const uat = creds.LARK_USER_ACCESS_TOKEN;
|
|
100
|
+
const rt = creds.LARK_USER_REFRESH_TOKEN;
|
|
101
|
+
if (appId && appSecret && uat && uat !== 'SETUP_NEEDED' && rt) {
|
|
102
|
+
try {
|
|
103
|
+
const official = new LarkOfficialClient(appId, appSecret);
|
|
104
|
+
official._uat = uat;
|
|
105
|
+
official._uatRefresh = rt;
|
|
106
|
+
official._uatExpires = 0; // force refresh
|
|
107
|
+
await official._refreshUAT(); // refreshes + persists automatically
|
|
108
|
+
console.log('[keepalive] UAT refreshed');
|
|
109
|
+
} catch (e) {
|
|
110
|
+
console.error(`[keepalive] UAT refresh FAILED: ${e.message}`);
|
|
111
|
+
ok = false;
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
if (ok) {
|
|
116
|
+
console.log('[keepalive] All tokens refreshed successfully');
|
|
117
|
+
}
|
|
118
|
+
process.exit(ok ? 0 : 1);
|
|
119
|
+
}
|
|
120
|
+
|
|
59
121
|
async function checkStatus() {
|
|
60
122
|
const { LarkUserClient } = require('./client');
|
|
61
123
|
const { LarkOfficialClient } = require('./official');
|
|
62
|
-
const
|
|
63
|
-
|
|
124
|
+
const { findMcpConfig } = require('./config');
|
|
125
|
+
|
|
126
|
+
const found = findMcpConfig();
|
|
127
|
+
const creds = found ? found.serverEnv : {};
|
|
64
128
|
|
|
65
129
|
console.log('=== feishu-user-plugin Auth Status ===\n');
|
|
130
|
+
if (found) {
|
|
131
|
+
console.log(`Config: ${found.configPath}${found.projectPath ? ` (project: ${found.projectPath})` : ''}`);
|
|
132
|
+
} else {
|
|
133
|
+
console.log('Config: NOT FOUND (run: npx feishu-user-plugin setup)');
|
|
134
|
+
}
|
|
135
|
+
console.log('');
|
|
66
136
|
|
|
67
137
|
// Cookie
|
|
68
|
-
const cookie =
|
|
69
|
-
if (cookie) {
|
|
138
|
+
const cookie = creds.LARK_COOKIE;
|
|
139
|
+
if (cookie && cookie !== 'SETUP_NEEDED') {
|
|
70
140
|
try {
|
|
71
141
|
const client = new LarkUserClient(cookie);
|
|
72
142
|
await client.init();
|
|
@@ -79,21 +149,24 @@ async function checkStatus() {
|
|
|
79
149
|
}
|
|
80
150
|
|
|
81
151
|
// App credentials
|
|
82
|
-
const appId =
|
|
83
|
-
const appSecret =
|
|
152
|
+
const appId = creds.LARK_APP_ID;
|
|
153
|
+
const appSecret = creds.LARK_APP_SECRET;
|
|
84
154
|
console.log(`App credentials: ${appId && appSecret ? 'OK' : 'NOT SET'}`);
|
|
85
155
|
|
|
86
156
|
// UAT
|
|
87
|
-
const uat =
|
|
88
|
-
const rt =
|
|
89
|
-
if (uat) {
|
|
157
|
+
const uat = creds.LARK_USER_ACCESS_TOKEN;
|
|
158
|
+
const rt = creds.LARK_USER_REFRESH_TOKEN;
|
|
159
|
+
if (uat && uat !== 'SETUP_NEEDED') {
|
|
90
160
|
console.log(`UAT: SET (refresh_token: ${rt ? 'YES' : 'NO'})`);
|
|
91
161
|
if (appId && appSecret) {
|
|
92
162
|
const official = new LarkOfficialClient(appId, appSecret);
|
|
93
|
-
|
|
163
|
+
// Set UAT fields directly (bypassing loadUAT which reads from process.env)
|
|
164
|
+
official._uat = uat;
|
|
165
|
+
official._uatRefresh = rt || null;
|
|
166
|
+
official._uatExpires = parseInt(creds.LARK_UAT_EXPIRES || '0');
|
|
94
167
|
try {
|
|
95
|
-
|
|
96
|
-
console.log(
|
|
168
|
+
await official.listChatsAsUser({ pageSize: 1 });
|
|
169
|
+
console.log(' UAT test: OK (can list chats)');
|
|
97
170
|
} catch (e) {
|
|
98
171
|
console.log(` UAT test: FAILED — ${e.message}`);
|
|
99
172
|
}
|
package/src/client.js
CHANGED
|
@@ -90,7 +90,10 @@ class LarkUserClient {
|
|
|
90
90
|
this._heartbeatTimer = setInterval(async () => {
|
|
91
91
|
try {
|
|
92
92
|
await this._getCsrfToken();
|
|
93
|
-
|
|
93
|
+
// Lazy require to avoid circular dependency at module load time
|
|
94
|
+
const { persistToConfig } = require('./config');
|
|
95
|
+
persistToConfig({ LARK_COOKIE: this.cookieStr });
|
|
96
|
+
console.error('[feishu-user-plugin] Cookie heartbeat: session refreshed and persisted');
|
|
94
97
|
} catch (e) {
|
|
95
98
|
console.error('[feishu-user-plugin] Cookie heartbeat failed:', e.message);
|
|
96
99
|
}
|
|
@@ -252,7 +255,8 @@ class LarkUserClient {
|
|
|
252
255
|
const propBuf = this._encode('TextProperty', { content: elem.userId });
|
|
253
256
|
dictionary[elemId] = { tag: 5, property: propBuf };
|
|
254
257
|
} else if (elem.tag === 'a') {
|
|
255
|
-
|
|
258
|
+
// Link element: content stores the URL, display text goes through innerText
|
|
259
|
+
const propBuf = this._encode('TextProperty', { content: elem.href || elem.text || '' });
|
|
256
260
|
dictionary[elemId] = { tag: 6, property: propBuf };
|
|
257
261
|
}
|
|
258
262
|
}
|
package/src/config.js
ADDED
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
const fs = require('fs');
|
|
2
|
+
const path = require('path');
|
|
3
|
+
|
|
4
|
+
const SERVER_NAMES = ['feishu-user-plugin', 'feishu'];
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Search an mcpServers object for a feishu-user-plugin entry.
|
|
8
|
+
* Returns { serverName, serverEnv } or null.
|
|
9
|
+
*/
|
|
10
|
+
function _findInServers(servers) {
|
|
11
|
+
if (!servers || typeof servers !== 'object') return null;
|
|
12
|
+
for (const name of SERVER_NAMES) {
|
|
13
|
+
if (servers[name]) {
|
|
14
|
+
if (!servers[name].env) servers[name].env = {};
|
|
15
|
+
return { serverName: name, serverEnv: servers[name].env };
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
return null;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Discover the MCP config file containing feishu-user-plugin server entry.
|
|
23
|
+
*
|
|
24
|
+
* Search order:
|
|
25
|
+
* 1. ~/.claude.json — top-level mcpServers
|
|
26
|
+
* 2. ~/.claude.json — projects[*].mcpServers (Claude Code project-level config)
|
|
27
|
+
* 3. ~/.claude/.claude.json — same two-level search
|
|
28
|
+
* 4. <cwd>/.mcp.json — top-level mcpServers (reliable in CLI mode)
|
|
29
|
+
*
|
|
30
|
+
* Returns { configPath, config, serverName, serverEnv, projectPath? } or null.
|
|
31
|
+
*/
|
|
32
|
+
function findMcpConfig() {
|
|
33
|
+
const home = process.env.HOME;
|
|
34
|
+
const candidates = [
|
|
35
|
+
...(home ? [
|
|
36
|
+
path.join(home, '.claude.json'),
|
|
37
|
+
path.join(home, '.claude', '.claude.json'),
|
|
38
|
+
] : []),
|
|
39
|
+
path.join(process.cwd(), '.mcp.json'),
|
|
40
|
+
];
|
|
41
|
+
|
|
42
|
+
for (const configPath of candidates) {
|
|
43
|
+
try {
|
|
44
|
+
const raw = fs.readFileSync(configPath, 'utf8');
|
|
45
|
+
const config = JSON.parse(raw);
|
|
46
|
+
|
|
47
|
+
// Strategy 1: top-level mcpServers
|
|
48
|
+
const topLevel = _findInServers(config.mcpServers);
|
|
49
|
+
if (topLevel) {
|
|
50
|
+
return { configPath, config, ...topLevel, projectPath: null };
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// Strategy 2: projects[*].mcpServers (Claude Code nests project-level config here)
|
|
54
|
+
if (config.projects) {
|
|
55
|
+
for (const [projPath, projConfig] of Object.entries(config.projects)) {
|
|
56
|
+
const nested = _findInServers(projConfig.mcpServers);
|
|
57
|
+
if (nested) {
|
|
58
|
+
return { configPath, config, ...nested, projectPath: projPath };
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// Strategy 3: .mcp.json uses top-level keys as server names (no mcpServers wrapper)
|
|
64
|
+
const bare = _findInServers(config);
|
|
65
|
+
if (bare) {
|
|
66
|
+
return { configPath, config, ...bare, projectPath: null };
|
|
67
|
+
}
|
|
68
|
+
} catch (e) {
|
|
69
|
+
// Only warn if the file exists but is invalid (not for missing files)
|
|
70
|
+
if (e.code !== 'ENOENT') {
|
|
71
|
+
console.error(`[feishu-user-plugin] Warning: Failed to parse ${configPath}: ${e.message}`);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
return null;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Read all LARK_* credentials from the discovered MCP config.
|
|
80
|
+
* Returns an object with all env vars, or {} if no config found.
|
|
81
|
+
*/
|
|
82
|
+
function readCredentials() {
|
|
83
|
+
const found = findMcpConfig();
|
|
84
|
+
if (!found) return {};
|
|
85
|
+
return { ...found.serverEnv };
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Persist key-value updates into the MCP config's env block.
|
|
90
|
+
* Uses findMcpConfig() to locate the correct entry, then writes back.
|
|
91
|
+
* Returns true if persisted successfully, false otherwise.
|
|
92
|
+
*/
|
|
93
|
+
function persistToConfig(updates) {
|
|
94
|
+
try {
|
|
95
|
+
const found = findMcpConfig();
|
|
96
|
+
if (!found) {
|
|
97
|
+
console.error('[feishu-user-plugin] WARNING: No MCP config found. Update your config manually.');
|
|
98
|
+
return false;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
const { configPath, config, serverName, projectPath } = found;
|
|
102
|
+
|
|
103
|
+
// Navigate to the correct env object
|
|
104
|
+
let env;
|
|
105
|
+
if (projectPath) {
|
|
106
|
+
env = config.projects[projectPath].mcpServers[serverName].env;
|
|
107
|
+
} else if (config.mcpServers?.[serverName]) {
|
|
108
|
+
env = config.mcpServers[serverName].env;
|
|
109
|
+
} else {
|
|
110
|
+
env = config[serverName].env;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
Object.assign(env, updates);
|
|
114
|
+
fs.writeFileSync(configPath, JSON.stringify(config, null, 2) + '\n');
|
|
115
|
+
console.error(`[feishu-user-plugin] Config persisted to ${configPath}${projectPath ? ` (project: ${projectPath})` : ''}`);
|
|
116
|
+
return true;
|
|
117
|
+
} catch (e) {
|
|
118
|
+
console.error(`[feishu-user-plugin] Failed to persist config: ${e.message}`);
|
|
119
|
+
return false;
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Write a complete feishu-user-plugin MCP server entry to a config file.
|
|
125
|
+
* Used by the setup wizard.
|
|
126
|
+
*
|
|
127
|
+
* If an existing config is found via findMcpConfig(), updates it in-place
|
|
128
|
+
* (preserving its location — top-level or project-level).
|
|
129
|
+
* Otherwise, writes to ~/.claude.json top-level mcpServers.
|
|
130
|
+
*
|
|
131
|
+
* @param {object} env - The env vars to write
|
|
132
|
+
* @param {string} [configPath] - Override the target config file path
|
|
133
|
+
* @param {string} [projectPath] - If writing to a project-level entry
|
|
134
|
+
* @returns {{ configPath: string }} The path that was written
|
|
135
|
+
*/
|
|
136
|
+
function writeNewConfig(env, configPath, projectPath) {
|
|
137
|
+
if (!configPath) {
|
|
138
|
+
configPath = path.join(process.env.HOME || '', '.claude.json');
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
if (projectPath) {
|
|
142
|
+
// Verify the project entry still exists; warn if it was removed between discovery and write
|
|
143
|
+
let existing = {};
|
|
144
|
+
try { existing = JSON.parse(fs.readFileSync(configPath, 'utf8')); } catch {}
|
|
145
|
+
if (!existing.projects?.[projectPath]) {
|
|
146
|
+
console.error(`[feishu-user-plugin] Warning: project entry "${projectPath}" not found in ${configPath}, writing to top-level mcpServers`);
|
|
147
|
+
projectPath = null;
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
let config = {};
|
|
152
|
+
try {
|
|
153
|
+
config = JSON.parse(fs.readFileSync(configPath, 'utf8'));
|
|
154
|
+
} catch {}
|
|
155
|
+
|
|
156
|
+
const serverEntry = {
|
|
157
|
+
command: 'npx',
|
|
158
|
+
args: ['-y', 'feishu-user-plugin'],
|
|
159
|
+
env,
|
|
160
|
+
};
|
|
161
|
+
|
|
162
|
+
if (projectPath && config.projects?.[projectPath]) {
|
|
163
|
+
// Write into existing project-level config
|
|
164
|
+
if (!config.projects[projectPath].mcpServers) config.projects[projectPath].mcpServers = {};
|
|
165
|
+
config.projects[projectPath].mcpServers['feishu-user-plugin'] = serverEntry;
|
|
166
|
+
if (config.projects[projectPath].mcpServers.feishu) {
|
|
167
|
+
delete config.projects[projectPath].mcpServers.feishu;
|
|
168
|
+
}
|
|
169
|
+
} else if (configPath.endsWith('.mcp.json') && !config.mcpServers) {
|
|
170
|
+
// Bare .mcp.json format: server entries at top level (no mcpServers wrapper)
|
|
171
|
+
config['feishu-user-plugin'] = serverEntry;
|
|
172
|
+
if (config.feishu) {
|
|
173
|
+
delete config.feishu;
|
|
174
|
+
}
|
|
175
|
+
} else {
|
|
176
|
+
// Write to top-level mcpServers (default for ~/.claude.json)
|
|
177
|
+
if (!config.mcpServers) config.mcpServers = {};
|
|
178
|
+
config.mcpServers['feishu-user-plugin'] = serverEntry;
|
|
179
|
+
if (config.mcpServers.feishu) {
|
|
180
|
+
delete config.mcpServers.feishu;
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
fs.writeFileSync(configPath, JSON.stringify(config, null, 2) + '\n');
|
|
185
|
+
return { configPath };
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
module.exports = { findMcpConfig, readCredentials, persistToConfig, writeNewConfig, SERVER_NAMES };
|
package/src/index.js
CHANGED
|
@@ -6,6 +6,8 @@ const {
|
|
|
6
6
|
ListToolsRequestSchema,
|
|
7
7
|
} = require('@modelcontextprotocol/sdk/types.js');
|
|
8
8
|
const path = require('path');
|
|
9
|
+
// Local dev fallback: MCP clients inject env vars from config's env block at spawn time.
|
|
10
|
+
// This dotenv line only matters when running locally with a .env file (e.g. during development).
|
|
9
11
|
require('dotenv').config({ path: path.join(__dirname, '..', '.env') });
|
|
10
12
|
const { LarkUserClient } = require('./client');
|
|
11
13
|
const { LarkOfficialClient } = require('./official');
|
|
@@ -394,6 +396,17 @@ const TOOLS = [
|
|
|
394
396
|
required: ['document_id'],
|
|
395
397
|
},
|
|
396
398
|
},
|
|
399
|
+
{
|
|
400
|
+
name: 'get_doc_blocks',
|
|
401
|
+
description: '[Official API] Get structured block tree of a document. Returns block types, content, and hierarchy for precise document analysis.',
|
|
402
|
+
inputSchema: {
|
|
403
|
+
type: 'object',
|
|
404
|
+
properties: {
|
|
405
|
+
document_id: { type: 'string', description: 'Document ID (from search_docs or create_doc)' },
|
|
406
|
+
},
|
|
407
|
+
required: ['document_id'],
|
|
408
|
+
},
|
|
409
|
+
},
|
|
397
410
|
{
|
|
398
411
|
name: 'create_doc',
|
|
399
412
|
description: '[Official API] Create a new Feishu document.',
|
|
@@ -522,6 +535,33 @@ const TOOLS = [
|
|
|
522
535
|
},
|
|
523
536
|
},
|
|
524
537
|
|
|
538
|
+
// ========== Upload — Official API ==========
|
|
539
|
+
{
|
|
540
|
+
name: 'upload_image',
|
|
541
|
+
description: '[Official API] Upload an image file to Feishu. Returns image_key for use with send_image_as_user.',
|
|
542
|
+
inputSchema: {
|
|
543
|
+
type: 'object',
|
|
544
|
+
properties: {
|
|
545
|
+
image_path: { type: 'string', description: 'Absolute path to the image file on disk' },
|
|
546
|
+
image_type: { type: 'string', enum: ['message', 'avatar'], description: 'Image usage type (default: message)' },
|
|
547
|
+
},
|
|
548
|
+
required: ['image_path'],
|
|
549
|
+
},
|
|
550
|
+
},
|
|
551
|
+
{
|
|
552
|
+
name: 'upload_file',
|
|
553
|
+
description: '[Official API] Upload a file to Feishu. Returns file_key for use with send_file_as_user.',
|
|
554
|
+
inputSchema: {
|
|
555
|
+
type: 'object',
|
|
556
|
+
properties: {
|
|
557
|
+
file_path: { type: 'string', description: 'Absolute path to the file on disk' },
|
|
558
|
+
file_type: { type: 'string', enum: ['opus', 'mp4', 'pdf', 'doc', 'xls', 'ppt', 'stream'], description: 'File type (default: stream for generic files)' },
|
|
559
|
+
file_name: { type: 'string', description: 'Display file name (optional, defaults to basename)' },
|
|
560
|
+
},
|
|
561
|
+
required: ['file_path'],
|
|
562
|
+
},
|
|
563
|
+
},
|
|
564
|
+
|
|
525
565
|
// ========== Contact — Official API ==========
|
|
526
566
|
{
|
|
527
567
|
name: 'find_user',
|
|
@@ -539,7 +579,7 @@ const TOOLS = [
|
|
|
539
579
|
// --- Server ---
|
|
540
580
|
|
|
541
581
|
const server = new Server(
|
|
542
|
-
{ name: 'feishu-user-plugin', version: '
|
|
582
|
+
{ name: 'feishu-user-plugin', version: require('../package.json').version },
|
|
543
583
|
{ capabilities: { tools: {} } }
|
|
544
584
|
);
|
|
545
585
|
|
|
@@ -571,8 +611,13 @@ async function handleTool(name, args) {
|
|
|
571
611
|
case 'send_to_user': {
|
|
572
612
|
const c = await getUserClient();
|
|
573
613
|
const results = await c.search(args.user_name);
|
|
574
|
-
const
|
|
575
|
-
if (
|
|
614
|
+
const users = results.filter(r => r.type === 'user');
|
|
615
|
+
if (users.length === 0) return text(`User "${args.user_name}" not found. Results: ${JSON.stringify(results)}`);
|
|
616
|
+
if (users.length > 1) {
|
|
617
|
+
const candidates = users.slice(0, 5).map(u => ` - ${u.title} (ID: ${u.id})`).join('\n');
|
|
618
|
+
return text(`Multiple users match "${args.user_name}":\n${candidates}\nUse search_contacts to find the exact user, then create_p2p_chat + send_as_user.`);
|
|
619
|
+
}
|
|
620
|
+
const user = users[0];
|
|
576
621
|
const chatId = await c.createChat(user.id);
|
|
577
622
|
if (!chatId) return text(`Failed to create chat with ${user.title}`);
|
|
578
623
|
const r = await c.sendMessage(chatId, args.text);
|
|
@@ -581,8 +626,13 @@ async function handleTool(name, args) {
|
|
|
581
626
|
case 'send_to_group': {
|
|
582
627
|
const c = await getUserClient();
|
|
583
628
|
const results = await c.search(args.group_name);
|
|
584
|
-
const
|
|
585
|
-
if (
|
|
629
|
+
const groups = results.filter(r => r.type === 'group');
|
|
630
|
+
if (groups.length === 0) return text(`Group "${args.group_name}" not found. Results: ${JSON.stringify(results)}`);
|
|
631
|
+
if (groups.length > 1) {
|
|
632
|
+
const candidates = groups.slice(0, 5).map(g => ` - ${g.title} (ID: ${g.id})`).join('\n');
|
|
633
|
+
return text(`Multiple groups match "${args.group_name}":\n${candidates}\nUse search_contacts to find the exact group, then send_as_user with the ID.`);
|
|
634
|
+
}
|
|
635
|
+
const group = groups[0];
|
|
586
636
|
const r = await c.sendMessage(group.id, args.text);
|
|
587
637
|
return sendResult(r, `Text sent to group "${group.title}" (${group.id})`);
|
|
588
638
|
}
|
|
@@ -633,20 +683,16 @@ async function handleTool(name, args) {
|
|
|
633
683
|
}
|
|
634
684
|
case 'get_user_info': {
|
|
635
685
|
let n = null;
|
|
636
|
-
// Strategy 1:
|
|
686
|
+
// Strategy 1: Official API contact lookup (works for same-tenant users by open_id)
|
|
637
687
|
try {
|
|
638
|
-
const
|
|
639
|
-
n = await
|
|
640
|
-
if (!n && args.user_id) {
|
|
641
|
-
await c.search(args.user_id);
|
|
642
|
-
n = await c.getUserName(args.user_id);
|
|
643
|
-
}
|
|
688
|
+
const official = getOfficialClient();
|
|
689
|
+
n = await official.getUserById(args.user_id, 'open_id');
|
|
644
690
|
} catch {}
|
|
645
|
-
// Strategy 2:
|
|
691
|
+
// Strategy 2: User identity client cache (populated by previous search/init calls)
|
|
646
692
|
if (!n) {
|
|
647
693
|
try {
|
|
648
|
-
const
|
|
649
|
-
n = await
|
|
694
|
+
const c = await getUserClient();
|
|
695
|
+
n = await c.getUserName(args.user_id);
|
|
650
696
|
} catch {}
|
|
651
697
|
}
|
|
652
698
|
return text(n ? `User ${args.user_id}: ${n}` : `Could not resolve user ${args.user_id}. This user may be from an external tenant. Try search_contacts with the user's display name instead.`);
|
|
@@ -672,7 +718,8 @@ async function handleTool(name, args) {
|
|
|
672
718
|
const official = getOfficialClient();
|
|
673
719
|
let chatId = args.chat_id;
|
|
674
720
|
let uc = null;
|
|
675
|
-
|
|
721
|
+
let ucError = null;
|
|
722
|
+
try { uc = await getUserClient(); } catch (e) { ucError = e; }
|
|
676
723
|
// If chat_id is not numeric or oc_, try to resolve as user name → P2P chat
|
|
677
724
|
if (!/^\d+$/.test(chatId) && !chatId.startsWith('oc_')) {
|
|
678
725
|
if (uc) {
|
|
@@ -689,7 +736,8 @@ async function handleTool(name, args) {
|
|
|
689
736
|
else return text(`Cannot resolve "${args.chat_id}" to a chat. Use search_contacts to find the ID first.`);
|
|
690
737
|
}
|
|
691
738
|
} else {
|
|
692
|
-
|
|
739
|
+
const hint = ucError ? `Cookie auth failed: ${ucError.message}. Fix LARK_COOKIE first, or p` : 'P';
|
|
740
|
+
return text(`"${args.chat_id}" is not a valid chat ID. ${hint}rovide a numeric ID or oc_xxx format. Use search_contacts + create_p2p_chat to get the ID.`);
|
|
693
741
|
}
|
|
694
742
|
}
|
|
695
743
|
return json(await official.readMessagesAsUser(chatId, {
|
|
@@ -752,6 +800,8 @@ async function handleTool(name, args) {
|
|
|
752
800
|
return json(await getOfficialClient().searchDocs(args.query));
|
|
753
801
|
case 'read_doc':
|
|
754
802
|
return json(await getOfficialClient().readDoc(args.document_id));
|
|
803
|
+
case 'get_doc_blocks':
|
|
804
|
+
return json(await getOfficialClient().getDocBlocks(args.document_id));
|
|
755
805
|
case 'create_doc':
|
|
756
806
|
return text(`Document created: ${(await getOfficialClient().createDoc(args.title, args.folder_id)).documentId}`);
|
|
757
807
|
|
|
@@ -791,6 +841,17 @@ async function handleTool(name, args) {
|
|
|
791
841
|
case 'find_user':
|
|
792
842
|
return json(await getOfficialClient().findUserByIdentity({ emails: args.email, mobiles: args.mobile }));
|
|
793
843
|
|
|
844
|
+
// --- Upload ---
|
|
845
|
+
|
|
846
|
+
case 'upload_image': {
|
|
847
|
+
const r = await getOfficialClient().uploadImage(args.image_path, args.image_type);
|
|
848
|
+
return text(`Image uploaded: ${r.imageKey}\nUse this image_key with send_image_as_user to send it.`);
|
|
849
|
+
}
|
|
850
|
+
case 'upload_file': {
|
|
851
|
+
const r = await getOfficialClient().uploadFile(args.file_path, args.file_type, args.file_name);
|
|
852
|
+
return text(`File uploaded: ${r.fileKey}\nUse this file_key with send_file_as_user to send it.`);
|
|
853
|
+
}
|
|
854
|
+
|
|
794
855
|
default:
|
|
795
856
|
return text(`Unknown tool: ${name}`);
|
|
796
857
|
}
|
|
@@ -804,7 +865,7 @@ async function main() {
|
|
|
804
865
|
const hasCookie = !!process.env.LARK_COOKIE;
|
|
805
866
|
const hasApp = !!(process.env.LARK_APP_ID && process.env.LARK_APP_SECRET);
|
|
806
867
|
const hasUAT = !!process.env.LARK_USER_ACCESS_TOKEN;
|
|
807
|
-
console.error(`[feishu-user-plugin] MCP Server
|
|
868
|
+
console.error(`[feishu-user-plugin] MCP Server v${require('../package.json').version} — ${TOOLS.length} tools`);
|
|
808
869
|
console.error(`[feishu-user-plugin] Auth: Cookie=${hasCookie ? 'YES' : 'NO'} App=${hasApp ? 'YES' : 'NO'} UAT=${hasUAT ? 'YES' : 'NO'}`);
|
|
809
870
|
if (!hasCookie) console.error('[feishu-user-plugin] WARNING: LARK_COOKIE not set — user identity tools (send_to_user, etc.) will fail');
|
|
810
871
|
if (!hasApp) console.error('[feishu-user-plugin] WARNING: LARK_APP_ID/SECRET not set — official API tools (read_messages, docs, etc.) will fail');
|
package/src/oauth-auto.js
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
// Automated OAuth
|
|
2
|
+
// DEV ONLY: Automated OAuth using local Playwright (not used in production).
|
|
3
|
+
// Uses .env directly; not migrated to config module.
|
|
4
|
+
// Requires: npm install playwright (not in package.json dependencies)
|
|
3
5
|
const http = require('http');
|
|
4
6
|
const { chromium } = require('playwright');
|
|
5
7
|
const fs = require('fs');
|
|
@@ -30,7 +32,7 @@ function saveToken(tokenData) {
|
|
|
30
32
|
LARK_USER_ACCESS_TOKEN: tokenData.access_token,
|
|
31
33
|
LARK_USER_REFRESH_TOKEN: tokenData.refresh_token || '',
|
|
32
34
|
LARK_UAT_SCOPE: tokenData.scope || '',
|
|
33
|
-
LARK_UAT_EXPIRES: String(Math.floor(Date.now() / 1000 + tokenData.expires_in)),
|
|
35
|
+
LARK_UAT_EXPIRES: String(Math.floor(Date.now() / 1000 + (typeof tokenData.expires_in === 'number' && tokenData.expires_in > 0 ? tokenData.expires_in : 7200))),
|
|
34
36
|
};
|
|
35
37
|
for (const [key, val] of Object.entries(updates)) {
|
|
36
38
|
const regex = new RegExp(`^${key}=.*$`, 'm');
|
|
@@ -161,29 +163,6 @@ async function run() {
|
|
|
161
163
|
console.log('scope:', tokenData.scope);
|
|
162
164
|
console.log('expires_in:', tokenData.expires_in, 's');
|
|
163
165
|
|
|
164
|
-
// Test P2P message reading
|
|
165
|
-
console.log('\n[test] Testing P2P message reading...');
|
|
166
|
-
const testRes = await fetch('https://open.feishu.cn/open-apis/im/v1/messages?container_id_type=chat&container_id=oc_97a52756ee2c4351a2a86e6aa33e8ca4&page_size=2&sort_type=ByCreateTimeDesc', {
|
|
167
|
-
headers: { 'Authorization': `Bearer ${tokenData.access_token}` },
|
|
168
|
-
});
|
|
169
|
-
const testData = await testRes.json();
|
|
170
|
-
if (testData.code === 0) {
|
|
171
|
-
console.log('[test] P2P: SUCCESS!', testData.data?.items?.length, 'messages');
|
|
172
|
-
} else {
|
|
173
|
-
console.log('[test] P2P: Error', testData.code, testData.msg);
|
|
174
|
-
}
|
|
175
|
-
|
|
176
|
-
// Test group messages
|
|
177
|
-
const grpRes = await fetch('https://open.feishu.cn/open-apis/im/v1/messages?container_id_type=chat&container_id=oc_6ae081b457d07e9651d615493b7f1096&page_size=2&sort_type=ByCreateTimeDesc', {
|
|
178
|
-
headers: { 'Authorization': `Bearer ${tokenData.access_token}` },
|
|
179
|
-
});
|
|
180
|
-
const grpData = await grpRes.json();
|
|
181
|
-
if (grpData.code === 0) {
|
|
182
|
-
console.log('[test] Group: SUCCESS!', grpData.data?.items?.length, 'messages');
|
|
183
|
-
} else {
|
|
184
|
-
console.log('[test] Group: Error', grpData.code, grpData.msg);
|
|
185
|
-
}
|
|
186
|
-
|
|
187
166
|
} catch (e) {
|
|
188
167
|
console.error('\nError:', e.message);
|
|
189
168
|
await page.screenshot({ path: '/tmp/feishu-oauth-error.png' }).catch(() => {});
|
package/src/oauth.js
CHANGED
|
@@ -2,33 +2,31 @@
|
|
|
2
2
|
/**
|
|
3
3
|
* OAuth 授权脚本 — 获取带 IM 权限的 user_access_token
|
|
4
4
|
*
|
|
5
|
-
* 用法:
|
|
5
|
+
* 用法: npx feishu-user-plugin oauth
|
|
6
6
|
*
|
|
7
7
|
* 流程 (新版 End User Consent):
|
|
8
8
|
* 1. 查询应用信息,提示用户选择正确的飞书账号
|
|
9
9
|
* 2. 启动本地 HTTP 服务器 (端口 9997)
|
|
10
10
|
* 3. 打开 accounts.feishu.cn 授权页面 (新版 OAuth 2.0)
|
|
11
11
|
* 4. 用户点击"授权"后,用 /authen/v2/oauth/token 交换 token
|
|
12
|
-
* 5. 保存
|
|
12
|
+
* 5. 保存 token 到 MCP 配置文件
|
|
13
13
|
*/
|
|
14
14
|
|
|
15
15
|
const http = require('http');
|
|
16
16
|
const { execSync } = require('child_process');
|
|
17
|
-
const
|
|
18
|
-
const path = require('path');
|
|
19
|
-
const dotenv = require('dotenv');
|
|
17
|
+
const { readCredentials, persistToConfig } = require('./config');
|
|
20
18
|
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
const
|
|
24
|
-
const APP_SECRET = process.env.LARK_APP_SECRET;
|
|
19
|
+
const creds = readCredentials();
|
|
20
|
+
const APP_ID = creds.LARK_APP_ID;
|
|
21
|
+
const APP_SECRET = creds.LARK_APP_SECRET;
|
|
25
22
|
const PORT = 9997;
|
|
26
23
|
const REDIRECT_URI = `http://127.0.0.1:${PORT}/callback`;
|
|
27
24
|
// offline_access is required to get refresh_token for auto-renewal
|
|
28
25
|
const SCOPES = 'offline_access im:message im:message:readonly im:chat:readonly contact:user.base:readonly';
|
|
29
26
|
|
|
30
27
|
if (!APP_ID || !APP_SECRET) {
|
|
31
|
-
console.error('Missing LARK_APP_ID or LARK_APP_SECRET
|
|
28
|
+
console.error('Missing LARK_APP_ID or LARK_APP_SECRET.');
|
|
29
|
+
console.error('Run "npx feishu-user-plugin setup" first to configure app credentials.');
|
|
32
30
|
process.exit(1);
|
|
33
31
|
}
|
|
34
32
|
|
|
@@ -100,10 +98,6 @@ async function exchangeCode(code) {
|
|
|
100
98
|
}
|
|
101
99
|
|
|
102
100
|
function saveToken(tokenData) {
|
|
103
|
-
const envPath = path.join(__dirname, '..', '.env');
|
|
104
|
-
let envContent = '';
|
|
105
|
-
try { envContent = fs.readFileSync(envPath, 'utf8'); } catch {}
|
|
106
|
-
|
|
107
101
|
const updates = {
|
|
108
102
|
LARK_USER_ACCESS_TOKEN: tokenData.access_token,
|
|
109
103
|
LARK_USER_REFRESH_TOKEN: tokenData.refresh_token || '',
|
|
@@ -111,44 +105,13 @@ function saveToken(tokenData) {
|
|
|
111
105
|
LARK_UAT_EXPIRES: String(Math.floor(Date.now() / 1000 + (typeof tokenData.expires_in === 'number' && tokenData.expires_in > 0 ? tokenData.expires_in : 7200))),
|
|
112
106
|
};
|
|
113
107
|
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
envContent += `\n${key}=${val}`;
|
|
108
|
+
const ok = persistToConfig(updates);
|
|
109
|
+
if (!ok) {
|
|
110
|
+
console.error('WARNING: Tokens could not be saved to config. Copy them manually:');
|
|
111
|
+
for (const [k, v] of Object.entries(updates)) {
|
|
112
|
+
console.error(` ${k}=${v}`);
|
|
120
113
|
}
|
|
121
114
|
}
|
|
122
|
-
|
|
123
|
-
fs.writeFileSync(envPath, envContent.trim() + '\n');
|
|
124
|
-
|
|
125
|
-
// Also persist to ~/.claude.json MCP config so MCP restart picks up tokens immediately
|
|
126
|
-
_persistToClaudeJson(updates);
|
|
127
|
-
}
|
|
128
|
-
|
|
129
|
-
function _persistToClaudeJson(updates) {
|
|
130
|
-
const claudeJsonPaths = [
|
|
131
|
-
path.join(process.env.HOME || '', '.claude.json'),
|
|
132
|
-
path.join(process.env.HOME || '', '.claude', '.claude.json'),
|
|
133
|
-
];
|
|
134
|
-
for (const cjPath of claudeJsonPaths) {
|
|
135
|
-
try {
|
|
136
|
-
const raw = fs.readFileSync(cjPath, 'utf8');
|
|
137
|
-
const config = JSON.parse(raw);
|
|
138
|
-
const servers = config.mcpServers || {};
|
|
139
|
-
for (const name of ['feishu-user-plugin', 'feishu']) {
|
|
140
|
-
if (servers[name]?.env) {
|
|
141
|
-
Object.assign(servers[name].env, updates);
|
|
142
|
-
fs.writeFileSync(cjPath, JSON.stringify(config, null, 2) + '\n');
|
|
143
|
-
console.log(`[feishu-user-plugin] OAuth tokens persisted to ${cjPath}`);
|
|
144
|
-
return;
|
|
145
|
-
}
|
|
146
|
-
}
|
|
147
|
-
} catch (e) {
|
|
148
|
-
console.error(`[feishu-user-plugin] Failed to persist tokens to ${cjPath}: ${e.message}`);
|
|
149
|
-
}
|
|
150
|
-
}
|
|
151
|
-
console.error('[feishu-user-plugin] WARNING: Could not persist tokens to ~/.claude.json. Tokens saved to .env only — copy them to your MCP config manually.');
|
|
152
115
|
}
|
|
153
116
|
|
|
154
117
|
const server = http.createServer(async (req, res) => {
|
|
@@ -173,7 +136,7 @@ const server = http.createServer(async (req, res) => {
|
|
|
173
136
|
<p>scope: ${tokenData.scope}</p>
|
|
174
137
|
<p>expires_in: ${tokenData.expires_in}s</p>
|
|
175
138
|
<p>refresh_token: ${hasRefresh ? '✅ 已获取(30天有效,支持自动续期)' : '❌ 未返回(token 将在 2 小时后过期,需重新授权)'}</p>
|
|
176
|
-
<p>已保存到
|
|
139
|
+
<p>已保存到 MCP 配置文件,可以关闭此页面。</p>`);
|
|
177
140
|
|
|
178
141
|
console.log('\n=== OAuth 授权成功 ===');
|
|
179
142
|
console.log('scope:', tokenData.scope);
|
|
@@ -185,7 +148,7 @@ const server = http.createServer(async (req, res) => {
|
|
|
185
148
|
console.log(' - 授权时 scope 中未包含 offline_access');
|
|
186
149
|
console.log(' Token 将在 2 小时后过期,届时需要重新运行此脚本。');
|
|
187
150
|
}
|
|
188
|
-
console.log('token 已保存到
|
|
151
|
+
console.log('token 已保存到 MCP 配置文件');
|
|
189
152
|
|
|
190
153
|
setTimeout(() => { server.close(); process.exit(0); }, 1000);
|
|
191
154
|
} catch (e) {
|
|
@@ -200,6 +163,16 @@ const server = http.createServer(async (req, res) => {
|
|
|
200
163
|
res.end('Not found');
|
|
201
164
|
});
|
|
202
165
|
|
|
166
|
+
server.on('error', (e) => {
|
|
167
|
+
if (e.code === 'EADDRINUSE') {
|
|
168
|
+
console.error(`\nPort ${PORT} is already in use. Another OAuth process may be running.`);
|
|
169
|
+
console.error('Wait a minute and try again, or kill the process using the port.');
|
|
170
|
+
} else {
|
|
171
|
+
console.error('Server error:', e.message);
|
|
172
|
+
}
|
|
173
|
+
process.exit(1);
|
|
174
|
+
});
|
|
175
|
+
|
|
203
176
|
server.listen(PORT, '127.0.0.1', async () => {
|
|
204
177
|
const authUrl = `https://accounts.feishu.cn/open-apis/authen/v1/authorize?client_id=${APP_ID}&redirect_uri=${encodeURIComponent(REDIRECT_URI)}&response_type=code&scope=${encodeURIComponent(SCOPES)}`;
|
|
205
178
|
|
|
@@ -229,7 +202,8 @@ server.listen(PORT, '127.0.0.1', async () => {
|
|
|
229
202
|
console.log('授权 URL:', authUrl);
|
|
230
203
|
|
|
231
204
|
try {
|
|
232
|
-
|
|
205
|
+
const openCmd = process.platform === 'win32' ? 'start' : process.platform === 'darwin' ? 'open' : 'xdg-open';
|
|
206
|
+
execSync(`${openCmd} "${authUrl}"`);
|
|
233
207
|
} catch {
|
|
234
208
|
console.log('\n请手动在浏览器中打开上面的 URL');
|
|
235
209
|
}
|
package/src/official.js
CHANGED
|
@@ -66,77 +66,23 @@ class LarkOfficialClient {
|
|
|
66
66
|
}
|
|
67
67
|
|
|
68
68
|
_persistUAT() {
|
|
69
|
-
|
|
70
|
-
const
|
|
71
|
-
|
|
69
|
+
// Lazy require to avoid circular dependency at module load time
|
|
70
|
+
const { persistToConfig } = require('./config');
|
|
71
|
+
persistToConfig({
|
|
72
72
|
LARK_USER_ACCESS_TOKEN: this._uat,
|
|
73
73
|
LARK_USER_REFRESH_TOKEN: this._uatRefresh,
|
|
74
74
|
LARK_UAT_EXPIRES: String(this._uatExpires),
|
|
75
|
-
};
|
|
76
|
-
|
|
77
|
-
// Strategy 1: Update ~/.claude.json MCP config (works for npx users)
|
|
78
|
-
const claudeJsonPaths = [
|
|
79
|
-
path.join(process.env.HOME || '', '.claude.json'),
|
|
80
|
-
path.join(process.env.HOME || '', '.claude', '.claude.json'),
|
|
81
|
-
];
|
|
82
|
-
for (const cjPath of claudeJsonPaths) {
|
|
83
|
-
try {
|
|
84
|
-
const raw = fs.readFileSync(cjPath, 'utf8');
|
|
85
|
-
const config = JSON.parse(raw);
|
|
86
|
-
const servers = config.mcpServers || {};
|
|
87
|
-
// Find our server entry by name
|
|
88
|
-
for (const name of ['feishu-user-plugin', 'feishu']) {
|
|
89
|
-
if (servers[name]?.env) {
|
|
90
|
-
Object.assign(servers[name].env, updates);
|
|
91
|
-
fs.writeFileSync(cjPath, JSON.stringify(config, null, 2) + '\n');
|
|
92
|
-
console.error('[feishu-user-plugin] UAT persisted to', cjPath);
|
|
93
|
-
return;
|
|
94
|
-
}
|
|
95
|
-
}
|
|
96
|
-
} catch {}
|
|
97
|
-
}
|
|
98
|
-
|
|
99
|
-
// Strategy 2: Update project .mcp.json
|
|
100
|
-
const mcpJsonPaths = [
|
|
101
|
-
path.join(process.cwd(), '.mcp.json'),
|
|
102
|
-
];
|
|
103
|
-
for (const mjPath of mcpJsonPaths) {
|
|
104
|
-
try {
|
|
105
|
-
const raw = fs.readFileSync(mjPath, 'utf8');
|
|
106
|
-
const config = JSON.parse(raw);
|
|
107
|
-
const servers = config.mcpServers || config;
|
|
108
|
-
for (const name of ['feishu-user-plugin', 'feishu']) {
|
|
109
|
-
if (servers[name]?.env) {
|
|
110
|
-
Object.assign(servers[name].env, updates);
|
|
111
|
-
fs.writeFileSync(mjPath, JSON.stringify(config, null, 2) + '\n');
|
|
112
|
-
console.error('[feishu-user-plugin] UAT persisted to', mjPath);
|
|
113
|
-
return;
|
|
114
|
-
}
|
|
115
|
-
}
|
|
116
|
-
} catch {}
|
|
117
|
-
}
|
|
118
|
-
|
|
119
|
-
// Strategy 3: Fallback to .env in project root (for local dev)
|
|
120
|
-
const envPath = path.join(__dirname, '..', '.env');
|
|
121
|
-
try {
|
|
122
|
-
let env = '';
|
|
123
|
-
try { env = fs.readFileSync(envPath, 'utf8'); } catch {}
|
|
124
|
-
for (const [key, val] of Object.entries(updates)) {
|
|
125
|
-
const regex = new RegExp(`^${key}=.*$`, 'm');
|
|
126
|
-
if (regex.test(env)) env = env.replace(regex, `${key}=${val}`);
|
|
127
|
-
else env += `\n${key}=${val}`;
|
|
128
|
-
}
|
|
129
|
-
fs.writeFileSync(envPath, env.trim() + '\n');
|
|
130
|
-
} catch {}
|
|
75
|
+
});
|
|
131
76
|
}
|
|
132
77
|
|
|
133
78
|
// --- UAT-based IM operations (for P2P chats) ---
|
|
134
79
|
|
|
135
|
-
// Wrapper: call fn with UAT, retry once after refresh if auth fails
|
|
80
|
+
// Wrapper: call fn with UAT, retry once after refresh if auth fails
|
|
136
81
|
async _withUAT(fn) {
|
|
137
82
|
let uat = await this._getValidUAT();
|
|
138
83
|
const data = await fn(uat);
|
|
139
|
-
|
|
84
|
+
// Known auth error codes: 99991668 (invalid), 99991663 (expired), 99991677 (auth_expired)
|
|
85
|
+
if (data.code === 99991668 || data.code === 99991663 || data.code === 99991677) {
|
|
140
86
|
// Token invalid/expired — try refresh once
|
|
141
87
|
uat = await this._refreshUAT();
|
|
142
88
|
return fn(uat);
|
|
@@ -231,6 +177,36 @@ class LarkOfficialClient {
|
|
|
231
177
|
return { messageId: res.data.message_id };
|
|
232
178
|
}
|
|
233
179
|
|
|
180
|
+
// --- Upload ---
|
|
181
|
+
|
|
182
|
+
async uploadImage(imagePath, imageType = 'message') {
|
|
183
|
+
const fs = require('fs');
|
|
184
|
+
const res = await this._safeSDKCall(
|
|
185
|
+
() => this.client.im.image.create({
|
|
186
|
+
data: { image_type: imageType, image: fs.createReadStream(imagePath) },
|
|
187
|
+
}),
|
|
188
|
+
'uploadImage'
|
|
189
|
+
);
|
|
190
|
+
return { imageKey: res.data.image_key };
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
async uploadFile(filePath, fileType = 'stream', fileName) {
|
|
194
|
+
const fs = require('fs');
|
|
195
|
+
const path = require('path');
|
|
196
|
+
if (!fileName) fileName = path.basename(filePath);
|
|
197
|
+
const res = await this._safeSDKCall(
|
|
198
|
+
() => this.client.im.file.create({
|
|
199
|
+
data: {
|
|
200
|
+
file_type: fileType,
|
|
201
|
+
file_name: fileName,
|
|
202
|
+
file: fs.createReadStream(filePath),
|
|
203
|
+
},
|
|
204
|
+
}),
|
|
205
|
+
'uploadFile'
|
|
206
|
+
);
|
|
207
|
+
return { fileKey: res.data.file_key };
|
|
208
|
+
}
|
|
209
|
+
|
|
234
210
|
// --- Docs ---
|
|
235
211
|
|
|
236
212
|
async searchDocs(query, { pageSize = 10, pageToken } = {}) {
|
|
@@ -442,9 +418,9 @@ class LarkOfficialClient {
|
|
|
442
418
|
unknownIds.add(item.senderId);
|
|
443
419
|
}
|
|
444
420
|
}
|
|
445
|
-
//
|
|
446
|
-
|
|
447
|
-
await this.getUserById(id);
|
|
421
|
+
// Parallel resolve via official contact API (instead of sequential N calls)
|
|
422
|
+
if (unknownIds.size > 0) {
|
|
423
|
+
await Promise.allSettled([...unknownIds].map(id => this.getUserById(id)));
|
|
448
424
|
}
|
|
449
425
|
// Fallback: resolve remaining unknowns via cookie-based user identity client
|
|
450
426
|
if (userClient) {
|
package/src/setup.js
CHANGED
|
@@ -1,67 +1,92 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
/**
|
|
3
|
-
*
|
|
3
|
+
* Setup wizard for feishu-user-plugin
|
|
4
4
|
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
5
|
+
* Two modes:
|
|
6
|
+
* Interactive: npx feishu-user-plugin setup
|
|
7
|
+
* Non-interactive: npx feishu-user-plugin setup --app-id xxx --app-secret yyy
|
|
8
|
+
*
|
|
9
|
+
* Writes MCP config to ~/.claude.json top-level mcpServers (global).
|
|
7
10
|
*/
|
|
8
11
|
|
|
9
|
-
const fs = require('fs');
|
|
10
|
-
const path = require('path');
|
|
11
12
|
const readline = require('readline');
|
|
13
|
+
const { findMcpConfig, writeNewConfig } = require('./config');
|
|
14
|
+
|
|
15
|
+
// Parse CLI args: --app-id, --app-secret, --cookie
|
|
16
|
+
function parseArgs() {
|
|
17
|
+
const args = {};
|
|
18
|
+
const argv = process.argv.slice(2);
|
|
19
|
+
for (let i = 0; i < argv.length; i++) {
|
|
20
|
+
if (argv[i] === '--app-id' && argv[i + 1]) args.appId = argv[++i];
|
|
21
|
+
else if (argv[i] === '--app-secret' && argv[i + 1]) args.appSecret = argv[++i];
|
|
22
|
+
else if (argv[i] === '--cookie' && argv[i + 1]) args.cookie = argv[++i];
|
|
23
|
+
}
|
|
24
|
+
return args;
|
|
25
|
+
}
|
|
12
26
|
|
|
13
|
-
|
|
14
|
-
const
|
|
27
|
+
async function main() {
|
|
28
|
+
const cliArgs = parseArgs();
|
|
29
|
+
const nonInteractive = !!(cliArgs.appId && cliArgs.appSecret);
|
|
15
30
|
|
|
16
|
-
const
|
|
17
|
-
const
|
|
31
|
+
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
|
32
|
+
const ask = (q) => new Promise(resolve => rl.question(q, resolve));
|
|
18
33
|
|
|
19
|
-
async function main() {
|
|
20
34
|
console.log('='.repeat(60));
|
|
21
|
-
console.log(' feishu-user-plugin Setup
|
|
35
|
+
console.log(' feishu-user-plugin Setup');
|
|
22
36
|
console.log('='.repeat(60));
|
|
23
|
-
console.log('');
|
|
24
37
|
|
|
25
38
|
// Check existing config
|
|
26
|
-
let config = {};
|
|
27
39
|
let existingEnv = {};
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
if (
|
|
32
|
-
|
|
33
|
-
console.log('
|
|
34
|
-
|
|
40
|
+
const found = findMcpConfig();
|
|
41
|
+
if (found) {
|
|
42
|
+
existingEnv = found.serverEnv;
|
|
43
|
+
if (found.projectPath) {
|
|
44
|
+
console.log(`\nFound project-level config in ${found.configPath} (project: ${found.projectPath})`);
|
|
45
|
+
console.log('This setup will write to global config instead (recommended).');
|
|
46
|
+
console.log('You can remove the project-level entry later to avoid conflicts.');
|
|
47
|
+
} else {
|
|
48
|
+
console.log(`\nFound existing config in ${found.configPath}`);
|
|
49
|
+
}
|
|
50
|
+
if (!nonInteractive) {
|
|
51
|
+
const update = await ask('Update config? (Y/n): ');
|
|
35
52
|
if (update.toLowerCase() === 'n') {
|
|
36
53
|
console.log('Cancelled.');
|
|
37
54
|
rl.close();
|
|
38
55
|
return;
|
|
39
56
|
}
|
|
40
57
|
}
|
|
41
|
-
} catch {}
|
|
42
|
-
|
|
43
|
-
// Collect credentials
|
|
44
|
-
console.log('\n--- App Credentials ---');
|
|
45
|
-
console.log('Team members: press Enter to use the shared defaults.');
|
|
46
|
-
console.log('External users: get these from https://open.feishu.cn/app\n');
|
|
47
|
-
|
|
48
|
-
const defaultAppId = existingEnv.LARK_APP_ID || '';
|
|
49
|
-
const defaultAppSecret = existingEnv.LARK_APP_SECRET || '';
|
|
50
|
-
|
|
51
|
-
let appId = await ask(`LARK_APP_ID [${defaultAppId || 'required'}]: `);
|
|
52
|
-
appId = appId.trim() || defaultAppId;
|
|
53
|
-
if (!appId) {
|
|
54
|
-
console.error('Error: LARK_APP_ID is required.');
|
|
55
|
-
rl.close();
|
|
56
|
-
process.exit(1);
|
|
57
58
|
}
|
|
58
59
|
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
60
|
+
// Resolve App credentials
|
|
61
|
+
let appId, appSecret;
|
|
62
|
+
|
|
63
|
+
if (nonInteractive) {
|
|
64
|
+
// CLI args provided — no prompting
|
|
65
|
+
appId = cliArgs.appId;
|
|
66
|
+
appSecret = cliArgs.appSecret;
|
|
67
|
+
console.log(`\nApp ID: ${appId}`);
|
|
68
|
+
console.log('App Secret: ***');
|
|
69
|
+
} else {
|
|
70
|
+
// Interactive mode
|
|
71
|
+
console.log('\n--- App Credentials ---');
|
|
72
|
+
console.log('Get these from https://open.feishu.cn/app\n');
|
|
73
|
+
|
|
74
|
+
const defaultAppId = existingEnv.LARK_APP_ID || '';
|
|
75
|
+
const defaultAppSecret = existingEnv.LARK_APP_SECRET || '';
|
|
76
|
+
|
|
77
|
+
appId = (await ask(`LARK_APP_ID [${defaultAppId || 'required'}]: `)).trim() || defaultAppId;
|
|
78
|
+
if (!appId) {
|
|
79
|
+
console.error('Error: LARK_APP_ID is required.');
|
|
80
|
+
rl.close();
|
|
81
|
+
process.exit(1);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
appSecret = (await ask(`LARK_APP_SECRET [${defaultAppSecret ? '***' : 'required'}]: `)).trim() || defaultAppSecret;
|
|
85
|
+
if (!appSecret) {
|
|
86
|
+
console.error('Error: LARK_APP_SECRET is required.');
|
|
87
|
+
rl.close();
|
|
88
|
+
process.exit(1);
|
|
89
|
+
}
|
|
65
90
|
}
|
|
66
91
|
|
|
67
92
|
// Validate app credentials
|
|
@@ -77,7 +102,6 @@ async function main() {
|
|
|
77
102
|
console.log('App credentials: VALID');
|
|
78
103
|
} else {
|
|
79
104
|
console.error(`App credentials: INVALID — ${data.msg || JSON.stringify(data)}`);
|
|
80
|
-
console.error('Please check your LARK_APP_ID and LARK_APP_SECRET.');
|
|
81
105
|
rl.close();
|
|
82
106
|
process.exit(1);
|
|
83
107
|
}
|
|
@@ -85,86 +109,57 @@ async function main() {
|
|
|
85
109
|
console.warn(`Could not validate: ${e.message}. Continuing anyway.`);
|
|
86
110
|
}
|
|
87
111
|
|
|
88
|
-
// Cookie
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
const existingCookie = existingEnv.LARK_COOKIE;
|
|
94
|
-
const hasCookie = existingCookie && existingCookie !== 'PLACEHOLDER' && existingCookie.includes('session=');
|
|
95
|
-
if (hasCookie) {
|
|
96
|
-
console.log('Existing cookie found (has session token).');
|
|
97
|
-
const keepCookie = await ask('Keep existing cookie? (Y/n): ');
|
|
98
|
-
if (keepCookie.toLowerCase() === 'n') {
|
|
99
|
-
console.log('You can update it later or use Playwright extraction.');
|
|
100
|
-
}
|
|
112
|
+
// Resolve Cookie
|
|
113
|
+
let cookie;
|
|
114
|
+
if (cliArgs.cookie) {
|
|
115
|
+
cookie = cliArgs.cookie;
|
|
101
116
|
} else {
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
117
|
+
const existingCookie = existingEnv.LARK_COOKIE;
|
|
118
|
+
const hasCookie = existingCookie && existingCookie !== 'SETUP_NEEDED' && existingCookie.includes('session=');
|
|
119
|
+
if (hasCookie) {
|
|
120
|
+
cookie = existingCookie;
|
|
121
|
+
console.log('\nKeeping existing cookie (has session token).');
|
|
122
|
+
} else {
|
|
123
|
+
cookie = 'SETUP_NEEDED';
|
|
124
|
+
if (!nonInteractive) {
|
|
125
|
+
console.log('\n--- Cookie ---');
|
|
126
|
+
console.log('No valid cookie found. After setup:');
|
|
127
|
+
console.log(' Tell Claude Code: "帮我设置飞书 Cookie" (with Playwright MCP)');
|
|
128
|
+
console.log(' Or manually copy from DevTools → Network → Cookie header');
|
|
129
|
+
}
|
|
130
|
+
}
|
|
105
131
|
}
|
|
106
132
|
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
// UAT
|
|
133
|
+
// Resolve UAT
|
|
110
134
|
const existingUAT = existingEnv.LARK_USER_ACCESS_TOKEN;
|
|
111
135
|
const existingRT = existingEnv.LARK_USER_REFRESH_TOKEN;
|
|
112
|
-
const hasUAT = existingUAT && existingUAT !== '
|
|
113
|
-
|
|
114
|
-
if (!hasUAT) {
|
|
115
|
-
console.log('\n--- OAuth UAT ---');
|
|
116
|
-
console.log('UAT not configured. After setup, run:');
|
|
117
|
-
console.log(' npx feishu-user-plugin oauth');
|
|
118
|
-
console.log('This will open a browser for OAuth consent.');
|
|
119
|
-
}
|
|
136
|
+
const hasUAT = existingUAT && existingUAT !== 'SETUP_NEEDED' && existingUAT.length > 20;
|
|
120
137
|
|
|
121
138
|
// Write config
|
|
122
139
|
console.log('\n--- Writing Config ---');
|
|
123
140
|
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
LARK_APP_ID: appId,
|
|
131
|
-
LARK_APP_SECRET: appSecret,
|
|
132
|
-
LARK_USER_ACCESS_TOKEN: hasUAT ? existingUAT : 'SETUP_NEEDED',
|
|
133
|
-
LARK_USER_REFRESH_TOKEN: hasUAT ? (existingRT || '') : '',
|
|
134
|
-
},
|
|
141
|
+
const env = {
|
|
142
|
+
LARK_COOKIE: cookie,
|
|
143
|
+
LARK_APP_ID: appId,
|
|
144
|
+
LARK_APP_SECRET: appSecret,
|
|
145
|
+
LARK_USER_ACCESS_TOKEN: hasUAT ? existingUAT : 'SETUP_NEEDED',
|
|
146
|
+
LARK_USER_REFRESH_TOKEN: hasUAT ? (existingRT || '') : '',
|
|
135
147
|
};
|
|
136
148
|
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
delete config.mcpServers.feishu;
|
|
140
|
-
}
|
|
141
|
-
|
|
142
|
-
fs.writeFileSync(CLAUDE_JSON_PATH, JSON.stringify(config, null, 2) + '\n');
|
|
143
|
-
console.log(`Written to ${CLAUDE_JSON_PATH}`);
|
|
144
|
-
|
|
145
|
-
// Also write .env for oauth.js to use
|
|
146
|
-
const envPath = path.join(__dirname, '..', '.env');
|
|
147
|
-
const envContent = [
|
|
148
|
-
`LARK_APP_ID=${appId}`,
|
|
149
|
-
`LARK_APP_SECRET=${appSecret}`,
|
|
150
|
-
cookie !== 'SETUP_NEEDED' ? `LARK_COOKIE=${cookie}` : '',
|
|
151
|
-
hasUAT ? `LARK_USER_ACCESS_TOKEN=${existingUAT}` : '',
|
|
152
|
-
hasUAT && existingRT ? `LARK_USER_REFRESH_TOKEN=${existingRT}` : '',
|
|
153
|
-
].filter(Boolean).join('\n') + '\n';
|
|
154
|
-
fs.writeFileSync(envPath, envContent);
|
|
149
|
+
const result = writeNewConfig(env);
|
|
150
|
+
console.log(`Written to ${result.configPath} (global)`);
|
|
155
151
|
|
|
156
152
|
// Summary
|
|
157
153
|
console.log('\n' + '='.repeat(60));
|
|
158
154
|
console.log(' Setup Complete!');
|
|
159
155
|
console.log('='.repeat(60));
|
|
160
|
-
console.log('');
|
|
161
156
|
|
|
162
157
|
const todo = [];
|
|
163
158
|
if (cookie === 'SETUP_NEEDED') todo.push('Get Cookie: tell Claude Code "帮我设置飞书 Cookie"');
|
|
164
159
|
if (!hasUAT) todo.push('Get UAT: run "npx feishu-user-plugin oauth"');
|
|
165
160
|
todo.push('Restart Claude Code');
|
|
166
161
|
|
|
167
|
-
console.log('
|
|
162
|
+
console.log('\nNext steps:');
|
|
168
163
|
todo.forEach((t, i) => console.log(` ${i + 1}. ${t}`));
|
|
169
164
|
console.log('');
|
|
170
165
|
|
|
@@ -173,6 +168,5 @@ async function main() {
|
|
|
173
168
|
|
|
174
169
|
main().catch(e => {
|
|
175
170
|
console.error('Setup failed:', e.message);
|
|
176
|
-
rl.close();
|
|
177
171
|
process.exit(1);
|
|
178
172
|
});
|