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/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
- let currentProfile = credentials.getActiveProfileName();
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 handler(args || {}, buildCtx());
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));
@@ -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: bot-routed default] Send an interactive card to a chat. **As of v1.3.6, identity defaults to BOT** because user-identity card sending requires reverse-engineering the Feishu web protobuf and is deferred to v1.3.7. The tool name keeps the "as_user" suffix so callers don\'t have to migrate when v1.3.7 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".',
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: {
@@ -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 };