feishu-user-plugin 1.3.8 → 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.
- package/.claude-plugin/plugin.json +12 -2
- package/CHANGELOG.md +50 -12
- package/README.md +4 -4
- package/package.json +9 -5
- package/proto/lark.proto +10 -0
- package/scripts/explore-card-protobuf.js +144 -0
- package/scripts/explore-image-minimize.js +163 -0
- package/scripts/generate-release-artifacts.js +318 -0
- package/scripts/probe-feishu-docx.js +203 -0
- package/scripts/sync-team-skills.sh +109 -7
- package/skills/feishu-user-plugin/SKILL.md +76 -4
- package/skills/feishu-user-plugin/references/CLAUDE.md +74 -54
- package/src/auth/credentials.js +36 -0
- package/src/cli.js +86 -45
- package/src/clients/user.js +15 -13
- package/src/events/cursor.js +103 -0
- package/src/events/event-buffer.js +8 -5
- package/src/events/event-log.js +151 -0
- package/src/events/index.js +8 -1
- package/src/events/lockfile.js +126 -0
- package/src/events/owner.js +73 -0
- package/src/events/ws-server.js +95 -25
- package/src/oauth.js +48 -7
- package/src/resolver.js +10 -0
- package/src/server.js +248 -29
- package/src/setup.js +99 -25
- package/src/test-all.js +12 -9
- package/src/test-events-cursor.js +56 -0
- package/src/test-events-lockfile.js +36 -0
- package/src/test-events-log.js +67 -0
- package/src/test-events-owner.js +64 -0
- package/src/test-fixtures/doc-blocks/sample-1.json +1256 -0
- package/src/test-read-doc-markdown.js +61 -0
- package/src/test-switch-profile.js +171 -0
- package/src/tools/diagnostics.js +10 -3
- package/src/tools/docs.js +93 -3
- package/src/tools/events.js +143 -33
- package/src/tools/messaging-bot.js +2 -3
- package/src/tools/messaging-user.js +23 -14
- package/src/tools/profile.js +12 -7
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
|
|
17
|
+
const credentialsModule = require('./auth/credentials');
|
|
18
|
+
const legacyConfig = require('./config');
|
|
18
19
|
|
|
19
|
-
|
|
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
|
-
|
|
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
|
|
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');
|
|
@@ -65,9 +67,28 @@ const HANDLERS = Object.fromEntries(TOOL_MODULES.flatMap((m) => Object.entries(m
|
|
|
65
67
|
// When credentials.json exists, switching also persists the active field so
|
|
66
68
|
// cross-process MCP servers see the same active profile after restart.
|
|
67
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
|
+
|
|
68
75
|
let userClient = null;
|
|
69
76
|
let officialClient = null;
|
|
70
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
|
+
|
|
71
92
|
function getEventBuffer() {
|
|
72
93
|
return wsServer ? wsServer.buffer : null;
|
|
73
94
|
}
|
|
@@ -75,12 +96,19 @@ function getEventBuffer() {
|
|
|
75
96
|
// from the persisted active profile (credentials.json) at boot, but in-process
|
|
76
97
|
// switches may diverge from the persisted active until the next server restart.
|
|
77
98
|
//
|
|
78
|
-
// Profile selection precedence (v1.3.
|
|
79
|
-
// 1.
|
|
80
|
-
// 2.
|
|
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)
|
|
81
103
|
// 3. 'default' — legacy zero-config path
|
|
82
|
-
|
|
83
|
-
|
|
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.
|
|
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
|
+
}
|
|
84
112
|
|
|
85
113
|
function profileEnv(name) {
|
|
86
114
|
return credentials.getActiveProfileEnv(name);
|
|
@@ -134,6 +162,157 @@ function loadUATFromEnv(client, env) {
|
|
|
134
162
|
client._uatExpires = expires || client._decodeTokenExpiry(token);
|
|
135
163
|
}
|
|
136
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
|
+
|
|
137
316
|
// Resolver helper: turn document_id / app_token / wiki node / Feishu URL into
|
|
138
317
|
// a native token. No-op for already-native inputs. See src/resolver.js.
|
|
139
318
|
async function resolveDocId(input) {
|
|
@@ -158,11 +337,45 @@ function buildCtx() {
|
|
|
158
337
|
currentProfile = n;
|
|
159
338
|
userClient = null;
|
|
160
339
|
officialClient = null;
|
|
340
|
+
// Clear resolver cache so wiki-node lookups don't carry over from the old profile.
|
|
341
|
+
require('./resolver').clearCache();
|
|
161
342
|
// Persist the active-field flip when credentials.json exists so peer MCP
|
|
162
|
-
// servers see the new active profile on next read.
|
|
163
|
-
|
|
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();
|
|
164
357
|
},
|
|
165
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
|
+
},
|
|
166
379
|
};
|
|
167
380
|
}
|
|
168
381
|
|
|
@@ -176,6 +389,9 @@ const server = new Server(
|
|
|
176
389
|
server.setRequestHandler(ListToolsRequestSchema, async () => ({ tools: TOOLS }));
|
|
177
390
|
|
|
178
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();
|
|
179
395
|
const { name, arguments: args } = request.params;
|
|
180
396
|
const handler = HANDLERS[name];
|
|
181
397
|
if (!handler) {
|
|
@@ -211,8 +427,18 @@ process.on('uncaughtException', (err) => {
|
|
|
211
427
|
process.on('unhandledRejection', (reason) => {
|
|
212
428
|
console.error('[feishu-user-plugin] Unhandled rejection:', reason);
|
|
213
429
|
});
|
|
214
|
-
process.on('SIGTERM', () => {
|
|
215
|
-
|
|
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
|
+
});
|
|
216
442
|
|
|
217
443
|
// --- main ---
|
|
218
444
|
|
|
@@ -222,9 +448,11 @@ async function main() {
|
|
|
222
448
|
|
|
223
449
|
// Startup diagnostics — use the resolved active-profile env so users on
|
|
224
450
|
// credentials.json (where process.env may not have LARK_*) get accurate flags.
|
|
225
|
-
//
|
|
226
|
-
//
|
|
227
|
-
|
|
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) {
|
|
228
456
|
const known = credentials.listProfileNames();
|
|
229
457
|
if (!known.includes(currentProfile)) {
|
|
230
458
|
console.error(`[feishu-user-plugin] FATAL: FEISHU_PLUGIN_PROFILE="${currentProfile}" not found. Known: ${known.join(', ')}.`);
|
|
@@ -275,24 +503,15 @@ async function main() {
|
|
|
275
503
|
}
|
|
276
504
|
}
|
|
277
505
|
|
|
278
|
-
// --- Real-time events (v1.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.
|
|
506
|
+
// --- Real-time events (v1.3.9 — owner-arbitrated) ---
|
|
281
507
|
if (hasApp) {
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
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
|
-
}
|
|
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
|
+
});
|
|
296
515
|
} else {
|
|
297
516
|
console.error('[feishu-user-plugin] WS not started — APP_ID/SECRET missing. Realtime events (get_new_events) will return empty.');
|
|
298
517
|
}
|
package/src/setup.js
CHANGED
|
@@ -10,9 +10,12 @@
|
|
|
10
10
|
*/
|
|
11
11
|
|
|
12
12
|
const readline = require('readline');
|
|
13
|
+
const fs = require('fs');
|
|
14
|
+
const os = require('os');
|
|
15
|
+
const path = require('path');
|
|
13
16
|
const { findMcpConfig, writeNewConfig } = require('./config');
|
|
14
17
|
|
|
15
|
-
// Parse CLI args: --app-id, --app-secret, --cookie, --client
|
|
18
|
+
// Parse CLI args: --app-id, --app-secret, --cookie, --client, --force, --profile
|
|
16
19
|
function parseArgs() {
|
|
17
20
|
const args = {};
|
|
18
21
|
const argv = process.argv.slice(2);
|
|
@@ -21,7 +24,10 @@ function parseArgs() {
|
|
|
21
24
|
else if (argv[i] === '--app-secret' && argv[i + 1]) args.appSecret = argv[++i];
|
|
22
25
|
else if (argv[i] === '--cookie' && argv[i + 1]) args.cookie = argv[++i];
|
|
23
26
|
else if (argv[i] === '--client' && argv[i + 1]) args.client = argv[++i];
|
|
24
|
-
else if (argv[i] === '--pointer-only') args.pointerOnly = true;
|
|
27
|
+
else if (argv[i] === '--pointer-only') args.pointerOnly = true; // kept for backward compat; now implicit default
|
|
28
|
+
else if (argv[i] === '--force') args.force = true;
|
|
29
|
+
else if (argv[i] === '--profile' && argv[i + 1]) args.profile = argv[++i];
|
|
30
|
+
else if (argv[i] === '--activate') args.activate = true;
|
|
25
31
|
}
|
|
26
32
|
return args;
|
|
27
33
|
}
|
|
@@ -151,34 +157,102 @@ async function main() {
|
|
|
151
157
|
}
|
|
152
158
|
if (!client) client = 'claude';
|
|
153
159
|
|
|
154
|
-
//
|
|
155
|
-
//
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
160
|
+
// --- 4-state SSOT matrix (v1.3.9 A.3) ---
|
|
161
|
+
// Determines how credentials.json is created/updated based on current state.
|
|
162
|
+
//
|
|
163
|
+
// State 1 (fresh): credentials.json absent, no harness LARK_* env → create fresh
|
|
164
|
+
// State 2 (auto-migrate): credentials.json absent, harness LARK_* env exists → migrate
|
|
165
|
+
// State 3 (preserve): credentials.json present, no --app-id → touch nothing in file
|
|
166
|
+
// State 4 (update): credentials.json present, --app-id given → update/add profile
|
|
167
|
+
const credentials = require('./auth/credentials');
|
|
168
|
+
const credsPath = path.join(os.homedir(), '.feishu-user-plugin', 'credentials.json');
|
|
169
|
+
const credsExist = !!credentials.readCanonical();
|
|
170
|
+
const targetProfile = cliArgs.profile || 'default';
|
|
171
|
+
const harnessHasLark = !!(existingEnv.LARK_APP_ID || existingEnv.LARK_COOKIE || existingEnv.LARK_USER_ACCESS_TOKEN);
|
|
166
172
|
|
|
167
|
-
|
|
168
|
-
|
|
173
|
+
let mode;
|
|
174
|
+
if (!credsExist && !harnessHasLark) mode = 'fresh';
|
|
175
|
+
else if (!credsExist && harnessHasLark) mode = 'auto-migrate';
|
|
176
|
+
else if (credsExist && !cliArgs.appId) mode = 'preserve';
|
|
177
|
+
else mode = 'update';
|
|
178
|
+
console.log(`\nSetup mode: ${mode}`);
|
|
169
179
|
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
180
|
+
if (mode === 'fresh' || mode === 'update') {
|
|
181
|
+
// Gather profile values — only include keys that have real values.
|
|
182
|
+
const profileValues = {};
|
|
183
|
+
if (appId) profileValues.LARK_APP_ID = appId;
|
|
184
|
+
if (appSecret) profileValues.LARK_APP_SECRET = appSecret;
|
|
185
|
+
if (cookie && cookie !== 'SETUP_NEEDED') profileValues.LARK_COOKIE = cookie;
|
|
186
|
+
else if (existingEnv.LARK_COOKIE && existingEnv.LARK_COOKIE !== 'SETUP_NEEDED') profileValues.LARK_COOKIE = existingEnv.LARK_COOKIE;
|
|
187
|
+
if (existingEnv.LARK_USER_ACCESS_TOKEN && existingEnv.LARK_USER_ACCESS_TOKEN !== 'SETUP_NEEDED') profileValues.LARK_USER_ACCESS_TOKEN = existingEnv.LARK_USER_ACCESS_TOKEN;
|
|
188
|
+
if (existingEnv.LARK_USER_REFRESH_TOKEN) profileValues.LARK_USER_REFRESH_TOKEN = existingEnv.LARK_USER_REFRESH_TOKEN;
|
|
177
189
|
|
|
178
|
-
|
|
190
|
+
if (mode === 'fresh') {
|
|
191
|
+
fs.mkdirSync(path.dirname(credsPath), { recursive: true, mode: 0o700 });
|
|
192
|
+
const data = { version: 1, active: targetProfile, profiles: { [targetProfile]: profileValues }, profileHints: {} };
|
|
193
|
+
fs.writeFileSync(credsPath, JSON.stringify(data, null, 2) + '\n', { mode: 0o600 });
|
|
194
|
+
console.log(`Created ${credsPath} with profile "${targetProfile}"`);
|
|
195
|
+
} else {
|
|
196
|
+
// mode === 'update'
|
|
197
|
+
const canonical = credentials.readCanonical();
|
|
198
|
+
const profileExists = !!(canonical && canonical.profiles[targetProfile]);
|
|
199
|
+
if (profileExists && targetProfile === 'default' && !cliArgs.force && !cliArgs.profile) {
|
|
200
|
+
console.error(`Profile "default" already exists. Pass --force to overwrite, or --profile <name> to create a new profile.`);
|
|
201
|
+
rl.close();
|
|
202
|
+
process.exit(1);
|
|
203
|
+
}
|
|
204
|
+
if (profileExists && cliArgs.profile && !cliArgs.force) {
|
|
205
|
+
console.error(`Profile "${targetProfile}" already exists. Pass --force to overwrite, or pick a different --profile name.`);
|
|
206
|
+
rl.close();
|
|
207
|
+
process.exit(1);
|
|
208
|
+
}
|
|
209
|
+
canonical.profiles[targetProfile] = { ...(canonical.profiles[targetProfile] || {}), ...profileValues };
|
|
210
|
+
// v1.3.9 fix: only flip credentials.json::active when --activate is given.
|
|
211
|
+
// Without --activate, adding/updating a non-active profile leaves the
|
|
212
|
+
// current active alone (least-surprise: "I added work2, default is still
|
|
213
|
+
// active, I'll switch when I want via MCP switch_profile").
|
|
214
|
+
if (cliArgs.activate || (cliArgs.force && targetProfile === canonical.active)) {
|
|
215
|
+
canonical.active = targetProfile;
|
|
216
|
+
}
|
|
217
|
+
fs.writeFileSync(credsPath, JSON.stringify(canonical, null, 2) + '\n', { mode: 0o600 });
|
|
218
|
+
console.log(`Updated profile "${targetProfile}" in ${credsPath}`);
|
|
219
|
+
if (cliArgs.activate) console.log(` → active profile flipped to "${targetProfile}"`);
|
|
220
|
+
else if (canonical.active !== targetProfile) {
|
|
221
|
+
console.log(` → active profile unchanged ("${canonical.active}"). Pass --activate to flip, or use switch_profile MCP tool at runtime.`);
|
|
222
|
+
}
|
|
223
|
+
if (cliArgs.force) console.warn(` warning: overwrote existing profile credentials with --force`);
|
|
224
|
+
}
|
|
225
|
+
} else if (mode === 'auto-migrate') {
|
|
226
|
+
// Run migrate to consolidate harness env → credentials.json, then optionally
|
|
227
|
+
// override the default profile with any explicitly provided --app-id.
|
|
228
|
+
const result = credentials.migrate({ dryRun: false });
|
|
229
|
+
if (!result.ok) {
|
|
230
|
+
console.error('Auto-migrate failed; aborting setup.');
|
|
231
|
+
rl.close();
|
|
232
|
+
process.exit(1);
|
|
233
|
+
}
|
|
234
|
+
if (cliArgs.appId) {
|
|
235
|
+
credentials.persistProfileUpdate('default', { LARK_APP_ID: appId, LARK_APP_SECRET: appSecret });
|
|
236
|
+
console.log('Updated default profile with new app credentials.');
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
// mode === 'preserve': credentials.json is unchanged; we only update the harness pointer.
|
|
240
|
+
|
|
241
|
+
// --- Write harness config ---
|
|
242
|
+
// Always write pointer-only env to harness configs (v1.3.9 SSOT).
|
|
243
|
+
// The harness env block only needs FEISHU_PLUGIN_PROFILE; all real creds
|
|
244
|
+
// live in credentials.json.
|
|
245
|
+
console.log('\n--- Writing Config ---');
|
|
246
|
+
// v1.3.9 fix: harness env pointer should reflect what credentials.json::active
|
|
247
|
+
// will end up as, not blindly the targetProfile (which would mislead users
|
|
248
|
+
// who added a non-active profile via --profile alt without --activate).
|
|
249
|
+
const finalCanonical = credentials.readCanonical();
|
|
250
|
+
const harnessActive = finalCanonical?.active || targetProfile;
|
|
251
|
+
const pointerEnv = { FEISHU_PLUGIN_PROFILE: harnessActive };
|
|
252
|
+
const result = writeNewConfig(pointerEnv, undefined, undefined, client, { pointerOnly: true });
|
|
179
253
|
if (result.configPath) console.log(`Written to ${result.configPath} (Claude Code)`);
|
|
180
254
|
if (result.codexConfigPath) console.log(`Written to ${result.codexConfigPath} (Codex)`);
|
|
181
|
-
|
|
255
|
+
console.log(`Mode: pointer-only (env block contains only FEISHU_PLUGIN_PROFILE=${targetProfile})`);
|
|
182
256
|
|
|
183
257
|
// Summary
|
|
184
258
|
console.log('\n' + '='.repeat(60));
|
package/src/test-all.js
CHANGED
|
@@ -266,14 +266,6 @@ async function main() {
|
|
|
266
266
|
// 31. create_folder (skip)
|
|
267
267
|
log('create_folder', 'SKIP', 'skipped to avoid creating unnecessary folders');
|
|
268
268
|
|
|
269
|
-
// 32. find_user
|
|
270
|
-
try {
|
|
271
|
-
const res = await officialClient.findUserByIdentity({ emails: 'test@test.com' });
|
|
272
|
-
log('find_user', 'PASS', `returned ${(res.userList || []).length} users (expected 0 for test email)`);
|
|
273
|
-
} catch (e) {
|
|
274
|
-
log('find_user', 'FAIL', e.message);
|
|
275
|
-
}
|
|
276
|
-
|
|
277
269
|
// ========== UAT Tests ==========
|
|
278
270
|
|
|
279
271
|
if (officialClient.hasUAT) {
|
|
@@ -321,4 +313,15 @@ async function main() {
|
|
|
321
313
|
}
|
|
322
314
|
}
|
|
323
315
|
|
|
324
|
-
main().catch(console.error)
|
|
316
|
+
main().catch(console.error).finally(() => {
|
|
317
|
+
// Fixture-based unit test — runs regardless of credential availability
|
|
318
|
+
require('./test-read-doc-markdown').run();
|
|
319
|
+
require('./test-switch-profile').run().catch(e => {
|
|
320
|
+
console.error('switch-profile-e2e: FAIL', e);
|
|
321
|
+
process.exitCode = 1;
|
|
322
|
+
});
|
|
323
|
+
require('./test-events-lockfile').run();
|
|
324
|
+
require('./test-events-log').run();
|
|
325
|
+
require('./test-events-cursor').run();
|
|
326
|
+
require('./test-events-owner').run();
|
|
327
|
+
});
|