feishu-user-plugin 1.3.6 → 1.3.8
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 +71 -0
- package/README.md +72 -41
- package/package.json +10 -3
- package/scripts/capture-feishu-protobuf.js +86 -0
- package/scripts/check-changelog.js +31 -0
- package/scripts/check-docs-sync.js +41 -0
- package/scripts/check-tool-count.js +40 -0
- package/scripts/check-version.js +40 -0
- package/scripts/decode-feishu-protobuf.js +115 -0
- package/scripts/smoke.js +224 -0
- package/scripts/sync-claude-md.sh +12 -0
- package/scripts/sync-server-json.js +71 -0
- package/scripts/sync-team-skills.sh +22 -0
- package/scripts/test-all-tools.js +158 -0
- package/scripts/test-wiki-attach-fallback.js +71 -0
- package/scripts/test-ws-events.js +84 -0
- package/skills/feishu-user-plugin/SKILL.md +5 -5
- package/skills/feishu-user-plugin/references/CLAUDE.md +248 -318
- package/skills/feishu-user-plugin/references/table.md +18 -9
- package/src/auth/cookie.js +30 -0
- package/src/auth/credentials.js +399 -0
- package/src/auth/profile-router.js +248 -0
- package/src/auth/uat.js +231 -0
- package/src/cli.js +45 -13
- package/src/clients/official/base.js +188 -0
- package/src/clients/official/bitable.js +269 -0
- package/src/clients/official/calendar.js +176 -0
- package/src/clients/official/contacts.js +54 -0
- package/src/clients/official/docs.js +301 -0
- package/src/clients/official/drive.js +77 -0
- package/src/clients/official/groups.js +68 -0
- package/src/clients/official/im.js +414 -0
- package/src/clients/official/index.js +30 -0
- package/src/clients/official/okr.js +127 -0
- package/src/clients/official/tasks.js +142 -0
- package/src/clients/official/uploads.js +260 -0
- package/src/clients/official/wiki.js +207 -0
- package/src/{client.js → clients/user.js} +25 -33
- package/src/config.js +13 -8
- package/src/events/event-buffer.js +100 -0
- package/src/events/index.js +5 -0
- package/src/events/ws-server.js +86 -0
- package/src/index.js +4 -1977
- package/src/logger.js +20 -0
- package/src/oauth.js +5 -1
- package/src/official.js +5 -1944
- package/src/prompts/_registry.js +69 -0
- package/src/prompts/index.js +54 -0
- package/src/server.js +305 -0
- package/src/setup.js +16 -1
- package/src/test-all.js +2 -2
- package/src/test-comprehensive.js +3 -3
- package/src/test-send.js +1 -1
- package/src/tools/_registry.js +31 -0
- package/src/tools/bitable.js +246 -0
- package/src/tools/calendar.js +207 -0
- package/src/tools/contacts.js +66 -0
- package/src/tools/diagnostics.js +172 -0
- package/src/tools/docs.js +158 -0
- package/src/tools/drive.js +111 -0
- package/src/tools/events.js +64 -0
- package/src/tools/groups.js +81 -0
- package/src/tools/im-read.js +259 -0
- package/src/tools/messaging-bot.js +151 -0
- package/src/tools/messaging-user.js +292 -0
- package/src/tools/okr.js +159 -0
- package/src/tools/profile.js +74 -0
- package/src/tools/tasks.js +168 -0
- package/src/tools/uploads.js +63 -0
- package/src/tools/wiki.js +191 -0
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
// src/prompts/_registry.js — Load the 9 sub-skill markdown files as MCP prompt definitions.
|
|
2
|
+
//
|
|
3
|
+
// Reads from skills/feishu-user-plugin/references/ (whitelisted set).
|
|
4
|
+
// For each file:
|
|
5
|
+
// - name: filename without .md
|
|
6
|
+
// - description: first non-empty line
|
|
7
|
+
// - arguments: [{name:'arguments', description, required:false}] if body contains $ARGUMENTS
|
|
8
|
+
// - body: full file content (used for substitution in getPrompt)
|
|
9
|
+
|
|
10
|
+
'use strict';
|
|
11
|
+
|
|
12
|
+
const fs = require('fs');
|
|
13
|
+
const path = require('path');
|
|
14
|
+
|
|
15
|
+
const WHITELIST = new Set(['send', 'reply', 'digest', 'search', 'doc', 'table', 'wiki', 'drive', 'status']);
|
|
16
|
+
const REFS_DIR = path.join(__dirname, '..', '..', 'skills', 'feishu-user-plugin', 'references');
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Parse $ARGUMENTS description from the ## 参数 section.
|
|
20
|
+
* Looks for a line matching "- $ARGUMENTS:<desc>" or "- $ARGUMENTS: <desc>".
|
|
21
|
+
* Returns the description text, or a generic fallback.
|
|
22
|
+
*/
|
|
23
|
+
function parseArgumentsDescription(body) {
|
|
24
|
+
const match = body.match(/\$ARGUMENTS[::]\s*(.+)/);
|
|
25
|
+
if (match) {
|
|
26
|
+
return match[1].trim();
|
|
27
|
+
}
|
|
28
|
+
return 'Skill arguments';
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Load all whitelisted sub-skill files and return prompt definitions.
|
|
33
|
+
* @returns {Array<{name: string, description: string, arguments?: Array, body: string}>}
|
|
34
|
+
*/
|
|
35
|
+
function loadAllSkills() {
|
|
36
|
+
const prompts = [];
|
|
37
|
+
for (const name of WHITELIST) {
|
|
38
|
+
const filePath = path.join(REFS_DIR, `${name}.md`);
|
|
39
|
+
let content;
|
|
40
|
+
try {
|
|
41
|
+
content = fs.readFileSync(filePath, 'utf8');
|
|
42
|
+
} catch (e) {
|
|
43
|
+
console.error(`[feishu-user-plugin] prompts: could not read ${filePath}: ${e.message}`);
|
|
44
|
+
continue;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// description = first non-empty line
|
|
48
|
+
const lines = content.split('\n');
|
|
49
|
+
const firstNonEmpty = lines.find((l) => l.trim() !== '');
|
|
50
|
+
const description = firstNonEmpty ? firstNonEmpty.trim() : name;
|
|
51
|
+
|
|
52
|
+
const hasArguments = content.includes('$ARGUMENTS');
|
|
53
|
+
const prompt = {
|
|
54
|
+
name,
|
|
55
|
+
description,
|
|
56
|
+
body: content,
|
|
57
|
+
};
|
|
58
|
+
if (hasArguments) {
|
|
59
|
+
const argDesc = parseArgumentsDescription(content);
|
|
60
|
+
prompt.arguments = [{ name: 'arguments', description: argDesc, required: false }];
|
|
61
|
+
}
|
|
62
|
+
prompts.push(prompt);
|
|
63
|
+
}
|
|
64
|
+
// Sort deterministically by name
|
|
65
|
+
prompts.sort((a, b) => a.name.localeCompare(b.name));
|
|
66
|
+
return prompts;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
module.exports = { loadAllSkills };
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
// src/prompts/index.js — MCP prompt registry: listPrompts + getPrompt.
|
|
2
|
+
//
|
|
3
|
+
// Loaded once at module load; cached for the lifetime of the server process.
|
|
4
|
+
// listPrompts() → MCP prompts/list shaped array (no body exposed in listing).
|
|
5
|
+
// getPrompt(name, args) → MCP prompts/get shaped result with $ARGUMENTS substituted.
|
|
6
|
+
|
|
7
|
+
'use strict';
|
|
8
|
+
|
|
9
|
+
const { loadAllSkills } = require('./_registry');
|
|
10
|
+
|
|
11
|
+
// Cache all skills once at module load.
|
|
12
|
+
const _skills = loadAllSkills();
|
|
13
|
+
const _byName = new Map(_skills.map((s) => [s.name, s]));
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Returns the spec-shaped prompts array for prompts/list.
|
|
17
|
+
* Omits the body field; omits arguments if empty.
|
|
18
|
+
*/
|
|
19
|
+
function listPrompts() {
|
|
20
|
+
return _skills.map((s) => {
|
|
21
|
+
const p = { name: s.name, description: s.description };
|
|
22
|
+
if (s.arguments && s.arguments.length > 0) {
|
|
23
|
+
p.arguments = s.arguments;
|
|
24
|
+
}
|
|
25
|
+
return p;
|
|
26
|
+
});
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Returns the prompts/get result for a given prompt name.
|
|
31
|
+
* Substitutes $ARGUMENTS in the body with args.arguments (or empty string).
|
|
32
|
+
* @param {string} name
|
|
33
|
+
* @param {object} args - e.g. { arguments: "Alice: hi" }
|
|
34
|
+
* @returns {{ description: string, messages: [{role: 'user', content: {type: 'text', text: string}}] }}
|
|
35
|
+
*/
|
|
36
|
+
function getPrompt(name, args = {}) {
|
|
37
|
+
const skill = _byName.get(name);
|
|
38
|
+
if (!skill) {
|
|
39
|
+
throw new Error(`Unknown prompt: ${name}`);
|
|
40
|
+
}
|
|
41
|
+
const argValue = args.arguments != null ? String(args.arguments) : '';
|
|
42
|
+
const text = skill.body.replace(/\$ARGUMENTS/g, argValue);
|
|
43
|
+
return {
|
|
44
|
+
description: skill.description,
|
|
45
|
+
messages: [
|
|
46
|
+
{
|
|
47
|
+
role: 'user',
|
|
48
|
+
content: { type: 'text', text },
|
|
49
|
+
},
|
|
50
|
+
],
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
module.exports = { listPrompts, getPrompt };
|
package/src/server.js
ADDED
|
@@ -0,0 +1,305 @@
|
|
|
1
|
+
// src/server.js — MCP bootstrap, tool registration, request dispatch.
|
|
2
|
+
//
|
|
3
|
+
// What this owns:
|
|
4
|
+
// - Loading every src/tools/<domain>.js module and flattening its schemas.
|
|
5
|
+
// - Building the ctx object that handlers receive (factory closures + profile state).
|
|
6
|
+
// - The MCP Server instance and its ListTools / CallTool request handlers.
|
|
7
|
+
// - Startup diagnostics (auth status, APP_ID validation).
|
|
8
|
+
//
|
|
9
|
+
// What it does NOT own:
|
|
10
|
+
// - Tool definitions: those live in src/tools/<domain>.js.
|
|
11
|
+
// - Feishu API calls: those live in src/clients/{user,official}.
|
|
12
|
+
// - Auth lifecycle (cookie heartbeat, UAT refresh, file lock): src/auth/*.
|
|
13
|
+
// - Config discovery / persistence: src/config/*.
|
|
14
|
+
|
|
15
|
+
const path = require('path');
|
|
16
|
+
const { Server } = require('@modelcontextprotocol/sdk/server/index.js');
|
|
17
|
+
const { StdioServerTransport } = require('@modelcontextprotocol/sdk/server/stdio.js');
|
|
18
|
+
const { CallToolRequestSchema, ListToolsRequestSchema, ListPromptsRequestSchema, GetPromptRequestSchema } = require('@modelcontextprotocol/sdk/types.js');
|
|
19
|
+
|
|
20
|
+
// Local dev fallback: MCP clients inject env vars from config's env block at
|
|
21
|
+
// spawn time. This dotenv line only matters when running locally with a .env.
|
|
22
|
+
require('dotenv').config({ path: path.join(__dirname, '..', '.env') });
|
|
23
|
+
|
|
24
|
+
const { LarkUserClient } = require('./clients/user');
|
|
25
|
+
const { LarkOfficialClient } = require('./clients/official');
|
|
26
|
+
const { resolveToken } = require('./resolver');
|
|
27
|
+
const { listPrompts, getPrompt } = require('./prompts');
|
|
28
|
+
const credentials = require('./auth/credentials');
|
|
29
|
+
const profileRouter = require('./auth/profile-router');
|
|
30
|
+
|
|
31
|
+
// --- Tool modules ---
|
|
32
|
+
// Adding a new domain: create src/tools/<x>.js exporting { schemas, handlers }
|
|
33
|
+
// and append it here. The schemas are concatenated into the MCP tools/list
|
|
34
|
+
// response; the handlers are looked up by name when tools/call comes in.
|
|
35
|
+
const TOOL_MODULES = [
|
|
36
|
+
require('./tools/bitable'),
|
|
37
|
+
require('./tools/calendar'),
|
|
38
|
+
require('./tools/contacts'),
|
|
39
|
+
require('./tools/diagnostics'),
|
|
40
|
+
require('./tools/docs'),
|
|
41
|
+
require('./tools/drive'),
|
|
42
|
+
require('./tools/events'),
|
|
43
|
+
require('./tools/groups'),
|
|
44
|
+
require('./tools/im-read'),
|
|
45
|
+
require('./tools/messaging-bot'),
|
|
46
|
+
require('./tools/messaging-user'),
|
|
47
|
+
require('./tools/okr'),
|
|
48
|
+
require('./tools/profile'),
|
|
49
|
+
require('./tools/tasks'),
|
|
50
|
+
require('./tools/uploads'),
|
|
51
|
+
require('./tools/wiki'),
|
|
52
|
+
];
|
|
53
|
+
|
|
54
|
+
const TOOLS = TOOL_MODULES.flatMap((m) => m.schemas);
|
|
55
|
+
const HANDLERS = Object.fromEntries(TOOL_MODULES.flatMap((m) => Object.entries(m.handlers)));
|
|
56
|
+
|
|
57
|
+
// --- Profile system + client singletons ---
|
|
58
|
+
// Profile resolution order (see src/auth/credentials.js):
|
|
59
|
+
// 1. ~/.feishu-user-plugin/credentials.json — single source of truth (v1.3.7+)
|
|
60
|
+
// 2. process.env.LARK_* — legacy default profile (v1.3.6 behaviour)
|
|
61
|
+
// 3. process.env.LARK_PROFILES_JSON — legacy named profiles
|
|
62
|
+
//
|
|
63
|
+
// switch_profile (handler in tools/profile.js) calls ctx.setActiveProfile(n)
|
|
64
|
+
// which resets the cached client singletons; the next tool call rebuilds them.
|
|
65
|
+
// When credentials.json exists, switching also persists the active field so
|
|
66
|
+
// cross-process MCP servers see the same active profile after restart.
|
|
67
|
+
|
|
68
|
+
let userClient = null;
|
|
69
|
+
let officialClient = null;
|
|
70
|
+
let wsServer = null;
|
|
71
|
+
function getEventBuffer() {
|
|
72
|
+
return wsServer ? wsServer.buffer : null;
|
|
73
|
+
}
|
|
74
|
+
// The "current" profile this in-memory MCP server is pinned to. Initialised
|
|
75
|
+
// from the persisted active profile (credentials.json) at boot, but in-process
|
|
76
|
+
// switches may diverge from the persisted active until the next server restart.
|
|
77
|
+
//
|
|
78
|
+
// Profile selection precedence (v1.3.8 E.1):
|
|
79
|
+
// 1. process.env.FEISHU_PLUGIN_PROFILE — harness pointer
|
|
80
|
+
// 2. credentials.json::active — single-file persisted active
|
|
81
|
+
// 3. 'default' — legacy zero-config path
|
|
82
|
+
let currentProfile = process.env.FEISHU_PLUGIN_PROFILE
|
|
83
|
+
|| credentials.getActiveProfileName();
|
|
84
|
+
|
|
85
|
+
function profileEnv(name) {
|
|
86
|
+
return credentials.getActiveProfileEnv(name);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
async function getUserClient() {
|
|
90
|
+
if (userClient) return userClient;
|
|
91
|
+
const env = profileEnv(currentProfile);
|
|
92
|
+
const cookie = env.LARK_COOKIE;
|
|
93
|
+
if (!cookie) throw new Error(
|
|
94
|
+
`LARK_COOKIE not set for profile "${currentProfile}". To fix:\n` +
|
|
95
|
+
'1. Open https://www.feishu.cn/messenger/ and log in\n' +
|
|
96
|
+
'2. DevTools → Network tab → Disable cache → Reload → Click first request → Request Headers → Cookie → Copy value\n' +
|
|
97
|
+
' (Do NOT use document.cookie or Application→Cookies — they miss HttpOnly cookies like session/sl_session)\n' +
|
|
98
|
+
'3. Paste the cookie string into your .mcp.json env LARK_COOKIE field, then restart Claude Code\n' +
|
|
99
|
+
'If Playwright MCP is available: navigate to feishu.cn/messenger/, let user log in, then use context.cookies() to get the full cookie string including HttpOnly cookies.'
|
|
100
|
+
);
|
|
101
|
+
userClient = new LarkUserClient(cookie);
|
|
102
|
+
await userClient.init();
|
|
103
|
+
return userClient;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function getOfficialClient() {
|
|
107
|
+
if (officialClient) return officialClient;
|
|
108
|
+
const env = profileEnv(currentProfile);
|
|
109
|
+
const appId = env.LARK_APP_ID;
|
|
110
|
+
const appSecret = env.LARK_APP_SECRET;
|
|
111
|
+
if (!appId || !appSecret) throw new Error(
|
|
112
|
+
`LARK_APP_ID and LARK_APP_SECRET not set for profile "${currentProfile}".\n` +
|
|
113
|
+
'For team members: these should be pre-filled in your .mcp.json. Check that the config was copied correctly from the team-skills README.\n' +
|
|
114
|
+
'For external users: create a Custom App at https://open.feishu.cn/app, get the App ID and App Secret, add them to your .mcp.json env.'
|
|
115
|
+
);
|
|
116
|
+
officialClient = new LarkOfficialClient(appId, appSecret);
|
|
117
|
+
// Load UAT directly from the active profile env. With credentials.json the
|
|
118
|
+
// env may differ from process.env (whose LARK_USER_* may be missing if the
|
|
119
|
+
// user moved creds out of harness configs); using profileEnv() here keeps
|
|
120
|
+
// the source of truth consistent with what get*Client() reads above.
|
|
121
|
+
loadUATFromEnv(officialClient, env);
|
|
122
|
+
return officialClient;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// Mirror of LarkOfficialClient.loadUAT() but sourced from a specific env block
|
|
126
|
+
// instead of process.env, so credentials.json profiles work uniformly.
|
|
127
|
+
function loadUATFromEnv(client, env) {
|
|
128
|
+
const token = env.LARK_USER_ACCESS_TOKEN;
|
|
129
|
+
const refresh = env.LARK_USER_REFRESH_TOKEN;
|
|
130
|
+
const expires = parseInt(env.LARK_UAT_EXPIRES || '0');
|
|
131
|
+
if (!token) return;
|
|
132
|
+
client._uat = token;
|
|
133
|
+
client._uatRefresh = refresh || null;
|
|
134
|
+
client._uatExpires = expires || client._decodeTokenExpiry(token);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// Resolver helper: turn document_id / app_token / wiki node / Feishu URL into
|
|
138
|
+
// a native token. No-op for already-native inputs. See src/resolver.js.
|
|
139
|
+
async function resolveDocId(input) {
|
|
140
|
+
if (!input) return input;
|
|
141
|
+
return resolveToken(input, getOfficialClient());
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// --- ctx ---
|
|
145
|
+
// What handlers receive in their second argument. Kept stable so tools/* don't
|
|
146
|
+
// reach back into server.js for state. Adding a new ctx field: also document
|
|
147
|
+
// it in src/tools/_registry.js docstring.
|
|
148
|
+
function buildCtx() {
|
|
149
|
+
return {
|
|
150
|
+
getUserClient,
|
|
151
|
+
getOfficialClient,
|
|
152
|
+
getEventBuffer,
|
|
153
|
+
listProfiles: () => credentials.listProfileNames(),
|
|
154
|
+
getActiveProfile: () => currentProfile,
|
|
155
|
+
setActiveProfile: (n) => {
|
|
156
|
+
// Validate the profile exists (throws if unknown) before nuking client cache.
|
|
157
|
+
credentials.getActiveProfileEnv(n);
|
|
158
|
+
currentProfile = n;
|
|
159
|
+
userClient = null;
|
|
160
|
+
officialClient = null;
|
|
161
|
+
// Persist the active-field flip when credentials.json exists so peer MCP
|
|
162
|
+
// servers see the new active profile on next read. Tolerated when no file.
|
|
163
|
+
try { credentials.setActiveProfile(n); } catch (_) {}
|
|
164
|
+
},
|
|
165
|
+
resolveDocId,
|
|
166
|
+
};
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// --- MCP server ---
|
|
170
|
+
|
|
171
|
+
const server = new Server(
|
|
172
|
+
{ name: 'feishu-user-plugin', version: require('../package.json').version },
|
|
173
|
+
{ capabilities: { tools: {}, prompts: {} } }
|
|
174
|
+
);
|
|
175
|
+
|
|
176
|
+
server.setRequestHandler(ListToolsRequestSchema, async () => ({ tools: TOOLS }));
|
|
177
|
+
|
|
178
|
+
server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
179
|
+
const { name, arguments: args } = request.params;
|
|
180
|
+
const handler = HANDLERS[name];
|
|
181
|
+
if (!handler) {
|
|
182
|
+
return { content: [{ type: 'text', text: `Unknown tool: ${name}` }], isError: true };
|
|
183
|
+
}
|
|
184
|
+
// Strip via_profile from args before passing to the handler — it's a
|
|
185
|
+
// routing-layer concern, not a tool argument. Keep a copy for routing.
|
|
186
|
+
const cleanArgs = (args && typeof args === 'object') ? { ...args } : {};
|
|
187
|
+
delete cleanArgs.via_profile;
|
|
188
|
+
|
|
189
|
+
try {
|
|
190
|
+
return await profileRouter.withProfileRouting(buildCtx(), name, args || {}, async () => {
|
|
191
|
+
return handler(cleanArgs, buildCtx());
|
|
192
|
+
});
|
|
193
|
+
} catch (err) {
|
|
194
|
+
return { content: [{ type: 'text', text: `Error: ${err.message}` }], isError: true };
|
|
195
|
+
}
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
server.setRequestHandler(ListPromptsRequestSchema, async () => ({ prompts: listPrompts() }));
|
|
199
|
+
|
|
200
|
+
server.setRequestHandler(GetPromptRequestSchema, async (req) => {
|
|
201
|
+
const { name, arguments: args } = req.params;
|
|
202
|
+
return getPrompt(name, args || {});
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
// --- Process-level error handlers ---
|
|
206
|
+
// Prevent stray promise rejections or uncaught exceptions from killing the MCP server.
|
|
207
|
+
process.on('uncaughtException', (err) => {
|
|
208
|
+
console.error('[feishu-user-plugin] Uncaught exception:', err.message);
|
|
209
|
+
console.error(err.stack);
|
|
210
|
+
});
|
|
211
|
+
process.on('unhandledRejection', (reason) => {
|
|
212
|
+
console.error('[feishu-user-plugin] Unhandled rejection:', reason);
|
|
213
|
+
});
|
|
214
|
+
process.on('SIGTERM', () => { try { wsServer?.stop(); } catch {} process.exit(0); });
|
|
215
|
+
process.on('SIGINT', () => { try { wsServer?.stop(); } catch {} process.exit(0); });
|
|
216
|
+
|
|
217
|
+
// --- main ---
|
|
218
|
+
|
|
219
|
+
async function main() {
|
|
220
|
+
const transport = new StdioServerTransport();
|
|
221
|
+
await server.connect(transport);
|
|
222
|
+
|
|
223
|
+
// Startup diagnostics — use the resolved active-profile env so users on
|
|
224
|
+
// credentials.json (where process.env may not have LARK_*) get accurate flags.
|
|
225
|
+
// If FEISHU_PLUGIN_PROFILE was set, validate the name exists. If not, fail
|
|
226
|
+
// loud — silently falling back to "default" would mask a typo'd harness env.
|
|
227
|
+
if (process.env.FEISHU_PLUGIN_PROFILE) {
|
|
228
|
+
const known = credentials.listProfileNames();
|
|
229
|
+
if (!known.includes(currentProfile)) {
|
|
230
|
+
console.error(`[feishu-user-plugin] FATAL: FEISHU_PLUGIN_PROFILE="${currentProfile}" not found. Known: ${known.join(', ')}.`);
|
|
231
|
+
console.error('[feishu-user-plugin] Fix: edit harness env block, or add the profile to ~/.feishu-user-plugin/credentials.json.');
|
|
232
|
+
process.exit(2);
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
let activeEnv = {};
|
|
236
|
+
try { activeEnv = profileEnv(currentProfile); } catch (_) { /* unknown profile is reported below */ }
|
|
237
|
+
const hasCanonical = !!credentials.readCanonical();
|
|
238
|
+
const hasCookie = !!activeEnv.LARK_COOKIE;
|
|
239
|
+
const hasApp = !!(activeEnv.LARK_APP_ID && activeEnv.LARK_APP_SECRET);
|
|
240
|
+
const hasUAT = !!activeEnv.LARK_USER_ACCESS_TOKEN;
|
|
241
|
+
const source = hasCanonical ? `credentials.json profile=${currentProfile}` : 'env vars (legacy)';
|
|
242
|
+
console.error(`[feishu-user-plugin] MCP Server v${require('../package.json').version} — ${TOOLS.length} tools, ${listPrompts().length} prompts`);
|
|
243
|
+
console.error(`[feishu-user-plugin] Auth: Cookie=${hasCookie ? 'YES' : 'NO'} App=${hasApp ? 'YES' : 'NO'} UAT=${hasUAT ? 'YES' : 'NO'} (source: ${source})`);
|
|
244
|
+
if (!hasCookie) console.error('[feishu-user-plugin] WARNING: LARK_COOKIE not set — user identity tools (send_to_user, etc.) will fail');
|
|
245
|
+
if (!hasApp) console.error('[feishu-user-plugin] WARNING: LARK_APP_ID/SECRET not set — official API tools (read_messages, docs, etc.) will fail');
|
|
246
|
+
if (!hasUAT) console.error('[feishu-user-plugin] WARNING: LARK_USER_ACCESS_TOKEN not set — P2P chat reading (read_p2p_messages) will fail');
|
|
247
|
+
// Warn when both credentials.json AND legacy env vars exist — they may
|
|
248
|
+
// diverge silently after a UAT refresh (we always write credentials.json).
|
|
249
|
+
if (hasCanonical && (process.env.LARK_COOKIE || process.env.LARK_APP_ID || process.env.LARK_USER_ACCESS_TOKEN)) {
|
|
250
|
+
console.error('[feishu-user-plugin] NOTE: credentials.json AND legacy LARK_* env vars are both set. Plugin reads credentials.json; the env vars are ignored. To clean up: remove the LARK_* keys from your harness config, leaving FEISHU_PLUGIN_PROFILE only.');
|
|
251
|
+
}
|
|
252
|
+
// Nudge legacy env-only users to migrate.
|
|
253
|
+
if (!hasCanonical && (hasCookie || hasApp || hasUAT)) {
|
|
254
|
+
console.error('[feishu-user-plugin] TIP: run `npx feishu-user-plugin migrate --confirm` to consolidate credentials into ~/.feishu-user-plugin/credentials.json (single source of truth, removes UAT-refresh drift across harnesses).');
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
// Validate APP_ID/SECRET against Feishu before serving any tool calls.
|
|
258
|
+
// Catches the "Claude filled in a wrong/stale APP_ID during install" failure
|
|
259
|
+
// mode that otherwise surfaces as cryptic 401s on every Official API call
|
|
260
|
+
// (looks like "MCP 掉线" to the user). Non-blocking — we warn but still serve,
|
|
261
|
+
// because the user may only need user-identity (cookie) tools.
|
|
262
|
+
if (hasApp) {
|
|
263
|
+
try {
|
|
264
|
+
const probe = await getOfficialClient().verifyApp();
|
|
265
|
+
if (probe.valid) {
|
|
266
|
+
const nameBit = probe.appName ? ` "${probe.appName}"` : '';
|
|
267
|
+
console.error(`[feishu-user-plugin] App verified: ${probe.appId}${nameBit}`);
|
|
268
|
+
} else {
|
|
269
|
+
console.error(`[feishu-user-plugin] ERROR: LARK_APP_ID=${probe.appId} was REJECTED by Feishu (${probe.error}).`);
|
|
270
|
+
console.error('[feishu-user-plugin] → Every Official API tool call will fail. Likely wrong/stale APP_ID.');
|
|
271
|
+
console.error('[feishu-user-plugin] → Re-run the install prompt from team-skills/plugins/feishu-user-plugin/README.md to get the correct credentials.');
|
|
272
|
+
}
|
|
273
|
+
} catch (e) {
|
|
274
|
+
console.error(`[feishu-user-plugin] WARNING: Could not verify APP_ID (${e.message}); network issue or cold start. Proceeding anyway.`);
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
// --- Real-time events (v1.3.8 C.3) ---
|
|
279
|
+
// Boot WS only when APP_ID/SECRET are valid. WS uses app credentials
|
|
280
|
+
// (not UAT), so cookie-only setups don't get realtime.
|
|
281
|
+
if (hasApp) {
|
|
282
|
+
try {
|
|
283
|
+
const { createWSServer } = require('./events');
|
|
284
|
+
wsServer = createWSServer({
|
|
285
|
+
appId: activeEnv.LARK_APP_ID,
|
|
286
|
+
appSecret: activeEnv.LARK_APP_SECRET,
|
|
287
|
+
registrations: ['im.message.receive_v1'],
|
|
288
|
+
});
|
|
289
|
+
// Start asynchronously — don't block MCP serving on WS handshake.
|
|
290
|
+
wsServer.start().catch((e) => {
|
|
291
|
+
console.error(`[feishu-user-plugin] WS deferred start error: ${e.message}`);
|
|
292
|
+
});
|
|
293
|
+
} catch (e) {
|
|
294
|
+
console.error(`[feishu-user-plugin] WS init failed: ${e.message}. Continuing without realtime.`);
|
|
295
|
+
}
|
|
296
|
+
} else {
|
|
297
|
+
console.error('[feishu-user-plugin] WS not started — APP_ID/SECRET missing. Realtime events (get_new_events) will return empty.');
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
module.exports = { main, TOOLS, HANDLERS };
|
|
302
|
+
|
|
303
|
+
if (require.main === module) {
|
|
304
|
+
main().catch((err) => { console.error('Fatal:', err); process.exit(1); });
|
|
305
|
+
}
|
package/src/setup.js
CHANGED
|
@@ -21,6 +21,7 @@ function parseArgs() {
|
|
|
21
21
|
else if (argv[i] === '--app-secret' && argv[i + 1]) args.appSecret = argv[++i];
|
|
22
22
|
else if (argv[i] === '--cookie' && argv[i + 1]) args.cookie = argv[++i];
|
|
23
23
|
else if (argv[i] === '--client' && argv[i + 1]) args.client = argv[++i];
|
|
24
|
+
else if (argv[i] === '--pointer-only') args.pointerOnly = true;
|
|
24
25
|
}
|
|
25
26
|
return args;
|
|
26
27
|
}
|
|
@@ -150,6 +151,19 @@ async function main() {
|
|
|
150
151
|
}
|
|
151
152
|
if (!client) client = 'claude';
|
|
152
153
|
|
|
154
|
+
// If credentials.json exists, recommend pointer-only — the env block in
|
|
155
|
+
// harness configs becomes redundant (and divergent on UAT refresh).
|
|
156
|
+
const { readCanonical } = require('./auth/credentials');
|
|
157
|
+
const hasCanonical = !!readCanonical();
|
|
158
|
+
let pointerOnly = !!cliArgs.pointerOnly;
|
|
159
|
+
if (hasCanonical && !pointerOnly && !nonInteractive) {
|
|
160
|
+
console.log('\n--- Pointer-only mode ---');
|
|
161
|
+
console.log('Detected ~/.feishu-user-plugin/credentials.json. You can write only');
|
|
162
|
+
console.log('FEISHU_PLUGIN_PROFILE=default to the harness env (recommended for clean configs).');
|
|
163
|
+
const ans = (await ask('Use pointer-only mode? (y/N): ')).trim().toLowerCase();
|
|
164
|
+
pointerOnly = (ans === 'y' || ans === 'yes');
|
|
165
|
+
}
|
|
166
|
+
|
|
153
167
|
// Write config
|
|
154
168
|
console.log('\n--- Writing Config ---');
|
|
155
169
|
|
|
@@ -161,9 +175,10 @@ async function main() {
|
|
|
161
175
|
LARK_USER_REFRESH_TOKEN: hasUAT ? (existingRT || '') : '',
|
|
162
176
|
};
|
|
163
177
|
|
|
164
|
-
const result = writeNewConfig(env, undefined, undefined, client);
|
|
178
|
+
const result = writeNewConfig(env, undefined, undefined, client, { pointerOnly });
|
|
165
179
|
if (result.configPath) console.log(`Written to ${result.configPath} (Claude Code)`);
|
|
166
180
|
if (result.codexConfigPath) console.log(`Written to ${result.codexConfigPath} (Codex)`);
|
|
181
|
+
if (pointerOnly) console.log('Mode: pointer-only (env block contains only FEISHU_PLUGIN_PROFILE)');
|
|
167
182
|
|
|
168
183
|
// Summary
|
|
169
184
|
console.log('\n' + '='.repeat(60));
|
package/src/test-all.js
CHANGED
|
@@ -4,8 +4,8 @@
|
|
|
4
4
|
* Sends test messages to "飞书plugin测试群".
|
|
5
5
|
*/
|
|
6
6
|
require('dotenv').config({ path: require('path').join(__dirname, '..', '.env') });
|
|
7
|
-
const { LarkUserClient } = require('./
|
|
8
|
-
const { LarkOfficialClient } = require('./official');
|
|
7
|
+
const { LarkUserClient } = require('./clients/user');
|
|
8
|
+
const { LarkOfficialClient } = require('./clients/official');
|
|
9
9
|
|
|
10
10
|
const TEST_GROUP = '飞书plugin测试群';
|
|
11
11
|
const results = [];
|
|
@@ -6,8 +6,8 @@
|
|
|
6
6
|
const path = require('path');
|
|
7
7
|
require('dotenv').config({ path: path.join(__dirname, '..', '.env') });
|
|
8
8
|
|
|
9
|
-
const { LarkUserClient } = require('./
|
|
10
|
-
const { LarkOfficialClient } = require('./official');
|
|
9
|
+
const { LarkUserClient } = require('./clients/user');
|
|
10
|
+
const { LarkOfficialClient } = require('./clients/official');
|
|
11
11
|
|
|
12
12
|
const results = [];
|
|
13
13
|
|
|
@@ -247,7 +247,7 @@ async function testUAT() {
|
|
|
247
247
|
|
|
248
248
|
// 3. End-to-end P2P flow: search → create_p2p → read_p2p_messages
|
|
249
249
|
try {
|
|
250
|
-
const { LarkUserClient } = require('./
|
|
250
|
+
const { LarkUserClient } = require('./clients/user');
|
|
251
251
|
const userClient = new LarkUserClient(process.env.LARK_COOKIE);
|
|
252
252
|
await userClient.init();
|
|
253
253
|
const results = await userClient.search('杨一可');
|
package/src/test-send.js
CHANGED
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
* node src/test-send.js info <chatId> # Get chat info
|
|
10
10
|
*/
|
|
11
11
|
require('dotenv').config({ path: require('path').join(__dirname, '..', '.env') });
|
|
12
|
-
const { LarkUserClient } = require('./
|
|
12
|
+
const { LarkUserClient } = require('./clients/user');
|
|
13
13
|
|
|
14
14
|
async function main() {
|
|
15
15
|
const cookie = process.env.LARK_COOKIE;
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
// src/tools/_registry.js — shared infrastructure for tool modules.
|
|
2
|
+
//
|
|
3
|
+
// Every src/tools/<domain>.js exports:
|
|
4
|
+
// { schemas: [<MCP tool schema objects>], handlers: { [name]: async (args, ctx) => MCPResponse } }
|
|
5
|
+
//
|
|
6
|
+
// The ctx object that handlers receive is built in src/server.js (or, during
|
|
7
|
+
// the v1.3.7 phase A migration, temporarily in src/index.js) and provides:
|
|
8
|
+
// - getUserClient(): Promise<LarkUserClient>
|
|
9
|
+
// - getOfficialClient(): LarkOfficialClient
|
|
10
|
+
// - getEventBuffer(): EventBuffer | null — null when WS isn't running
|
|
11
|
+
// - resolveDocId(x): Promise<string> — wiki-node / URL → native token
|
|
12
|
+
// - listProfiles(): string[] — names from LARK_PROFILES_JSON + 'default'
|
|
13
|
+
// - getActiveProfile():string
|
|
14
|
+
// - setActiveProfile(name): void — invalidates cached clients
|
|
15
|
+
//
|
|
16
|
+
// Response builders below are imported directly by each tool module — they're
|
|
17
|
+
// not on ctx because they're pure functions with no state.
|
|
18
|
+
|
|
19
|
+
const text = (s) => ({ content: [{ type: 'text', text: s }] });
|
|
20
|
+
|
|
21
|
+
// `json` will lift any `fallbackWarning` field to the top of the rendered
|
|
22
|
+
// response so users see the warning before the structured payload. Preserved
|
|
23
|
+
// from index.js v1.3.5 behaviour.
|
|
24
|
+
const json = (o) => {
|
|
25
|
+
const warn = o && typeof o === 'object' && o.fallbackWarning ? `${o.fallbackWarning}\n\n` : '';
|
|
26
|
+
return text(warn + JSON.stringify(o, null, 2));
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
const sendResult = (r, desc) => text(r.success ? desc : `Send failed (status: ${r.status})`);
|
|
30
|
+
|
|
31
|
+
module.exports = { text, json, sendResult };
|