feishu-user-plugin 1.3.7 → 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 +49 -0
- package/README.md +19 -3
- package/package.json +3 -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 +32 -7
- package/scripts/decode-feishu-protobuf.js +115 -0
- package/scripts/sync-server-json.js +71 -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 +3 -3
- package/skills/feishu-user-plugin/references/CLAUDE.md +146 -255
- package/src/auth/cookie.js +30 -0
- package/src/auth/credentials.js +49 -0
- package/src/auth/profile-router.js +248 -0
- package/src/auth/uat.js +231 -0
- package/src/cli.js +3 -0
- package/src/clients/official/base.js +12 -248
- package/src/clients/user.js +10 -24
- 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/server.js +65 -2
- package/src/setup.js +16 -1
- package/src/tools/_registry.js +1 -0
- package/src/tools/events.js +64 -0
- package/src/tools/messaging-user.js +1 -1
- package/src/tools/profile.js +31 -0
package/src/server.js
CHANGED
|
@@ -26,6 +26,7 @@ const { LarkOfficialClient } = require('./clients/official');
|
|
|
26
26
|
const { resolveToken } = require('./resolver');
|
|
27
27
|
const { listPrompts, getPrompt } = require('./prompts');
|
|
28
28
|
const credentials = require('./auth/credentials');
|
|
29
|
+
const profileRouter = require('./auth/profile-router');
|
|
29
30
|
|
|
30
31
|
// --- Tool modules ---
|
|
31
32
|
// Adding a new domain: create src/tools/<x>.js exporting { schemas, handlers }
|
|
@@ -38,6 +39,7 @@ const TOOL_MODULES = [
|
|
|
38
39
|
require('./tools/diagnostics'),
|
|
39
40
|
require('./tools/docs'),
|
|
40
41
|
require('./tools/drive'),
|
|
42
|
+
require('./tools/events'),
|
|
41
43
|
require('./tools/groups'),
|
|
42
44
|
require('./tools/im-read'),
|
|
43
45
|
require('./tools/messaging-bot'),
|
|
@@ -65,10 +67,20 @@ const HANDLERS = Object.fromEntries(TOOL_MODULES.flatMap((m) => Object.entries(m
|
|
|
65
67
|
|
|
66
68
|
let userClient = null;
|
|
67
69
|
let officialClient = null;
|
|
70
|
+
let wsServer = null;
|
|
71
|
+
function getEventBuffer() {
|
|
72
|
+
return wsServer ? wsServer.buffer : null;
|
|
73
|
+
}
|
|
68
74
|
// The "current" profile this in-memory MCP server is pinned to. Initialised
|
|
69
75
|
// from the persisted active profile (credentials.json) at boot, but in-process
|
|
70
76
|
// switches may diverge from the persisted active until the next server restart.
|
|
71
|
-
|
|
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();
|
|
72
84
|
|
|
73
85
|
function profileEnv(name) {
|
|
74
86
|
return credentials.getActiveProfileEnv(name);
|
|
@@ -137,6 +149,7 @@ function buildCtx() {
|
|
|
137
149
|
return {
|
|
138
150
|
getUserClient,
|
|
139
151
|
getOfficialClient,
|
|
152
|
+
getEventBuffer,
|
|
140
153
|
listProfiles: () => credentials.listProfileNames(),
|
|
141
154
|
getActiveProfile: () => currentProfile,
|
|
142
155
|
setActiveProfile: (n) => {
|
|
@@ -168,8 +181,15 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
168
181
|
if (!handler) {
|
|
169
182
|
return { content: [{ type: 'text', text: `Unknown tool: ${name}` }], isError: true };
|
|
170
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
|
+
|
|
171
189
|
try {
|
|
172
|
-
return await
|
|
190
|
+
return await profileRouter.withProfileRouting(buildCtx(), name, args || {}, async () => {
|
|
191
|
+
return handler(cleanArgs, buildCtx());
|
|
192
|
+
});
|
|
173
193
|
} catch (err) {
|
|
174
194
|
return { content: [{ type: 'text', text: `Error: ${err.message}` }], isError: true };
|
|
175
195
|
}
|
|
@@ -191,6 +211,8 @@ process.on('uncaughtException', (err) => {
|
|
|
191
211
|
process.on('unhandledRejection', (reason) => {
|
|
192
212
|
console.error('[feishu-user-plugin] Unhandled rejection:', reason);
|
|
193
213
|
});
|
|
214
|
+
process.on('SIGTERM', () => { try { wsServer?.stop(); } catch {} process.exit(0); });
|
|
215
|
+
process.on('SIGINT', () => { try { wsServer?.stop(); } catch {} process.exit(0); });
|
|
194
216
|
|
|
195
217
|
// --- main ---
|
|
196
218
|
|
|
@@ -200,6 +222,16 @@ async function main() {
|
|
|
200
222
|
|
|
201
223
|
// Startup diagnostics — use the resolved active-profile env so users on
|
|
202
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
|
+
}
|
|
203
235
|
let activeEnv = {};
|
|
204
236
|
try { activeEnv = profileEnv(currentProfile); } catch (_) { /* unknown profile is reported below */ }
|
|
205
237
|
const hasCanonical = !!credentials.readCanonical();
|
|
@@ -212,6 +244,15 @@ async function main() {
|
|
|
212
244
|
if (!hasCookie) console.error('[feishu-user-plugin] WARNING: LARK_COOKIE not set — user identity tools (send_to_user, etc.) will fail');
|
|
213
245
|
if (!hasApp) console.error('[feishu-user-plugin] WARNING: LARK_APP_ID/SECRET not set — official API tools (read_messages, docs, etc.) will fail');
|
|
214
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
|
+
}
|
|
215
256
|
|
|
216
257
|
// Validate APP_ID/SECRET against Feishu before serving any tool calls.
|
|
217
258
|
// Catches the "Claude filled in a wrong/stale APP_ID during install" failure
|
|
@@ -233,6 +274,28 @@ async function main() {
|
|
|
233
274
|
console.error(`[feishu-user-plugin] WARNING: Could not verify APP_ID (${e.message}); network issue or cold start. Proceeding anyway.`);
|
|
234
275
|
}
|
|
235
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
|
+
}
|
|
236
299
|
}
|
|
237
300
|
|
|
238
301
|
module.exports = { main, TOOLS, HANDLERS };
|
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/tools/_registry.js
CHANGED
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
// the v1.3.7 phase A migration, temporarily in src/index.js) and provides:
|
|
8
8
|
// - getUserClient(): Promise<LarkUserClient>
|
|
9
9
|
// - getOfficialClient(): LarkOfficialClient
|
|
10
|
+
// - getEventBuffer(): EventBuffer | null — null when WS isn't running
|
|
10
11
|
// - resolveDocId(x): Promise<string> — wiki-node / URL → native token
|
|
11
12
|
// - listProfiles(): string[] — names from LARK_PROFILES_JSON + 'default'
|
|
12
13
|
// - getActiveProfile():string
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
// src/tools/events.js — real-time event consumption (v1.3.8).
|
|
2
|
+
//
|
|
3
|
+
// Single tool: get_new_events. Drains the EventBuffer that ws-server.js fills
|
|
4
|
+
// from Feishu's realtime WS push. Default: pulls all events accumulated since
|
|
5
|
+
// the last call (drain semantics — consumers must accept that events vanish
|
|
6
|
+
// after read).
|
|
7
|
+
|
|
8
|
+
const { text, json } = require('./_registry');
|
|
9
|
+
|
|
10
|
+
const schemas = [
|
|
11
|
+
{
|
|
12
|
+
name: 'get_new_events',
|
|
13
|
+
description: '[Plugin v1.3.8] Drain real-time events received since the last call. Currently surfaces "im.message.receive_v1" events (replies, group messages). Returns empty when WS isn\'t connected or no events have arrived. Use filter to scope by event_type or chat_id; max_events caps response size.',
|
|
14
|
+
inputSchema: {
|
|
15
|
+
type: 'object',
|
|
16
|
+
properties: {
|
|
17
|
+
event_type: { type: 'string', description: 'Optional: only events of this type (e.g. "im.message.receive_v1").' },
|
|
18
|
+
event_types: { type: 'array', items: { type: 'string' }, description: 'Optional: any-of list of event types.' },
|
|
19
|
+
chat_id: { type: 'string', description: 'Optional: only events from this chat (oc_xxx for groups, message events expose chat_id).' },
|
|
20
|
+
since_seconds: { type: 'integer', description: 'Optional: only events received in the last N seconds.' },
|
|
21
|
+
max_events: { type: 'integer', description: 'Cap on returned events (default 50). Drained events beyond the cap are returned in subsequent calls.' },
|
|
22
|
+
peek: { type: 'boolean', description: 'When true, leave events in the buffer (default false = drain).' },
|
|
23
|
+
},
|
|
24
|
+
},
|
|
25
|
+
},
|
|
26
|
+
];
|
|
27
|
+
|
|
28
|
+
const handlers = {
|
|
29
|
+
async get_new_events(args, ctx) {
|
|
30
|
+
const buffer = ctx.getEventBuffer && ctx.getEventBuffer();
|
|
31
|
+
if (!buffer) {
|
|
32
|
+
return text('Realtime events are not available. Reasons: APP_ID/SECRET not configured, OR Lark international tenant (Feishu WS only supports feishu.cn), OR the WS handshake failed at startup. Check server stderr for "WS connected" / "WS start failed".');
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const filter = {};
|
|
36
|
+
if (args.event_type) filter.event_type = args.event_type;
|
|
37
|
+
if (args.event_types) filter.event_types = args.event_types;
|
|
38
|
+
if (args.chat_id) filter.chat_id = args.chat_id;
|
|
39
|
+
if (args.since_seconds) filter.since_seconds = args.since_seconds;
|
|
40
|
+
|
|
41
|
+
const cap = Math.max(1, parseInt(args.max_events, 10) || 50);
|
|
42
|
+
|
|
43
|
+
let events = args.peek ? buffer.peek(filter) : buffer.drain(filter);
|
|
44
|
+
let truncated = false;
|
|
45
|
+
if (events.length > cap) {
|
|
46
|
+
const kept = events.slice(0, cap);
|
|
47
|
+
const overflow = events.slice(cap);
|
|
48
|
+
if (!args.peek) {
|
|
49
|
+
for (const e of overflow) buffer.push(e);
|
|
50
|
+
}
|
|
51
|
+
events = kept;
|
|
52
|
+
truncated = true;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
return json({
|
|
56
|
+
events,
|
|
57
|
+
stats: buffer.stats(),
|
|
58
|
+
truncated,
|
|
59
|
+
hint: events.length === 0 ? 'No new events. Call again later, or check stats.totalSeen / .totalDropped to confirm WS is alive.' : undefined,
|
|
60
|
+
});
|
|
61
|
+
},
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
module.exports = { schemas, handlers };
|
|
@@ -160,7 +160,7 @@ const schemas = [
|
|
|
160
160
|
},
|
|
161
161
|
{
|
|
162
162
|
name: 'send_card_as_user',
|
|
163
|
-
description: '[v1.3.6
|
|
163
|
+
description: '[v1.3.6+: bot-routed default] Send an interactive card to a chat. **Identity defaults to BOT** because user-identity card sending requires reverse-engineering the Feishu web protobuf (deferred to v1.3.9; v1.3.8 shipped the capture/decode tooling). The tool name keeps the "as_user" suffix so callers don\'t have to migrate when v1.3.9 lands; once user-identity is implemented the default flips. Pass `card` as a JSON object (Feishu card schema). To force bot explicitly set via="bot".',
|
|
164
164
|
inputSchema: {
|
|
165
165
|
type: 'object',
|
|
166
166
|
properties: {
|
package/src/tools/profile.js
CHANGED
|
@@ -23,6 +23,19 @@ const schemas = [
|
|
|
23
23
|
required: ['name'],
|
|
24
24
|
},
|
|
25
25
|
},
|
|
26
|
+
{
|
|
27
|
+
name: 'manage_profile_hints',
|
|
28
|
+
description: '[Plugin v1.3.8] Inspect / set / clear profileHints — the resourceKey → profileName cache the auto-switch middleware uses to remember which profile owns each Feishu resource. Useful when a hint goes stale (e.g., a profile lost access to a doc).',
|
|
29
|
+
inputSchema: {
|
|
30
|
+
type: 'object',
|
|
31
|
+
properties: {
|
|
32
|
+
action: { type: 'string', enum: ['list', 'set', 'clear'], description: 'list = show all hints; set = upsert one; clear = remove one or all.' },
|
|
33
|
+
resource_key: { type: 'string', description: 'For set/clear: the resourceKey, e.g. "doc:doccnXXX" or "chat:oc_zzz". Omit on clear to wipe all hints.' },
|
|
34
|
+
profile: { type: 'string', description: 'For set: the profile name to associate with the resource_key.' },
|
|
35
|
+
},
|
|
36
|
+
required: ['action'],
|
|
37
|
+
},
|
|
38
|
+
},
|
|
26
39
|
];
|
|
27
40
|
|
|
28
41
|
const handlers = {
|
|
@@ -38,6 +51,24 @@ const handlers = {
|
|
|
38
51
|
ctx.setActiveProfile(target);
|
|
39
52
|
return text(`Switched to profile: ${target}`);
|
|
40
53
|
},
|
|
54
|
+
async manage_profile_hints(args, _ctx) {
|
|
55
|
+
const credentials = require('../auth/credentials');
|
|
56
|
+
if (args.action === 'list') {
|
|
57
|
+
return json({ hints: credentials.getProfileHints() });
|
|
58
|
+
}
|
|
59
|
+
if (args.action === 'set') {
|
|
60
|
+
if (!args.resource_key) return text('manage_profile_hints(set): resource_key is required');
|
|
61
|
+
if (!args.profile) return text('manage_profile_hints(set): profile is required');
|
|
62
|
+
const ok = credentials.setProfileHint(args.resource_key, args.profile);
|
|
63
|
+
return text(ok ? `Hint set: ${args.resource_key} → ${args.profile}` : 'Hint not set (no credentials.json — run `npx feishu-user-plugin migrate --confirm`)');
|
|
64
|
+
}
|
|
65
|
+
if (args.action === 'clear') {
|
|
66
|
+
const ok = credentials.clearProfileHint(args.resource_key);
|
|
67
|
+
const target = args.resource_key || '<all>';
|
|
68
|
+
return text(ok ? `Hint cleared: ${target}` : `No hint to clear for: ${target}`);
|
|
69
|
+
}
|
|
70
|
+
return text(`manage_profile_hints: unknown action "${args.action}". Use list / set / clear.`);
|
|
71
|
+
},
|
|
41
72
|
};
|
|
42
73
|
|
|
43
74
|
module.exports = { schemas, handlers };
|