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
package/src/cli.js
CHANGED
|
@@ -67,6 +67,21 @@ Setup options:
|
|
|
67
67
|
--app-secret <s> App Secret (non-interactive mode)
|
|
68
68
|
--cookie <c> Cookie string (optional)
|
|
69
69
|
--client <target> Config target: claude (default), codex, or both
|
|
70
|
+
--force Overwrite existing default profile in credentials.json
|
|
71
|
+
--profile <name> Create or update a named profile (replaces LARK_PROFILES_JSON
|
|
72
|
+
for new setups). Without --activate, leaves the active
|
|
73
|
+
profile unchanged so adding work2 doesn't yank you off default.
|
|
74
|
+
--activate When used with --profile, also flip credentials.json::active
|
|
75
|
+
to the named profile.
|
|
76
|
+
|
|
77
|
+
OAuth options (v1.3.9):
|
|
78
|
+
npx feishu-user-plugin oauth --profile <name>
|
|
79
|
+
Get UAT for a specific profile. Default = currently active.
|
|
80
|
+
|
|
81
|
+
Keepalive options (v1.3.9):
|
|
82
|
+
npx feishu-user-plugin keepalive --all
|
|
83
|
+
Refresh cookie + UAT for ALL profiles in credentials.json.
|
|
84
|
+
Default (no flag) = active profile only (back-compat).
|
|
70
85
|
|
|
71
86
|
Quick Start (Claude Code):
|
|
72
87
|
1. npx feishu-user-plugin setup
|
|
@@ -78,9 +93,16 @@ Quick Start (Codex):
|
|
|
78
93
|
2. Follow the prompts to configure credentials
|
|
79
94
|
3. Restart Codex
|
|
80
95
|
|
|
96
|
+
Multi-account (v1.3.9):
|
|
97
|
+
1. npx feishu-user-plugin setup --app-id X1 --app-secret S1 --cookie C1
|
|
98
|
+
2. npx feishu-user-plugin oauth # default profile UAT
|
|
99
|
+
3. npx feishu-user-plugin setup --profile work2 --app-id X2 --app-secret S2 --cookie C2
|
|
100
|
+
4. npx feishu-user-plugin oauth --profile work2 # work2 profile UAT
|
|
101
|
+
5. In Claude Code: switch_profile(name="work2") MCP tool to flip live
|
|
102
|
+
|
|
81
103
|
Auto-renewal (optional):
|
|
82
104
|
Add to crontab to keep tokens alive even when Claude Code is closed:
|
|
83
|
-
crontab -e → add: 0 */4 * * * npx feishu-user-plugin keepalive >> /tmp/feishu-keepalive.log 2>&1
|
|
105
|
+
crontab -e → add: 0 */4 * * * npx feishu-user-plugin keepalive --all >> /tmp/feishu-keepalive.log 2>&1
|
|
84
106
|
`);
|
|
85
107
|
}
|
|
86
108
|
|
|
@@ -94,53 +116,75 @@ function migrate() {
|
|
|
94
116
|
async function keepalive() {
|
|
95
117
|
const { LarkUserClient } = require('./clients/user');
|
|
96
118
|
const { LarkOfficialClient } = require('./clients/official');
|
|
97
|
-
const
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
119
|
+
const cred = require('./auth/credentials');
|
|
120
|
+
|
|
121
|
+
// v1.3.9: --all flag iterates every profile in credentials.json,
|
|
122
|
+
// refreshing cookie + UAT for each. Default behavior (no flag) refreshes
|
|
123
|
+
// only the active profile (back-compat with v1.3.6+ cron usage).
|
|
124
|
+
const all = process.argv.includes('--all');
|
|
125
|
+
const targetProfiles = all ? cred.listProfileNames() : [cred.getActiveProfileName() || 'default'];
|
|
126
|
+
|
|
127
|
+
let totalOk = true;
|
|
128
|
+
for (const profileName of targetProfiles) {
|
|
129
|
+
let env;
|
|
130
|
+
try { env = cred.getActiveProfileEnv(profileName); }
|
|
131
|
+
catch (e) {
|
|
132
|
+
console.error(`[keepalive][${profileName}] cannot read profile: ${e.message}`);
|
|
133
|
+
totalOk = false;
|
|
134
|
+
continue;
|
|
135
|
+
}
|
|
136
|
+
if (!env.LARK_COOKIE && !env.LARK_APP_ID) {
|
|
137
|
+
console.error(`[keepalive][${profileName}] no credentials. Run: npx feishu-user-plugin setup --profile ${profileName} ...`);
|
|
138
|
+
totalOk = false;
|
|
139
|
+
continue;
|
|
140
|
+
}
|
|
141
|
+
let ok = true;
|
|
105
142
|
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
ok = false;
|
|
143
|
+
// 1. Refresh Cookie
|
|
144
|
+
if (env.LARK_COOKIE && env.LARK_COOKIE !== 'SETUP_NEEDED') {
|
|
145
|
+
try {
|
|
146
|
+
const client = new LarkUserClient(env.LARK_COOKIE);
|
|
147
|
+
await client.init();
|
|
148
|
+
cred.persistProfileUpdate(profileName, { LARK_COOKIE: client.cookieStr });
|
|
149
|
+
console.log(`[keepalive][${profileName}] cookie refreshed (user: ${client.userName})`);
|
|
150
|
+
} catch (e) {
|
|
151
|
+
console.error(`[keepalive][${profileName}] cookie refresh FAILED: ${e.message}`);
|
|
152
|
+
ok = false;
|
|
153
|
+
}
|
|
118
154
|
}
|
|
119
|
-
}
|
|
120
155
|
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
156
|
+
// 2. Refresh UAT (also writes to the same profile via auth/uat.js → persistToConfig
|
|
157
|
+
// which goes through the active profile path. For --all we need to switch
|
|
158
|
+
// active temporarily so the write lands on the right profile.)
|
|
159
|
+
if (env.LARK_APP_ID && env.LARK_APP_SECRET && env.LARK_USER_ACCESS_TOKEN && env.LARK_USER_ACCESS_TOKEN !== 'SETUP_NEEDED' && env.LARK_USER_REFRESH_TOKEN) {
|
|
160
|
+
const prevActive = cred.getActiveProfileName();
|
|
161
|
+
const needSwitch = all && prevActive !== profileName;
|
|
162
|
+
try {
|
|
163
|
+
if (needSwitch) cred.setActiveProfile(profileName);
|
|
164
|
+
// Set process.env so LarkOfficialClient.loadUAT() picks the right tokens
|
|
165
|
+
process.env.LARK_USER_ACCESS_TOKEN = env.LARK_USER_ACCESS_TOKEN;
|
|
166
|
+
process.env.LARK_USER_REFRESH_TOKEN = env.LARK_USER_REFRESH_TOKEN;
|
|
167
|
+
const official = new LarkOfficialClient(env.LARK_APP_ID, env.LARK_APP_SECRET);
|
|
168
|
+
official._uat = env.LARK_USER_ACCESS_TOKEN;
|
|
169
|
+
official._uatRefresh = env.LARK_USER_REFRESH_TOKEN;
|
|
170
|
+
official._uatExpires = 0; // force refresh
|
|
171
|
+
await official._refreshUAT();
|
|
172
|
+
console.log(`[keepalive][${profileName}] UAT refreshed`);
|
|
173
|
+
} catch (e) {
|
|
174
|
+
console.error(`[keepalive][${profileName}] UAT refresh FAILED: ${e.message}`);
|
|
175
|
+
ok = false;
|
|
176
|
+
} finally {
|
|
177
|
+
if (needSwitch) {
|
|
178
|
+
try { cred.setActiveProfile(prevActive); } catch (_) {}
|
|
179
|
+
}
|
|
180
|
+
}
|
|
137
181
|
}
|
|
182
|
+
if (!ok) totalOk = false;
|
|
138
183
|
}
|
|
139
184
|
|
|
140
|
-
if (
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
process.exit(ok ? 0 : 1);
|
|
185
|
+
if (totalOk) console.log(`[keepalive] all profiles refreshed (${targetProfiles.length} profile${targetProfiles.length === 1 ? '' : 's'})`);
|
|
186
|
+
else console.error(`[keepalive] one or more profiles failed`);
|
|
187
|
+
process.exit(totalOk ? 0 : 1);
|
|
144
188
|
}
|
|
145
189
|
|
|
146
190
|
async function checkStatus() {
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
const lark = require('@larksuiteoapi/node-sdk');
|
|
2
2
|
const { fetchWithTimeout } = require('../../utils');
|
|
3
3
|
const { stderrLogger } = require('../../logger');
|
|
4
|
+
const uatLifecycle = require('../../auth/uat');
|
|
4
5
|
|
|
5
6
|
class LarkOfficialClient {
|
|
6
7
|
constructor(appId, appSecret) {
|
|
@@ -74,254 +75,17 @@ class LarkOfficialClient {
|
|
|
74
75
|
}
|
|
75
76
|
}
|
|
76
77
|
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
}
|
|
88
|
-
|
|
89
|
-
_decodeTokenExpiry(token) {
|
|
90
|
-
try {
|
|
91
|
-
const payload = token?.split('.')?.[1];
|
|
92
|
-
if (!payload) return 0;
|
|
93
|
-
const data = JSON.parse(Buffer.from(payload, 'base64url').toString('utf8'));
|
|
94
|
-
return typeof data.exp === 'number' ? data.exp : 0;
|
|
95
|
-
} catch (_) {
|
|
96
|
-
return 0;
|
|
97
|
-
}
|
|
98
|
-
}
|
|
99
|
-
|
|
100
|
-
_adoptPersistedUATIfNewer() {
|
|
101
|
-
try {
|
|
102
|
-
// auth/credentials reads credentials.json first; falls back to legacy
|
|
103
|
-
// mcpServers. The peer-rotated UAT will land wherever persistUAT wrote,
|
|
104
|
-
// and we'll see it consistently.
|
|
105
|
-
const { readCredentials } = require('../../auth/credentials');
|
|
106
|
-
const creds = readCredentials();
|
|
107
|
-
const token = creds.LARK_USER_ACCESS_TOKEN;
|
|
108
|
-
const refresh = creds.LARK_USER_REFRESH_TOKEN;
|
|
109
|
-
if (!token && !refresh) return false;
|
|
110
|
-
|
|
111
|
-
const expires = parseInt(creds.LARK_UAT_EXPIRES || '0') || this._decodeTokenExpiry(token);
|
|
112
|
-
const changed = (token && token !== this._uat)
|
|
113
|
-
|| (refresh && refresh !== this._uatRefresh)
|
|
114
|
-
|| (expires && expires !== this._uatExpires);
|
|
115
|
-
if (!changed) return false;
|
|
116
|
-
|
|
117
|
-
if (token) this._uat = token;
|
|
118
|
-
if (refresh) this._uatRefresh = refresh;
|
|
119
|
-
this._uatExpires = expires || 0;
|
|
120
|
-
console.error('[feishu-user-plugin] UAT adopted latest persisted token before refresh');
|
|
121
|
-
return true;
|
|
122
|
-
} catch (e) {
|
|
123
|
-
console.error(`[feishu-user-plugin] UAT persisted-token check failed: ${e.message}`);
|
|
124
|
-
return false;
|
|
125
|
-
}
|
|
126
|
-
}
|
|
127
|
-
|
|
128
|
-
// Cross-process advisory lock for UAT refresh. Feishu rotates the refresh_token
|
|
129
|
-
// on every refresh (old one invalidated instantly). When multiple MCP server
|
|
130
|
-
// processes share the same persisted refresh_token and all wake up near expiry,
|
|
131
|
-
// they race: the first wins, the rest see `invalid_grant` and can't recover.
|
|
132
|
-
// This lock serialises refreshes across processes; inside the critical section
|
|
133
|
-
// we also re-read the persisted config so late arrivals adopt the winner's
|
|
134
|
-
// token instead of attempting a doomed refresh with the already-rotated one.
|
|
135
|
-
_uatLockPath() {
|
|
136
|
-
const path = require('path');
|
|
137
|
-
const os = require('os');
|
|
138
|
-
return path.join(os.homedir(), '.claude', 'feishu-uat-refresh.lock');
|
|
139
|
-
}
|
|
140
|
-
|
|
141
|
-
async _acquireRefreshLock(lockPath, { staleMs = 30000, pollMs = 200, timeoutMs = 20000 } = {}) {
|
|
142
|
-
const fs = require('fs');
|
|
143
|
-
const path = require('path');
|
|
144
|
-
try { fs.mkdirSync(path.dirname(lockPath), { recursive: true }); } catch (_) {}
|
|
145
|
-
const start = Date.now();
|
|
146
|
-
while (Date.now() - start < timeoutMs) {
|
|
147
|
-
try {
|
|
148
|
-
const fd = fs.openSync(lockPath, 'wx'); // O_CREAT | O_EXCL
|
|
149
|
-
fs.writeSync(fd, `${process.pid}\n${Date.now()}\n`);
|
|
150
|
-
fs.closeSync(fd);
|
|
151
|
-
return true;
|
|
152
|
-
} catch (e) {
|
|
153
|
-
if (e.code !== 'EEXIST') throw e;
|
|
154
|
-
try {
|
|
155
|
-
const stat = fs.statSync(lockPath);
|
|
156
|
-
if (Date.now() - stat.mtimeMs > staleMs) {
|
|
157
|
-
try { fs.unlinkSync(lockPath); } catch (_) {}
|
|
158
|
-
continue;
|
|
159
|
-
}
|
|
160
|
-
} catch (_) { /* lock vanished under us — retry */ }
|
|
161
|
-
await new Promise(r => setTimeout(r, pollMs));
|
|
162
|
-
}
|
|
163
|
-
}
|
|
164
|
-
return false;
|
|
165
|
-
}
|
|
166
|
-
|
|
167
|
-
_releaseRefreshLock(lockPath) {
|
|
168
|
-
try { require('fs').unlinkSync(lockPath); } catch (_) {}
|
|
169
|
-
}
|
|
170
|
-
|
|
171
|
-
async _refreshUAT() {
|
|
172
|
-
const lockPath = this._uatLockPath();
|
|
173
|
-
const acquired = await this._acquireRefreshLock(lockPath);
|
|
174
|
-
if (!acquired) {
|
|
175
|
-
console.error('[feishu-user-plugin] UAT refresh lock timed out; proceeding without mutual exclusion');
|
|
176
|
-
}
|
|
177
|
-
try {
|
|
178
|
-
// Re-check under lock: another process may have already refreshed and
|
|
179
|
-
// persisted a new token while we waited. If so, adopt and skip the refresh.
|
|
180
|
-
const now = Math.floor(Date.now() / 1000);
|
|
181
|
-
if (this._adoptPersistedUATIfNewer() && this._uatExpires > now + 300) {
|
|
182
|
-
return this._uat;
|
|
183
|
-
}
|
|
184
|
-
|
|
185
|
-
if (!this._uatRefresh) throw new Error('UAT expired and no refresh token. Run: npx feishu-user-plugin oauth');
|
|
186
|
-
|
|
187
|
-
const res = await fetchWithTimeout('https://open.feishu.cn/open-apis/authen/v2/oauth/token', {
|
|
188
|
-
method: 'POST',
|
|
189
|
-
headers: { 'content-type': 'application/json' },
|
|
190
|
-
body: JSON.stringify({
|
|
191
|
-
grant_type: 'refresh_token',
|
|
192
|
-
client_id: this.appId,
|
|
193
|
-
client_secret: this.appSecret,
|
|
194
|
-
refresh_token: this._uatRefresh,
|
|
195
|
-
}),
|
|
196
|
-
});
|
|
197
|
-
const data = await res.json();
|
|
198
|
-
const tokenData = data.access_token ? data : data.data;
|
|
199
|
-
if (!tokenData?.access_token) throw new Error(`UAT refresh failed: ${JSON.stringify(data)}. Run: npx feishu-user-plugin oauth`);
|
|
200
|
-
|
|
201
|
-
this._uat = tokenData.access_token;
|
|
202
|
-
this._uatRefresh = tokenData.refresh_token || this._uatRefresh;
|
|
203
|
-
const expiresIn = typeof tokenData.expires_in === 'number' && tokenData.expires_in > 0 ? tokenData.expires_in : 7200;
|
|
204
|
-
this._uatExpires = Math.floor(Date.now() / 1000) + expiresIn;
|
|
205
|
-
this._persistUAT();
|
|
206
|
-
console.error('[feishu-user-plugin] UAT refreshed successfully');
|
|
207
|
-
return this._uat;
|
|
208
|
-
} finally {
|
|
209
|
-
if (acquired) this._releaseRefreshLock(lockPath);
|
|
210
|
-
}
|
|
211
|
-
}
|
|
212
|
-
|
|
213
|
-
_persistUAT() {
|
|
214
|
-
// Lazy require to avoid circular dependency at module load time.
|
|
215
|
-
// auth/credentials writes to credentials.json when it exists, otherwise
|
|
216
|
-
// falls back to legacy mcpServers persistence — same call site, two
|
|
217
|
-
// outcomes, same end result for callers.
|
|
218
|
-
const { persistToConfig } = require('../../auth/credentials');
|
|
219
|
-
persistToConfig({
|
|
220
|
-
LARK_USER_ACCESS_TOKEN: this._uat,
|
|
221
|
-
LARK_USER_REFRESH_TOKEN: this._uatRefresh,
|
|
222
|
-
LARK_UAT_EXPIRES: String(this._uatExpires),
|
|
223
|
-
});
|
|
224
|
-
}
|
|
225
|
-
|
|
226
|
-
// --- UAT-based IM operations (for P2P chats) ---
|
|
227
|
-
|
|
228
|
-
// Wrapper: call fn with UAT, retry once after refresh if auth fails
|
|
229
|
-
async _withUAT(fn) {
|
|
230
|
-
let uat = await this._getValidUAT();
|
|
231
|
-
const data = await fn(uat);
|
|
232
|
-
// Known auth error codes: 99991668 (invalid), 99991663 (expired), 99991677 (auth_expired)
|
|
233
|
-
if (data.code === 99991668 || data.code === 99991663 || data.code === 99991677) {
|
|
234
|
-
// 99991668 is overloaded: "invalid token" (→ refresh helps) vs
|
|
235
|
-
// "endpoint doesn't support UAT at all" (→ refresh is pointless, and
|
|
236
|
-
// worse, it consumes a one-shot refresh_token rotation). The second
|
|
237
|
-
// case is identifiable by the msg "user access token not support" or
|
|
238
|
-
// "not support". If so, surface the code to the caller without refresh.
|
|
239
|
-
if (data.code === 99991668 && typeof data.msg === 'string' && /not support/i.test(data.msg)) {
|
|
240
|
-
return data;
|
|
241
|
-
}
|
|
242
|
-
// Token invalid/expired — try refresh once
|
|
243
|
-
uat = await this._refreshUAT();
|
|
244
|
-
return fn(uat);
|
|
245
|
-
}
|
|
246
|
-
return data;
|
|
247
|
-
}
|
|
248
|
-
|
|
249
|
-
// Generic UAT REST helper. Returns parsed JSON ({code, msg, data}).
|
|
250
|
-
// Array query values are expanded to repeated keys (period_ids=a&period_ids=b)
|
|
251
|
-
// because several Feishu endpoints (OKR, calendar) rely on that convention.
|
|
252
|
-
async _uatREST(method, path, { body, query } = {}) {
|
|
253
|
-
let qs = '';
|
|
254
|
-
if (query) {
|
|
255
|
-
const sp = new URLSearchParams();
|
|
256
|
-
for (const [k, v] of Object.entries(query)) {
|
|
257
|
-
if (v === undefined || v === null) continue;
|
|
258
|
-
if (Array.isArray(v)) { for (const item of v) sp.append(k, String(item)); }
|
|
259
|
-
else sp.append(k, String(v));
|
|
260
|
-
}
|
|
261
|
-
const str = sp.toString();
|
|
262
|
-
if (str) qs = '?' + str;
|
|
263
|
-
}
|
|
264
|
-
const url = 'https://open.feishu.cn' + path + qs;
|
|
265
|
-
return this._withUAT(async (uat) => {
|
|
266
|
-
const headers = { 'Authorization': `Bearer ${uat}` };
|
|
267
|
-
const init = { method, headers };
|
|
268
|
-
if (body !== undefined) {
|
|
269
|
-
headers['content-type'] = 'application/json';
|
|
270
|
-
init.body = JSON.stringify(body);
|
|
271
|
-
}
|
|
272
|
-
const res = await fetchWithTimeout(url, init);
|
|
273
|
-
return res.json();
|
|
274
|
-
});
|
|
275
|
-
}
|
|
276
|
-
|
|
277
|
-
// Try UAT first (for resources likely owned by the user), fall back to app SDK on failure.
|
|
278
|
-
// Returns SDK-shaped {code, msg, data, _viaUser}. _viaUser is true iff the UAT call succeeded;
|
|
279
|
-
// callers can surface this to distinguish "created by user" vs "created by app" for resources
|
|
280
|
-
// whose ownership matters (docs, bitables, folders).
|
|
281
|
-
//
|
|
282
|
-
// When BOTH paths fail (common for OKR/Calendar if neither UAT nor app has the scope),
|
|
283
|
-
// the final error includes the UAT-side reason too, so the user can tell whether they
|
|
284
|
-
// need a new OAuth (UAT missing scope) or a different app (app missing scope).
|
|
285
|
-
async _asUserOrApp({ uatPath, method = 'GET', body, query, sdkFn, label }) {
|
|
286
|
-
let uatSummary = null;
|
|
287
|
-
if (this.hasUAT) {
|
|
288
|
-
try {
|
|
289
|
-
const data = await this._uatREST(method, uatPath, { body, query });
|
|
290
|
-
if (data.code === 0) {
|
|
291
|
-
data._viaUser = true;
|
|
292
|
-
return data;
|
|
293
|
-
}
|
|
294
|
-
uatSummary = `as user: code=${data.code} msg=${data.msg}`;
|
|
295
|
-
console.error(`[feishu-user-plugin] ${label} ${uatSummary}, retrying as app`);
|
|
296
|
-
} catch (err) {
|
|
297
|
-
uatSummary = `as user: ${err.message}`;
|
|
298
|
-
console.error(`[feishu-user-plugin] ${label} as user threw (${err.message}), retrying as app`);
|
|
299
|
-
}
|
|
300
|
-
}
|
|
301
|
-
try {
|
|
302
|
-
const appData = await this._safeSDKCall(sdkFn, label);
|
|
303
|
-
if (appData && typeof appData === 'object') {
|
|
304
|
-
appData._viaUser = false;
|
|
305
|
-
// Attach a warning when we silently fell back to bot identity. This lets
|
|
306
|
-
// write handlers surface "⚠️ created as BOT, not you" so the user doesn't
|
|
307
|
-
// discover it days later when a teammate can read the "private" resource.
|
|
308
|
-
if (uatSummary) {
|
|
309
|
-
appData._fallbackWarning = `⚠️ UAT 不可用 (${uatSummary}),本次操作以 bot 身份执行。资源归属于共享 bot「Claude聊天助手」,不是你。恢复方法:运行 \`npx feishu-user-plugin oauth\` 后重启 Claude Code / Codex。`;
|
|
310
|
-
} else if (!this.hasUAT) {
|
|
311
|
-
appData._fallbackWarning = `⚠️ 未配置 UAT,本次操作以 bot 身份执行。资源归属于共享 bot「Claude聊天助手」,不是你。想让资源归你所有,先跑 \`npx feishu-user-plugin oauth\` 然后重启 Claude Code / Codex。`;
|
|
312
|
-
}
|
|
313
|
-
}
|
|
314
|
-
return appData;
|
|
315
|
-
} catch (appErr) {
|
|
316
|
-
if (uatSummary) {
|
|
317
|
-
const err = new Error(`${label} failed on both identities. ${uatSummary}. as app: ${appErr.message}`);
|
|
318
|
-
err.uatSummary = uatSummary;
|
|
319
|
-
err.appError = appErr;
|
|
320
|
-
throw err;
|
|
321
|
-
}
|
|
322
|
-
throw appErr;
|
|
323
|
-
}
|
|
324
|
-
}
|
|
78
|
+
// UAT lifecycle methods are extracted to src/auth/uat.js (v1.3.8 D.1).
|
|
79
|
+
// State (this._uat / this._uatRefresh / this._uatExpires) still lives here;
|
|
80
|
+
// function bodies live in auth/uat.js. These methods are 1-line delegates.
|
|
81
|
+
_decodeTokenExpiry(token) { return uatLifecycle.decodeTokenExpiry(token); }
|
|
82
|
+
async _getValidUAT() { return uatLifecycle.getValidUAT(this); }
|
|
83
|
+
_adoptPersistedUATIfNewer() { return uatLifecycle.adoptPersistedUATIfNewer(this); }
|
|
84
|
+
async _refreshUAT() { return uatLifecycle.refreshUAT(this); }
|
|
85
|
+
_persistUAT() { return uatLifecycle.persistUAT(this); }
|
|
86
|
+
async _withUAT(fn) { return uatLifecycle.withUAT(this, fn); }
|
|
87
|
+
async _uatREST(method, path, opts) { return uatLifecycle.uatREST(this, method, path, opts); }
|
|
88
|
+
async _asUserOrApp(opts) { return uatLifecycle.asUserOrApp(this, opts); }
|
|
325
89
|
|
|
326
90
|
// --- Safe SDK Call (extracts real Feishu error from AxiosError) ---
|
|
327
91
|
|
package/src/clients/user.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
const path = require('path');
|
|
2
2
|
const protobuf = require('protobufjs');
|
|
3
3
|
const { generateRequestId, generateCid, parseCookie, formatCookie, fetchWithTimeout } = require('../utils');
|
|
4
|
+
const cookieHeartbeat = require('../auth/cookie');
|
|
4
5
|
|
|
5
6
|
const GATEWAY_URL = 'https://internal-api-lark-api.feishu.cn/im/gateway/';
|
|
6
7
|
const CSRF_URL = 'https://internal-api-lark-api.feishu.cn/accounts/csrf';
|
|
@@ -86,26 +87,9 @@ class LarkUserClient {
|
|
|
86
87
|
}
|
|
87
88
|
|
|
88
89
|
// --- Cookie Heartbeat ---
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
// Feishu sl_session has 12h max-age; CSRF refresh also refreshes sl_session
|
|
93
|
-
this._heartbeatTimer = setInterval(async () => {
|
|
94
|
-
try {
|
|
95
|
-
await this._getCsrfToken();
|
|
96
|
-
// Lazy require to avoid circular dependency at module load time.
|
|
97
|
-
// auth/credentials writes to credentials.json (single source of truth)
|
|
98
|
-
// when it exists; falls back to legacy mcpServers persistence otherwise.
|
|
99
|
-
const { persistToConfig } = require('../auth/credentials');
|
|
100
|
-
persistToConfig({ LARK_COOKIE: this.cookieStr });
|
|
101
|
-
console.error('[feishu-user-plugin] Cookie heartbeat: session refreshed and persisted');
|
|
102
|
-
} catch (e) {
|
|
103
|
-
console.error('[feishu-user-plugin] Cookie heartbeat failed:', e.message);
|
|
104
|
-
}
|
|
105
|
-
}, 4 * 60 * 60 * 1000); // 4 hours
|
|
106
|
-
// Don't keep the process alive just for heartbeat
|
|
107
|
-
if (this._heartbeatTimer.unref) this._heartbeatTimer.unref();
|
|
108
|
-
}
|
|
90
|
+
// Body extracted to src/auth/cookie.js (v1.3.8 D.2). Timer state stays on
|
|
91
|
+
// this instance; auth/cookie.js mutates this._heartbeatTimer.
|
|
92
|
+
_startHeartbeat() { cookieHeartbeat.startHeartbeat(this); }
|
|
109
93
|
|
|
110
94
|
async checkSession() {
|
|
111
95
|
try {
|
|
@@ -201,16 +185,6 @@ class LarkUserClient {
|
|
|
201
185
|
if (parentId) req.parentId = parentId;
|
|
202
186
|
const { packet, ok } = await this._gateway(5, 'PutMessageRequest', req, '5.7.0');
|
|
203
187
|
if (!ok) {
|
|
204
|
-
// The cookie protobuf gateway returns HTTP 400 when our wire format is
|
|
205
|
-
// missing required fields. Verified for IMAGE (v1.3.7 testing): the
|
|
206
|
-
// simple {imageKey} content payload is rejected — Feishu Web encodes
|
|
207
|
-
// images with extra metadata (image dimensions, mime type, etc.) that
|
|
208
|
-
// we don't have in proto/lark.proto. Reverse-engineering requires Chrome
|
|
209
|
-
// DevTools capture and is deferred to v1.3.8. Surface a clear error
|
|
210
|
-
// routing the user to send_message_as_bot, which works.
|
|
211
|
-
if (type === MsgType.IMAGE) {
|
|
212
|
-
throw new Error('send_image_as_user: Feishu cookie protobuf gateway rejected the IMAGE wire format (HTTP 400). User-identity image sends are not yet supported — wire format reverse-engineering is deferred to v1.3.8. Workaround: use send_message_as_bot(chat_id, msg_type="image", payload={image_key:"..."}).');
|
|
213
|
-
}
|
|
214
188
|
throw new Error(`_sendMsg: cookie protobuf gateway returned non-2xx for type=${type}. The wire format likely doesn't match what Feishu expects.`);
|
|
215
189
|
}
|
|
216
190
|
return { success: true, status: packet.status };
|
|
@@ -282,9 +256,23 @@ class LarkUserClient {
|
|
|
282
256
|
}
|
|
283
257
|
|
|
284
258
|
// --- Send Image ---
|
|
259
|
+
// v1.3.9: cookie protobuf path now works. Gateway requires Content.imageKey
|
|
260
|
+
// (field 2) + Content.thumbnailKey (field 10). Width/height/mime/size are
|
|
261
|
+
// optional but accepted. We default thumbnailKey to imageKey when caller
|
|
262
|
+
// doesn't supply one — Feishu accepts that for already-thumbnail-sized
|
|
263
|
+
// uploads. See proto/lark.proto Content + scripts/explore-image-minimize.js.
|
|
285
264
|
|
|
286
265
|
async sendImage(chatId, imageKey, opts = {}) {
|
|
287
|
-
|
|
266
|
+
if (!imageKey) throw new Error('sendImage: imageKey required');
|
|
267
|
+
const content = {
|
|
268
|
+
imageKey,
|
|
269
|
+
thumbnailKey: opts.thumbnailKey || imageKey,
|
|
270
|
+
};
|
|
271
|
+
if (opts.width != null) content.imageWidth = opts.width;
|
|
272
|
+
if (opts.height != null) content.imageHeight = opts.height;
|
|
273
|
+
if (opts.mime) content.mimeType = opts.mime;
|
|
274
|
+
if (opts.size != null) content.fileSize = opts.size;
|
|
275
|
+
return this._sendMsg(MsgType.IMAGE, chatId, content, opts);
|
|
288
276
|
}
|
|
289
277
|
|
|
290
278
|
// --- Send File ---
|
package/src/config.js
CHANGED
|
@@ -274,23 +274,23 @@ function persistToConfig(updates) {
|
|
|
274
274
|
* client: 'claude' (default) | 'codex' | 'both'
|
|
275
275
|
* @returns {{ configPath: string, codexConfigPath?: string }}
|
|
276
276
|
*/
|
|
277
|
-
function writeNewConfig(env, configPath, projectPath, client) {
|
|
277
|
+
function writeNewConfig(env, configPath, projectPath, client, options = {}) {
|
|
278
278
|
const results = {};
|
|
279
279
|
|
|
280
280
|
// --- Claude Code (JSON) ---
|
|
281
281
|
if (client !== 'codex') {
|
|
282
|
-
results.configPath = _writeClaudeConfig(env, configPath, projectPath);
|
|
282
|
+
results.configPath = _writeClaudeConfig(env, configPath, projectPath, options);
|
|
283
283
|
}
|
|
284
284
|
|
|
285
285
|
// --- Codex (TOML) ---
|
|
286
286
|
if (client === 'codex' || client === 'both') {
|
|
287
|
-
results.codexConfigPath = _writeCodexConfig(env);
|
|
287
|
+
results.codexConfigPath = _writeCodexConfig(env, options);
|
|
288
288
|
}
|
|
289
289
|
|
|
290
290
|
return results;
|
|
291
291
|
}
|
|
292
292
|
|
|
293
|
-
function _writeClaudeConfig(env, configPath, projectPath) {
|
|
293
|
+
function _writeClaudeConfig(env, configPath, projectPath, options = {}) {
|
|
294
294
|
if (!configPath) {
|
|
295
295
|
configPath = path.join(process.env.HOME || '', '.claude.json');
|
|
296
296
|
}
|
|
@@ -312,7 +312,9 @@ function _writeClaudeConfig(env, configPath, projectPath) {
|
|
|
312
312
|
const serverEntry = {
|
|
313
313
|
command: 'npx',
|
|
314
314
|
args: ['-y', 'feishu-user-plugin'],
|
|
315
|
-
env
|
|
315
|
+
env: options.pointerOnly
|
|
316
|
+
? { FEISHU_PLUGIN_PROFILE: env.FEISHU_PLUGIN_PROFILE || 'default' }
|
|
317
|
+
: env,
|
|
316
318
|
};
|
|
317
319
|
|
|
318
320
|
if (projectPath && config.projects?.[projectPath]) {
|
|
@@ -334,7 +336,7 @@ function _writeClaudeConfig(env, configPath, projectPath) {
|
|
|
334
336
|
return configPath;
|
|
335
337
|
}
|
|
336
338
|
|
|
337
|
-
function _writeCodexConfig(env) {
|
|
339
|
+
function _writeCodexConfig(env, options = {}) {
|
|
338
340
|
const home = process.env.HOME || '';
|
|
339
341
|
const codexDir = path.join(home, '.codex');
|
|
340
342
|
const configPath = path.join(codexDir, 'config.toml');
|
|
@@ -352,8 +354,11 @@ function _writeCodexConfig(env) {
|
|
|
352
354
|
content = _removeTomlServer(content, name);
|
|
353
355
|
}
|
|
354
356
|
|
|
355
|
-
// Append new entry
|
|
356
|
-
|
|
357
|
+
// Append new entry — pointer-only writes only FEISHU_PLUGIN_PROFILE
|
|
358
|
+
const envToWrite = options.pointerOnly
|
|
359
|
+
? { FEISHU_PLUGIN_PROFILE: env.FEISHU_PLUGIN_PROFILE || 'default' }
|
|
360
|
+
: env;
|
|
361
|
+
content = content.trimEnd() + '\n\n' + _generateTomlServerEntry('feishu-user-plugin', envToWrite);
|
|
357
362
|
|
|
358
363
|
_atomicWrite(configPath, content);
|
|
359
364
|
console.error(`[feishu-user-plugin] Codex config written to ${configPath}`);
|