feishu-user-plugin 1.3.7 → 1.3.9

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.
Files changed (54) hide show
  1. package/.claude-plugin/plugin.json +13 -3
  2. package/CHANGELOG.md +87 -0
  3. package/README.md +20 -4
  4. package/package.json +10 -6
  5. package/proto/lark.proto +10 -0
  6. package/scripts/capture-feishu-protobuf.js +86 -0
  7. package/scripts/check-changelog.js +31 -0
  8. package/scripts/check-docs-sync.js +41 -0
  9. package/scripts/check-tool-count.js +32 -7
  10. package/scripts/decode-feishu-protobuf.js +115 -0
  11. package/scripts/explore-card-protobuf.js +144 -0
  12. package/scripts/explore-image-minimize.js +163 -0
  13. package/scripts/generate-release-artifacts.js +318 -0
  14. package/scripts/probe-feishu-docx.js +203 -0
  15. package/scripts/sync-server-json.js +71 -0
  16. package/scripts/sync-team-skills.sh +109 -7
  17. package/scripts/test-wiki-attach-fallback.js +71 -0
  18. package/scripts/test-ws-events.js +84 -0
  19. package/skills/feishu-user-plugin/SKILL.md +77 -5
  20. package/skills/feishu-user-plugin/references/CLAUDE.md +208 -297
  21. package/src/auth/cookie.js +30 -0
  22. package/src/auth/credentials.js +85 -0
  23. package/src/auth/profile-router.js +248 -0
  24. package/src/auth/uat.js +231 -0
  25. package/src/cli.js +86 -42
  26. package/src/clients/official/base.js +12 -248
  27. package/src/clients/user.js +19 -31
  28. package/src/config.js +13 -8
  29. package/src/events/cursor.js +103 -0
  30. package/src/events/event-buffer.js +103 -0
  31. package/src/events/event-log.js +151 -0
  32. package/src/events/index.js +12 -0
  33. package/src/events/lockfile.js +126 -0
  34. package/src/events/owner.js +73 -0
  35. package/src/events/ws-server.js +156 -0
  36. package/src/oauth.js +48 -7
  37. package/src/resolver.js +10 -0
  38. package/src/server.js +285 -3
  39. package/src/setup.js +100 -11
  40. package/src/test-all.js +12 -9
  41. package/src/test-events-cursor.js +56 -0
  42. package/src/test-events-lockfile.js +36 -0
  43. package/src/test-events-log.js +67 -0
  44. package/src/test-events-owner.js +64 -0
  45. package/src/test-fixtures/doc-blocks/sample-1.json +1256 -0
  46. package/src/test-read-doc-markdown.js +61 -0
  47. package/src/test-switch-profile.js +171 -0
  48. package/src/tools/_registry.js +1 -0
  49. package/src/tools/diagnostics.js +10 -3
  50. package/src/tools/docs.js +93 -3
  51. package/src/tools/events.js +174 -0
  52. package/src/tools/messaging-bot.js +2 -3
  53. package/src/tools/messaging-user.js +23 -14
  54. package/src/tools/profile.js +43 -7
@@ -0,0 +1,156 @@
1
+ // src/events/ws-server.js — rewritten for v1.3.9 owner-arbitrated mode.
2
+ //
3
+ // What this owns:
4
+ // - createWSServer(opts) — factory for WS client + event dispatcher.
5
+ // - v1.3.9: In owner mode (logPath provided), events go to events.jsonl
6
+ // directly instead of the in-memory buffer.
7
+ // - Tracks wsState / wsProfile so downstream can filter dropped events.
8
+ //
9
+ // What it does NOT own:
10
+ // - Owner-lock acquisition (src/events/owner.js).
11
+ // - Cursor protocol (src/events/cursor.js).
12
+
13
+ 'use strict';
14
+
15
+ const lark = require('@larksuiteoapi/node-sdk');
16
+ const { EventBuffer, DEFAULT_CAP } = require('./event-buffer');
17
+ const { stderrLogger } = require('../logger');
18
+ const { appendEvent } = require('./event-log');
19
+
20
+ // Build the handler that writes a normalised event row.
21
+ // In v1.3.9 owner mode, we write to events.jsonl directly with a profile tag.
22
+ // In legacy mode (no logPath given) we fall back to the in-memory buffer.
23
+ function _eventRowHandler({ buffer, eventType, getProfile, getWsState, logPath }) {
24
+ return async (data) => {
25
+ const wsState = getWsState();
26
+ if (wsState !== 'connected') {
27
+ // Drop events received during 'switching' / 'disconnected' — we don't
28
+ // know which profile they belong to.
29
+ return;
30
+ }
31
+ const event = {
32
+ event_id: data?.event_id || data?.header?.event_id || 'evt_' + Math.random().toString(36).slice(2),
33
+ ts: Date.now(),
34
+ profile: getProfile(),
35
+ event_type: eventType,
36
+ header: data?.header || null,
37
+ event: data?.event || data,
38
+ };
39
+ if (logPath) {
40
+ try {
41
+ appendEvent(logPath, event);
42
+ } catch (e) {
43
+ // Disk-full or similar — fall back to in-memory buffer.
44
+ console.error(`[feishu-user-plugin] events.jsonl append failed: ${e.message}; falling back to in-memory buffer`);
45
+ buffer.push(event);
46
+ }
47
+ } else {
48
+ buffer.push(event);
49
+ }
50
+ };
51
+ }
52
+
53
+ function createWSServer({
54
+ appId, appSecret,
55
+ bufferCap = DEFAULT_CAP,
56
+ registrations = ['im.message.receive_v1'],
57
+ logPath = null, // NEW: when set, events go to events.jsonl
58
+ initialProfile = 'default', // NEW: profile name to tag events with
59
+ } = {}) {
60
+ if (!appId || !appSecret) throw new Error('createWSServer: appId + appSecret required');
61
+
62
+ const buffer = new EventBuffer({ cap: bufferCap });
63
+ let wsClient = null;
64
+ let started = false;
65
+ let stopped = false;
66
+ let wsProfile = initialProfile;
67
+ let wsState = 'disconnected'; // disconnected | connected | switching
68
+ let lastReconnectAt = null;
69
+ let reconnectAttempts = 0;
70
+
71
+ const dispatcher = new lark.EventDispatcher({
72
+ logger: stderrLogger,
73
+ loggerLevel: lark.LoggerLevel.warn,
74
+ });
75
+
76
+ const handlers = {};
77
+ for (const t of registrations) {
78
+ handlers[t] = _eventRowHandler({
79
+ buffer, eventType: t,
80
+ getProfile: () => wsProfile,
81
+ getWsState: () => wsState,
82
+ logPath,
83
+ });
84
+ }
85
+ try {
86
+ dispatcher.register(handlers);
87
+ } catch (e) {
88
+ console.error(`[feishu-user-plugin] WS event registration failed: ${e.message}; falling back to im.message.receive_v1 only`);
89
+ dispatcher.register({
90
+ 'im.message.receive_v1': _eventRowHandler({
91
+ buffer, eventType: 'im.message.receive_v1',
92
+ getProfile: () => wsProfile, getWsState: () => wsState, logPath,
93
+ }),
94
+ });
95
+ }
96
+
97
+ async function start() {
98
+ if (started) return;
99
+ started = true;
100
+ wsState = 'switching';
101
+ wsClient = new lark.WSClient({
102
+ appId, appSecret,
103
+ logger: stderrLogger,
104
+ loggerLevel: lark.LoggerLevel.warn,
105
+ });
106
+ try {
107
+ await wsClient.start({ eventDispatcher: dispatcher });
108
+ wsState = 'connected';
109
+ lastReconnectAt = Date.now();
110
+ console.error(`[feishu-user-plugin] WS connected (profile=${wsProfile}) — listening for: ${registrations.join(', ')}`);
111
+ } catch (e) {
112
+ wsState = 'disconnected';
113
+ reconnectAttempts++;
114
+ console.error(`[feishu-user-plugin] WS start failed: ${e.message}. Continuing without realtime events.`);
115
+ started = false;
116
+ wsClient = null;
117
+ }
118
+ }
119
+
120
+ async function stop() {
121
+ if (stopped) return;
122
+ stopped = true;
123
+ wsState = 'disconnected';
124
+ if (wsClient) {
125
+ try { wsClient.close(); } catch (_) {}
126
+ wsClient = null;
127
+ }
128
+ }
129
+
130
+ // For switching: stop, then start with a new profile/registrations.
131
+ // The registrations list is currently fixed at construction; full switching
132
+ // requires re-creating the WSClient. This helper just stops + nulls so the
133
+ // caller can construct a new server.
134
+ async function reconfigureProfile(newProfile) {
135
+ wsState = 'switching';
136
+ wsProfile = 'switching'; // tag-irrelevant during transition
137
+ await stop();
138
+ started = false; stopped = false;
139
+ wsProfile = newProfile;
140
+ await start();
141
+ }
142
+
143
+ function getStatus() {
144
+ return {
145
+ state: wsState,
146
+ wsProfile,
147
+ subscribed_events: registrations.slice(),
148
+ lastReconnectAt,
149
+ reconnectAttempts,
150
+ };
151
+ }
152
+
153
+ return { buffer, start, stop, reconfigureProfile, getStatus, get isRunning() { return started && !stopped; } };
154
+ }
155
+
156
+ module.exports = { createWSServer };
package/src/oauth.js CHANGED
@@ -14,9 +14,44 @@
14
14
 
15
15
  const http = require('http');
16
16
  const { execSync } = require('child_process');
17
- const { readCredentials, persistToConfig } = require('./config');
17
+ const credentialsModule = require('./auth/credentials');
18
+ const legacyConfig = require('./config');
18
19
 
19
- const creds = readCredentials();
20
+ // v1.3.9: profile-aware. Accepts `--profile <name>` (defaults to credentials.json::active);
21
+ // reads APP_ID/SECRET from that profile, persists UAT back to that profile.
22
+ // When credentials.json doesn't exist, falls back to legacy harness env path
23
+ // (which is what v1.3.6 → v1.3.8 did).
24
+ function _parseTargetProfile() {
25
+ const argv = process.argv.slice(2);
26
+ for (let i = 0; i < argv.length; i++) {
27
+ if (argv[i] === '--profile' && argv[i + 1]) return argv[++i];
28
+ }
29
+ return null;
30
+ }
31
+
32
+ const TARGET_PROFILE = _parseTargetProfile();
33
+ const _hasCanonical = !!credentialsModule.readCanonical();
34
+ let creds;
35
+ let profileLabel;
36
+ if (_hasCanonical) {
37
+ const targetName = TARGET_PROFILE || credentialsModule.getActiveProfileName();
38
+ try {
39
+ creds = credentialsModule.getActiveProfileEnv(targetName);
40
+ profileLabel = `credentials.json::profiles[${targetName}]`;
41
+ } catch (e) {
42
+ console.error(`OAuth target profile error: ${e.message}`);
43
+ console.error(`Available: ${credentialsModule.listProfileNames().join(', ')}`);
44
+ process.exit(1);
45
+ }
46
+ if (TARGET_PROFILE) console.log(`OAuth target profile: ${TARGET_PROFILE}`);
47
+ } else {
48
+ if (TARGET_PROFILE) {
49
+ console.error(`--profile flag given but credentials.json doesn't exist. Run \`npx feishu-user-plugin migrate --confirm\` first, or remove --profile to use legacy env path.`);
50
+ process.exit(1);
51
+ }
52
+ creds = legacyConfig.readCredentials();
53
+ profileLabel = 'harness env (legacy)';
54
+ }
20
55
  const APP_ID = creds.LARK_APP_ID;
21
56
  const APP_SECRET = creds.LARK_APP_SECRET;
22
57
  const PORT = 9997;
@@ -118,12 +153,18 @@ function saveToken(tokenData) {
118
153
  LARK_UAT_EXPIRES: String(Math.floor(Date.now() / 1000 + (typeof tokenData.expires_in === 'number' && tokenData.expires_in > 0 ? tokenData.expires_in : 7200))),
119
154
  };
120
155
 
121
- const ok = persistToConfig(updates);
156
+ let ok = false;
157
+ if (_hasCanonical) {
158
+ const targetName = TARGET_PROFILE || credentialsModule.getActiveProfileName();
159
+ ok = credentialsModule.persistProfileUpdate(targetName, updates);
160
+ if (ok) console.log(`Tokens written to ${profileLabel}`);
161
+ } else {
162
+ ok = legacyConfig.persistToConfig(updates);
163
+ if (ok) console.log(`Tokens written to ${profileLabel}`);
164
+ }
122
165
  if (!ok) {
123
- console.error('WARNING: Tokens could not be saved to config. Copy them manually:');
124
- for (const [k, v] of Object.entries(updates)) {
125
- console.error(` ${k}=${v}`);
126
- }
166
+ console.error('WARNING: Tokens could not be saved. Copy them manually:');
167
+ for (const [k, v] of Object.entries(updates)) console.error(` ${k}=${v}`);
127
168
  }
128
169
  }
129
170
 
package/src/resolver.js CHANGED
@@ -144,8 +144,18 @@ async function resolveToken(input, official) {
144
144
  return r.obj_token;
145
145
  }
146
146
 
147
+ /**
148
+ * Clear the wiki-node resolution cache. Called by the profile-sync hook in
149
+ * server.js when the active profile changes, so that wiki nodes belonging to
150
+ * the previous profile's app credentials don't poison lookups for the new one.
151
+ */
152
+ function clearCache() {
153
+ _cache.clear();
154
+ }
155
+
147
156
  module.exports = {
148
157
  parseFeishuInput,
149
158
  resolveToObj,
150
159
  resolveToken,
160
+ clearCache,
151
161
  };
package/src/server.js CHANGED
@@ -13,6 +13,8 @@
13
13
  // - Config discovery / persistence: src/config/*.
14
14
 
15
15
  const path = require('path');
16
+ const fs = require('fs');
17
+ const os = require('os');
16
18
  const { Server } = require('@modelcontextprotocol/sdk/server/index.js');
17
19
  const { StdioServerTransport } = require('@modelcontextprotocol/sdk/server/stdio.js');
18
20
  const { CallToolRequestSchema, ListToolsRequestSchema, ListPromptsRequestSchema, GetPromptRequestSchema } = require('@modelcontextprotocol/sdk/types.js');
@@ -26,6 +28,7 @@ const { LarkOfficialClient } = require('./clients/official');
26
28
  const { resolveToken } = require('./resolver');
27
29
  const { listPrompts, getPrompt } = require('./prompts');
28
30
  const credentials = require('./auth/credentials');
31
+ const profileRouter = require('./auth/profile-router');
29
32
 
30
33
  // --- Tool modules ---
31
34
  // Adding a new domain: create src/tools/<x>.js exporting { schemas, handlers }
@@ -38,6 +41,7 @@ const TOOL_MODULES = [
38
41
  require('./tools/diagnostics'),
39
42
  require('./tools/docs'),
40
43
  require('./tools/drive'),
44
+ require('./tools/events'),
41
45
  require('./tools/groups'),
42
46
  require('./tools/im-read'),
43
47
  require('./tools/messaging-bot'),
@@ -63,12 +67,48 @@ const HANDLERS = Object.fromEntries(TOOL_MODULES.flatMap((m) => Object.entries(m
63
67
  // When credentials.json exists, switching also persists the active field so
64
68
  // cross-process MCP servers see the same active profile after restart.
65
69
 
70
+ const events = require('./events');
71
+
72
+ const FEISHU_HOME = path.join(os.homedir(), '.feishu-user-plugin');
73
+ const EVENTS_LOG_PATH = path.join(FEISHU_HOME, 'events.jsonl');
74
+
66
75
  let userClient = null;
67
76
  let officialClient = null;
77
+ let wsServer = null;
78
+ let ownerHandle = null; // returned by tryClaim when isOwner
79
+ let ownerHeartbeatTimer = null;
80
+ let nonOwnerPollTimer = null;
81
+ let _ownerStartCallbacks = [];
82
+
83
+ function _onBecomeOwner(cb) { _ownerStartCallbacks.push(cb); }
84
+
85
+ function _stopHeartbeat() {
86
+ if (ownerHeartbeatTimer) { clearInterval(ownerHeartbeatTimer); ownerHeartbeatTimer = null; }
87
+ }
88
+ function _stopNonOwnerPoll() {
89
+ if (nonOwnerPollTimer) { clearInterval(nonOwnerPollTimer); nonOwnerPollTimer = null; }
90
+ }
91
+
92
+ function getEventBuffer() {
93
+ return wsServer ? wsServer.buffer : null;
94
+ }
68
95
  // The "current" profile this in-memory MCP server is pinned to. Initialised
69
96
  // from the persisted active profile (credentials.json) at boot, but in-process
70
97
  // switches may diverge from the persisted active until the next server restart.
98
+ //
99
+ // Profile selection precedence (v1.3.9 A.2 — SSOT):
100
+ // 1. credentials.json::active — single-file persisted active (canonical SSOT)
101
+ // 2. process.env.FEISHU_PLUGIN_PROFILE — harness pointer (bootstrap-only, when
102
+ // credentials.json does not exist)
103
+ // 3. 'default' — legacy zero-config path
104
+ // Note: FEISHU_PLUGIN_PROFILE in the harness env is now a bootstrap pointer only.
105
+ // Once credentials.json exists, its `active` field is authoritative; cross-process
106
+ // sync propagates active-profile changes without a server restart.
71
107
  let currentProfile = credentials.getActiveProfileName();
108
+ if (!credentials.readCanonical() && process.env.FEISHU_PLUGIN_PROFILE) {
109
+ // Bootstrap-only: legacy env users without canonical credentials file.
110
+ currentProfile = process.env.FEISHU_PLUGIN_PROFILE;
111
+ }
72
112
 
73
113
  function profileEnv(name) {
74
114
  return credentials.getActiveProfileEnv(name);
@@ -122,6 +162,157 @@ function loadUATFromEnv(client, env) {
122
162
  client._uatExpires = expires || client._decodeTokenExpiry(token);
123
163
  }
124
164
 
165
+ // --- Owner control loop (v1.3.9 A.1) ---
166
+
167
+ async function _claimAndStart() {
168
+ const claim = events.owner.tryClaim(FEISHU_HOME, { info: { role: 'ws_owner' } });
169
+ if (!claim.isOwner) {
170
+ // Become non-owner: poll lock health every 30s, attempt takeover when stale.
171
+ if (!nonOwnerPollTimer) {
172
+ nonOwnerPollTimer = setInterval(() => {
173
+ const info = events.owner.readOwnerInfo(FEISHU_HOME);
174
+ if (!info.exists || !info.alive) {
175
+ _claimAndStart().catch((e) => console.error(`[feishu-user-plugin] takeover attempt failed: ${e.message}`));
176
+ }
177
+ }, events.owner.TAKEOVER_POLL_INTERVAL_MS);
178
+ nonOwnerPollTimer.unref?.();
179
+ }
180
+ return;
181
+ }
182
+
183
+ ownerHandle = claim;
184
+ _stopNonOwnerPoll();
185
+ // Repair tail before WS starts pushing events
186
+ try { events.log.repairTail(EVENTS_LOG_PATH); } catch (_) {}
187
+
188
+ // Start WS with current active profile and its events list.
189
+ const profileName = currentProfile;
190
+ let activeEnv;
191
+ try { activeEnv = profileEnv(profileName); } catch (_) { return; }
192
+ if (!activeEnv.LARK_APP_ID || !activeEnv.LARK_APP_SECRET) return;
193
+
194
+ const eventsList = _getProfileEventsList(profileName);
195
+ wsServer = events.createWSServer({
196
+ appId: activeEnv.LARK_APP_ID,
197
+ appSecret: activeEnv.LARK_APP_SECRET,
198
+ registrations: eventsList,
199
+ logPath: EVENTS_LOG_PATH,
200
+ initialProfile: profileName,
201
+ });
202
+ wsServer.start().catch((e) => console.error(`[feishu-user-plugin] WS start error: ${e.message}`));
203
+
204
+ // Heartbeat + check active changes every 15s.
205
+ let lastCredMtime = _credMtime();
206
+ ownerHeartbeatTimer = setInterval(() => {
207
+ if (ownerHandle) ownerHandle.heartbeat();
208
+ const m = _credMtime();
209
+ if (m !== null && m !== lastCredMtime) {
210
+ lastCredMtime = m;
211
+ _maybeReconfigure().catch((e) => console.error(`[feishu-user-plugin] reconfigure failed: ${e.message}`));
212
+ }
213
+ // Defer-rotate check
214
+ try {
215
+ const snap = events.cursor.readSnapshot(FEISHU_HOME);
216
+ const SOFT_CAP = 10 * 1024 * 1024;
217
+ const HARD_CAP = 20 * 1024 * 1024;
218
+ const rot = events.log.maybeRotate(EVENTS_LOG_PATH, snap.cursor.offset, SOFT_CAP);
219
+ if (rot.rotated) {
220
+ events.cursor.resetCursorTo(FEISHU_HOME, 0);
221
+ } else if (snap.fileSize > HARD_CAP) {
222
+ events.log.forceRotate(EVENTS_LOG_PATH, snap.fileSize);
223
+ events.cursor.resetCursorTo(FEISHU_HOME, 0);
224
+ }
225
+ // Also clean up old .dropped files daily-ish (every heartbeat is cheap)
226
+ events.log.cleanupDropped(EVENTS_LOG_PATH, 7);
227
+ } catch (e) {
228
+ console.error(`[feishu-user-plugin] rotation check failed: ${e.message}`);
229
+ }
230
+ }, events.owner.HEARTBEAT_INTERVAL_MS);
231
+ ownerHeartbeatTimer.unref?.();
232
+
233
+ for (const cb of _ownerStartCallbacks) {
234
+ try { cb(); } catch (_) {}
235
+ }
236
+ }
237
+
238
+ function _credMtime() {
239
+ try {
240
+ const p = path.join(FEISHU_HOME, 'credentials.json');
241
+ return fs.statSync(p).mtimeMs;
242
+ } catch (_) { return null; }
243
+ }
244
+
245
+ // Cross-process active-profile sync (v1.3.9 A.2).
246
+ // Each tool call: stat credentials.json; if mtime changed AND active differs
247
+ // from in-memory currentProfile, do an in-process setActiveProfile().
248
+ // _credMtimeBaseline starts null — first call sets the baseline without syncing.
249
+ let _credMtimeBaseline = null;
250
+
251
+ function _syncActiveProfileFromDisk() {
252
+ const m = _credMtime();
253
+ if (m === null) return; // no credentials.json — nothing to sync
254
+ if (_credMtimeBaseline === null) { _credMtimeBaseline = m; return; }
255
+ if (m === _credMtimeBaseline) return; // unchanged
256
+ _credMtimeBaseline = m;
257
+ let newActive;
258
+ try { newActive = credentials.getActiveProfileName(); } catch (_) { return; }
259
+ if (newActive === currentProfile) return;
260
+ console.error(`[feishu-user-plugin] active profile changed on disk: ${currentProfile} → ${newActive}`);
261
+ // Use the same setActiveProfile flow that switch_profile uses, except don't
262
+ // re-write credentials.json (which it already reflects the change).
263
+ try {
264
+ credentials.getActiveProfileEnv(newActive); // validate profile exists
265
+ currentProfile = newActive;
266
+ userClient = null;
267
+ officialClient = null;
268
+ // resolver.js has a wiki-node resolution cache; clear it on profile switch
269
+ // so wiki nodes belonging to the previous app-id don't cross-contaminate.
270
+ require('./resolver').clearCache();
271
+ } catch (e) {
272
+ console.error(`[feishu-user-plugin] sync to "${newActive}" failed: ${e.message}; staying on "${currentProfile}"`);
273
+ }
274
+ }
275
+
276
+ async function _maybeReconfigure() {
277
+ if (!ownerHandle || !wsServer) return;
278
+ const newActive = credentials.getActiveProfileName();
279
+ const newEvents = _getProfileEventsList(newActive);
280
+ const status = wsServer.getStatus();
281
+ const sameProfile = status.wsProfile === newActive;
282
+ const sameEvents = JSON.stringify(status.subscribed_events) === JSON.stringify(newEvents);
283
+ if (sameProfile && sameEvents) return;
284
+
285
+ console.error(`[feishu-user-plugin] WS reconfigure: profile ${status.wsProfile}→${newActive}, events ${status.subscribed_events.length}→${newEvents.length}`);
286
+
287
+ // Tear down + rebuild because registrations are fixed at construction.
288
+ await wsServer.stop();
289
+ let activeEnv;
290
+ try { activeEnv = profileEnv(newActive); } catch (_) { return; }
291
+ if (!activeEnv.LARK_APP_ID || !activeEnv.LARK_APP_SECRET) return;
292
+ wsServer = events.createWSServer({
293
+ appId: activeEnv.LARK_APP_ID,
294
+ appSecret: activeEnv.LARK_APP_SECRET,
295
+ registrations: newEvents,
296
+ logPath: EVENTS_LOG_PATH,
297
+ initialProfile: newActive,
298
+ });
299
+ await wsServer.start();
300
+ }
301
+
302
+ function _getProfileEventsList(profileName) {
303
+ const canonical = credentials.readCanonical();
304
+ if (canonical && canonical.profiles[profileName]?.events) {
305
+ return canonical.profiles[profileName].events.slice();
306
+ }
307
+ // Bootstrap: env override
308
+ const env = process.env.FEISHU_PLUGIN_EXTRA_EVENTS;
309
+ if (env) {
310
+ const list = env.split(',').map(s => s.trim()).filter(Boolean);
311
+ if (list.length) return ['im.message.receive_v1', ...list];
312
+ }
313
+ return ['im.message.receive_v1'];
314
+ }
315
+
125
316
  // Resolver helper: turn document_id / app_token / wiki node / Feishu URL into
126
317
  // a native token. No-op for already-native inputs. See src/resolver.js.
127
318
  async function resolveDocId(input) {
@@ -137,6 +328,7 @@ function buildCtx() {
137
328
  return {
138
329
  getUserClient,
139
330
  getOfficialClient,
331
+ getEventBuffer,
140
332
  listProfiles: () => credentials.listProfileNames(),
141
333
  getActiveProfile: () => currentProfile,
142
334
  setActiveProfile: (n) => {
@@ -145,11 +337,45 @@ function buildCtx() {
145
337
  currentProfile = n;
146
338
  userClient = null;
147
339
  officialClient = null;
340
+ // Clear resolver cache so wiki-node lookups don't carry over from the old profile.
341
+ require('./resolver').clearCache();
148
342
  // Persist the active-field flip when credentials.json exists so peer MCP
149
- // servers see the new active profile on next read. Tolerated when no file.
150
- try { credentials.setActiveProfile(n); } catch (_) {}
343
+ // servers see the new active profile on next read. The credentials module
344
+ // throws if credentials.json doesn't exist OR if `n` isn't in profiles[];
345
+ // the first is benign (legacy mode), the second is a real bug — log it
346
+ // either way at warn level instead of swallowing silently.
347
+ try { credentials.setActiveProfile(n); }
348
+ catch (e) {
349
+ const cred = credentials.readCanonical();
350
+ if (cred) {
351
+ console.error(`[feishu-user-plugin] WARN: setActiveProfile("${n}") failed to persist to credentials.json: ${e.message}. In-memory currentProfile updated anyway, but other MCP processes won't see the switch.`);
352
+ }
353
+ }
354
+ // Re-baseline mtime so _syncActiveProfileFromDisk doesn't immediately
355
+ // trigger a redundant sync on the next tool call after we just wrote.
356
+ _credMtimeBaseline = _credMtime();
151
357
  },
152
358
  resolveDocId,
359
+ getWsServer: () => wsServer,
360
+ requestClaim: async ({ force = false } = {}) => {
361
+ const claim = events.owner.tryClaim(FEISHU_HOME, { info: { role: 'ws_owner' }, force });
362
+ if (claim.isOwner) {
363
+ ownerHandle = claim;
364
+ await _claimAndStart();
365
+ return { ok: true, became_owner: true };
366
+ }
367
+ return { ok: false, reason: 'lock_active_no_force', owner_pid: claim.ownerInfo?.pid };
368
+ },
369
+ requestReconfigure: async () => {
370
+ const before = wsServer?.getStatus() || {};
371
+ await _maybeReconfigure();
372
+ const after = wsServer?.getStatus() || {};
373
+ return {
374
+ prev_subscriptions: before.subscribed_events || [],
375
+ next_subscriptions: after.subscribed_events || [],
376
+ no_change: JSON.stringify(before.subscribed_events) === JSON.stringify(after.subscribed_events) && before.wsProfile === after.wsProfile,
377
+ };
378
+ },
153
379
  };
154
380
  }
155
381
 
@@ -163,13 +389,23 @@ const server = new Server(
163
389
  server.setRequestHandler(ListToolsRequestSchema, async () => ({ tools: TOOLS }));
164
390
 
165
391
  server.setRequestHandler(CallToolRequestSchema, async (request) => {
392
+ // Cross-process active-profile sync (v1.3.9 A.2): if another MCP process
393
+ // wrote a new `active` field to credentials.json, pick it up before dispatch.
394
+ _syncActiveProfileFromDisk();
166
395
  const { name, arguments: args } = request.params;
167
396
  const handler = HANDLERS[name];
168
397
  if (!handler) {
169
398
  return { content: [{ type: 'text', text: `Unknown tool: ${name}` }], isError: true };
170
399
  }
400
+ // Strip via_profile from args before passing to the handler — it's a
401
+ // routing-layer concern, not a tool argument. Keep a copy for routing.
402
+ const cleanArgs = (args && typeof args === 'object') ? { ...args } : {};
403
+ delete cleanArgs.via_profile;
404
+
171
405
  try {
172
- return await handler(args || {}, buildCtx());
406
+ return await profileRouter.withProfileRouting(buildCtx(), name, args || {}, async () => {
407
+ return handler(cleanArgs, buildCtx());
408
+ });
173
409
  } catch (err) {
174
410
  return { content: [{ type: 'text', text: `Error: ${err.message}` }], isError: true };
175
411
  }
@@ -191,6 +427,18 @@ process.on('uncaughtException', (err) => {
191
427
  process.on('unhandledRejection', (reason) => {
192
428
  console.error('[feishu-user-plugin] Unhandled rejection:', reason);
193
429
  });
430
+ process.on('SIGTERM', () => {
431
+ _stopHeartbeat(); _stopNonOwnerPoll();
432
+ try { wsServer?.stop(); } catch {}
433
+ try { ownerHandle?.release(); } catch {}
434
+ process.exit(0);
435
+ });
436
+ process.on('SIGINT', () => {
437
+ _stopHeartbeat(); _stopNonOwnerPoll();
438
+ try { wsServer?.stop(); } catch {}
439
+ try { ownerHandle?.release(); } catch {}
440
+ process.exit(0);
441
+ });
194
442
 
195
443
  // --- main ---
196
444
 
@@ -200,6 +448,18 @@ async function main() {
200
448
 
201
449
  // Startup diagnostics — use the resolved active-profile env so users on
202
450
  // credentials.json (where process.env may not have LARK_*) get accurate flags.
451
+ // Bootstrap-only validation: when credentials.json doesn't exist and
452
+ // FEISHU_PLUGIN_PROFILE is set, validate the name against known legacy profiles.
453
+ // If credentials.json exists, FEISHU_PLUGIN_PROFILE is ignored — the file's
454
+ // `active` field is the SSOT and cross-process sync keeps it up to date.
455
+ if (!credentials.readCanonical() && process.env.FEISHU_PLUGIN_PROFILE) {
456
+ const known = credentials.listProfileNames();
457
+ if (!known.includes(currentProfile)) {
458
+ console.error(`[feishu-user-plugin] FATAL: FEISHU_PLUGIN_PROFILE="${currentProfile}" not found. Known: ${known.join(', ')}.`);
459
+ console.error('[feishu-user-plugin] Fix: edit harness env block, or add the profile to ~/.feishu-user-plugin/credentials.json.');
460
+ process.exit(2);
461
+ }
462
+ }
203
463
  let activeEnv = {};
204
464
  try { activeEnv = profileEnv(currentProfile); } catch (_) { /* unknown profile is reported below */ }
205
465
  const hasCanonical = !!credentials.readCanonical();
@@ -212,6 +472,15 @@ async function main() {
212
472
  if (!hasCookie) console.error('[feishu-user-plugin] WARNING: LARK_COOKIE not set — user identity tools (send_to_user, etc.) will fail');
213
473
  if (!hasApp) console.error('[feishu-user-plugin] WARNING: LARK_APP_ID/SECRET not set — official API tools (read_messages, docs, etc.) will fail');
214
474
  if (!hasUAT) console.error('[feishu-user-plugin] WARNING: LARK_USER_ACCESS_TOKEN not set — P2P chat reading (read_p2p_messages) will fail');
475
+ // Warn when both credentials.json AND legacy env vars exist — they may
476
+ // diverge silently after a UAT refresh (we always write credentials.json).
477
+ if (hasCanonical && (process.env.LARK_COOKIE || process.env.LARK_APP_ID || process.env.LARK_USER_ACCESS_TOKEN)) {
478
+ 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.');
479
+ }
480
+ // Nudge legacy env-only users to migrate.
481
+ if (!hasCanonical && (hasCookie || hasApp || hasUAT)) {
482
+ 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).');
483
+ }
215
484
 
216
485
  // Validate APP_ID/SECRET against Feishu before serving any tool calls.
217
486
  // Catches the "Claude filled in a wrong/stale APP_ID during install" failure
@@ -233,6 +502,19 @@ async function main() {
233
502
  console.error(`[feishu-user-plugin] WARNING: Could not verify APP_ID (${e.message}); network issue or cold start. Proceeding anyway.`);
234
503
  }
235
504
  }
505
+
506
+ // --- Real-time events (v1.3.9 — owner-arbitrated) ---
507
+ if (hasApp) {
508
+ _claimAndStart().catch((e) => {
509
+ console.error(`[feishu-user-plugin] owner claim failed: ${e.message}; will retry every 30s`);
510
+ if (!nonOwnerPollTimer) {
511
+ nonOwnerPollTimer = setInterval(_claimAndStart, events.owner.TAKEOVER_POLL_INTERVAL_MS);
512
+ nonOwnerPollTimer.unref?.();
513
+ }
514
+ });
515
+ } else {
516
+ console.error('[feishu-user-plugin] WS not started — APP_ID/SECRET missing. Realtime events (get_new_events) will return empty.');
517
+ }
236
518
  }
237
519
 
238
520
  module.exports = { main, TOOLS, HANDLERS };