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.
- package/.claude-plugin/plugin.json +13 -3
- package/CHANGELOG.md +87 -0
- package/README.md +20 -4
- package/package.json +10 -6
- package/proto/lark.proto +10 -0
- 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/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-server-json.js +71 -0
- package/scripts/sync-team-skills.sh +109 -7
- package/scripts/test-wiki-attach-fallback.js +71 -0
- package/scripts/test-ws-events.js +84 -0
- package/skills/feishu-user-plugin/SKILL.md +77 -5
- package/skills/feishu-user-plugin/references/CLAUDE.md +208 -297
- package/src/auth/cookie.js +30 -0
- package/src/auth/credentials.js +85 -0
- package/src/auth/profile-router.js +248 -0
- package/src/auth/uat.js +231 -0
- package/src/cli.js +86 -42
- package/src/clients/official/base.js +12 -248
- package/src/clients/user.js +19 -31
- package/src/config.js +13 -8
- package/src/events/cursor.js +103 -0
- package/src/events/event-buffer.js +103 -0
- package/src/events/event-log.js +151 -0
- package/src/events/index.js +12 -0
- package/src/events/lockfile.js +126 -0
- package/src/events/owner.js +73 -0
- package/src/events/ws-server.js +156 -0
- package/src/oauth.js +48 -7
- package/src/resolver.js +10 -0
- package/src/server.js +285 -3
- package/src/setup.js +100 -11
- 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/_registry.js +1 -0
- package/src/tools/diagnostics.js +10 -3
- package/src/tools/docs.js +93 -3
- package/src/tools/events.js +174 -0
- package/src/tools/messaging-bot.js +2 -3
- package/src/tools/messaging-user.js +23 -14
- 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
|
|
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');
|
|
@@ -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.
|
|
150
|
-
|
|
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
|
|
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 };
|