feishu-user-plugin 1.3.6 → 1.3.8
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/plugin.json +2 -2
- package/CHANGELOG.md +71 -0
- package/README.md +72 -41
- package/package.json +10 -3
- 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 +40 -0
- package/scripts/check-version.js +40 -0
- package/scripts/decode-feishu-protobuf.js +115 -0
- package/scripts/smoke.js +224 -0
- package/scripts/sync-claude-md.sh +12 -0
- package/scripts/sync-server-json.js +71 -0
- package/scripts/sync-team-skills.sh +22 -0
- package/scripts/test-all-tools.js +158 -0
- package/scripts/test-wiki-attach-fallback.js +71 -0
- package/scripts/test-ws-events.js +84 -0
- package/skills/feishu-user-plugin/SKILL.md +5 -5
- package/skills/feishu-user-plugin/references/CLAUDE.md +248 -318
- package/skills/feishu-user-plugin/references/table.md +18 -9
- package/src/auth/cookie.js +30 -0
- package/src/auth/credentials.js +399 -0
- package/src/auth/profile-router.js +248 -0
- package/src/auth/uat.js +231 -0
- package/src/cli.js +45 -13
- package/src/clients/official/base.js +188 -0
- package/src/clients/official/bitable.js +269 -0
- package/src/clients/official/calendar.js +176 -0
- package/src/clients/official/contacts.js +54 -0
- package/src/clients/official/docs.js +301 -0
- package/src/clients/official/drive.js +77 -0
- package/src/clients/official/groups.js +68 -0
- package/src/clients/official/im.js +414 -0
- package/src/clients/official/index.js +30 -0
- package/src/clients/official/okr.js +127 -0
- package/src/clients/official/tasks.js +142 -0
- package/src/clients/official/uploads.js +260 -0
- package/src/clients/official/wiki.js +207 -0
- package/src/{client.js → clients/user.js} +25 -33
- package/src/config.js +13 -8
- package/src/events/event-buffer.js +100 -0
- package/src/events/index.js +5 -0
- package/src/events/ws-server.js +86 -0
- package/src/index.js +4 -1977
- package/src/logger.js +20 -0
- package/src/oauth.js +5 -1
- package/src/official.js +5 -1944
- package/src/prompts/_registry.js +69 -0
- package/src/prompts/index.js +54 -0
- package/src/server.js +305 -0
- package/src/setup.js +16 -1
- package/src/test-all.js +2 -2
- package/src/test-comprehensive.js +3 -3
- package/src/test-send.js +1 -1
- package/src/tools/_registry.js +31 -0
- package/src/tools/bitable.js +246 -0
- package/src/tools/calendar.js +207 -0
- package/src/tools/contacts.js +66 -0
- package/src/tools/diagnostics.js +172 -0
- package/src/tools/docs.js +158 -0
- package/src/tools/drive.js +111 -0
- package/src/tools/events.js +64 -0
- package/src/tools/groups.js +81 -0
- package/src/tools/im-read.js +259 -0
- package/src/tools/messaging-bot.js +151 -0
- package/src/tools/messaging-user.js +292 -0
- package/src/tools/okr.js +159 -0
- package/src/tools/profile.js +74 -0
- package/src/tools/tasks.js +168 -0
- package/src/tools/uploads.js +63 -0
- package/src/tools/wiki.js +191 -0
|
@@ -0,0 +1,248 @@
|
|
|
1
|
+
// src/auth/profile-router.js — multi-profile auto-switch middleware (v1.3.8).
|
|
2
|
+
//
|
|
3
|
+
// Wraps the MCP CallToolRequestSchema dispatcher. For READ-ONLY tools that
|
|
4
|
+
// fail with a permission-denied / forbidden error, we transparently retry the
|
|
5
|
+
// call with another profile (if more than one is configured) and remember the
|
|
6
|
+
// winner via credentials.profileHints.
|
|
7
|
+
//
|
|
8
|
+
// Intentionally DOES NOT touch write paths. A user wanting auto-switch on a
|
|
9
|
+
// write must opt in explicitly: pass `via_profile: "auto"` in the tool args.
|
|
10
|
+
//
|
|
11
|
+
// What this owns:
|
|
12
|
+
// - READ_ONLY_TOOLS whitelist (name prefix + manage_bitable_* read-action override)
|
|
13
|
+
// - SWITCH_TRIGGERING_PATTERNS (error code / message regex set)
|
|
14
|
+
// - extractResourceKey(name, args) — resourceKey for hinting
|
|
15
|
+
// - profileOrder(name, args, hints, ctx) — ordered list of profiles to try
|
|
16
|
+
// - withProfileRouting(ctx, name, args, callHandler) — the main wrapper
|
|
17
|
+
//
|
|
18
|
+
// What it does NOT own:
|
|
19
|
+
// - The actual setActiveProfile / cache-invalidate (delegated to ctx).
|
|
20
|
+
// - Persistence of hints (delegates to auth/credentials).
|
|
21
|
+
|
|
22
|
+
const credentials = require('./credentials');
|
|
23
|
+
|
|
24
|
+
// --- Whitelist ---
|
|
25
|
+
|
|
26
|
+
const READ_ONLY_PREFIXES = ['read_', 'list_', 'get_', 'search_', 'download_'];
|
|
27
|
+
|
|
28
|
+
// Explicit allowlist for manage_*/check_* tools whose action arg is read-only.
|
|
29
|
+
// Each entry: tool name → predicate(args) returns true if THIS call is read-only.
|
|
30
|
+
const READ_ONLY_OVERRIDES = {
|
|
31
|
+
manage_bitable_app: (a) => a?.action === 'get_meta',
|
|
32
|
+
manage_bitable_table: (a) => a?.action === 'list',
|
|
33
|
+
manage_bitable_field: (a) => a?.action === 'list',
|
|
34
|
+
manage_bitable_view: (a) => a?.action === 'list',
|
|
35
|
+
manage_bitable_record: (a) => a?.action === 'search',
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
function isReadOnlyCall(name, args) {
|
|
39
|
+
if (READ_ONLY_PREFIXES.some(p => name.startsWith(p))) return true;
|
|
40
|
+
const override = READ_ONLY_OVERRIDES[name];
|
|
41
|
+
if (override && override(args)) return true;
|
|
42
|
+
return false;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// --- Error classification ---
|
|
46
|
+
|
|
47
|
+
const SWITCH_CODES = new Set([
|
|
48
|
+
91403, // wiki / docx no permission
|
|
49
|
+
1254301, // bitable no permission
|
|
50
|
+
1254000, // bitable access denied
|
|
51
|
+
99991672, // OAuth scope not granted (user-side)
|
|
52
|
+
403, // generic HTTP forbidden
|
|
53
|
+
]);
|
|
54
|
+
|
|
55
|
+
const SWITCH_PATTERNS = [
|
|
56
|
+
/access[_ ]?denied/i,
|
|
57
|
+
/permission[_ ]?denied/i,
|
|
58
|
+
/docx_no_permission/i,
|
|
59
|
+
/no permission/i,
|
|
60
|
+
/not authorized/i,
|
|
61
|
+
/forbidden/i,
|
|
62
|
+
/HTTP 403/i,
|
|
63
|
+
];
|
|
64
|
+
|
|
65
|
+
function shouldSwitchOnError(err) {
|
|
66
|
+
const msg = (err?.message || String(err) || '').toLowerCase();
|
|
67
|
+
// Code-form: "(91403)", "code=1254301", "(HTTP 403, ...)"
|
|
68
|
+
const codeMatch = msg.match(/\b(?:code[=(]|http\s+)(\d+)/i) || msg.match(/\((\d{3,7})(?:,|\))/);
|
|
69
|
+
if (codeMatch) {
|
|
70
|
+
const code = parseInt(codeMatch[1], 10);
|
|
71
|
+
if (SWITCH_CODES.has(code)) return { yes: true, reason: `code=${code}` };
|
|
72
|
+
}
|
|
73
|
+
for (const re of SWITCH_PATTERNS) {
|
|
74
|
+
if (re.test(msg)) return { yes: true, reason: re.source };
|
|
75
|
+
}
|
|
76
|
+
return { yes: false, reason: null };
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// --- Resource key extraction ---
|
|
80
|
+
|
|
81
|
+
// Mapping: arg field name → resourceKey kind. Order matters — first match wins.
|
|
82
|
+
const RESOURCE_FIELDS = [
|
|
83
|
+
{ field: 'document_id', kind: 'doc' },
|
|
84
|
+
{ field: 'doc_token', kind: 'doc' },
|
|
85
|
+
{ field: 'app_token', kind: 'app' },
|
|
86
|
+
{ field: 'space_id', kind: 'wiki' },
|
|
87
|
+
{ field: 'node_token', kind: 'wiki' },
|
|
88
|
+
{ field: 'wiki_token', kind: 'wiki' },
|
|
89
|
+
{ field: 'chat_id', kind: 'chat' },
|
|
90
|
+
{ field: 'user_id', kind: 'user' },
|
|
91
|
+
{ field: 'open_id', kind: 'user' },
|
|
92
|
+
{ field: 'file_token', kind: 'file' },
|
|
93
|
+
{ field: 'image_token', kind: 'file' },
|
|
94
|
+
{ field: 'message_id', kind: 'msg' },
|
|
95
|
+
];
|
|
96
|
+
|
|
97
|
+
function extractResourceKey(args) {
|
|
98
|
+
if (!args || typeof args !== 'object') return null;
|
|
99
|
+
for (const { field, kind } of RESOURCE_FIELDS) {
|
|
100
|
+
const v = args[field];
|
|
101
|
+
if (typeof v === 'string' && v) return `${kind}:${v}`;
|
|
102
|
+
}
|
|
103
|
+
return null;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// --- Profile order ---
|
|
107
|
+
|
|
108
|
+
function profileOrder(name, args, ctx) {
|
|
109
|
+
const all = ctx.listProfiles();
|
|
110
|
+
if (all.length <= 1) return all;
|
|
111
|
+
const active = ctx.getActiveProfile();
|
|
112
|
+
const resourceKey = extractResourceKey(args);
|
|
113
|
+
const hints = credentials.getProfileHints();
|
|
114
|
+
const hinted = resourceKey ? hints[resourceKey] : null;
|
|
115
|
+
|
|
116
|
+
// Order: [hinted (if !active && exists), active, ...rest]
|
|
117
|
+
// We always try active first when there's no hint or the hint matches active —
|
|
118
|
+
// this preserves "your current profile is the default attempt" UX. When the
|
|
119
|
+
// hint differs from active, we skip active for the FIRST attempt because the
|
|
120
|
+
// hint is empirically known to work (or used to).
|
|
121
|
+
const order = [];
|
|
122
|
+
const seen = new Set();
|
|
123
|
+
const push = (p) => { if (p && all.includes(p) && !seen.has(p)) { order.push(p); seen.add(p); } };
|
|
124
|
+
|
|
125
|
+
if (hinted && hinted !== active) push(hinted);
|
|
126
|
+
push(active);
|
|
127
|
+
for (const p of [...all].sort()) push(p);
|
|
128
|
+
return order;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// --- Main wrapper ---
|
|
132
|
+
|
|
133
|
+
async function withProfileRouting(ctx, name, args, callHandler) {
|
|
134
|
+
// Bail out early when there's nothing to switch to or this is a write.
|
|
135
|
+
const all = ctx.listProfiles();
|
|
136
|
+
const isMultiProfile = all.length > 1;
|
|
137
|
+
|
|
138
|
+
// Explicit override: via_profile arg pins (or unlocks auto-switch on writes).
|
|
139
|
+
let viaProfileArg = null;
|
|
140
|
+
if (args && typeof args === 'object' && typeof args.via_profile === 'string') {
|
|
141
|
+
viaProfileArg = args.via_profile;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
const allowAuto = isMultiProfile && (
|
|
145
|
+
isReadOnlyCall(name, args) || viaProfileArg === 'auto'
|
|
146
|
+
);
|
|
147
|
+
|
|
148
|
+
if (!allowAuto) {
|
|
149
|
+
// Single-profile or write: just call once.
|
|
150
|
+
if (viaProfileArg && viaProfileArg !== 'auto') {
|
|
151
|
+
// Explicit pin
|
|
152
|
+
if (!all.includes(viaProfileArg)) {
|
|
153
|
+
return { content: [{ type: 'text', text: `via_profile: "${viaProfileArg}" not found. Available: ${all.join(', ')}.` }], isError: true };
|
|
154
|
+
}
|
|
155
|
+
const wasActive = ctx.getActiveProfile();
|
|
156
|
+
if (viaProfileArg !== wasActive) ctx.setActiveProfile(viaProfileArg);
|
|
157
|
+
try {
|
|
158
|
+
return await callHandler();
|
|
159
|
+
} finally {
|
|
160
|
+
// Restore active for subsequent calls in same session — explicit pin
|
|
161
|
+
// is per-call, not a session toggle.
|
|
162
|
+
if (viaProfileArg !== wasActive) ctx.setActiveProfile(wasActive);
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
return callHandler();
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// Auto-switch loop.
|
|
169
|
+
const order = profileOrder(name, args, ctx);
|
|
170
|
+
const wasActive = ctx.getActiveProfile();
|
|
171
|
+
const failures = [];
|
|
172
|
+
let switchedFrom = null;
|
|
173
|
+
let switchedReason = null;
|
|
174
|
+
|
|
175
|
+
for (let i = 0; i < order.length; i++) {
|
|
176
|
+
const profile = order[i];
|
|
177
|
+
if (i > 0) {
|
|
178
|
+
// Switching away from previous profile.
|
|
179
|
+
if (!switchedFrom) switchedFrom = wasActive;
|
|
180
|
+
ctx.setActiveProfile(profile);
|
|
181
|
+
console.error(`[feishu-user-plugin] profile-router: ${order[i - 1]} → ${profile} on ${name} (${switchedReason || 'first attempt failed'})`);
|
|
182
|
+
} else if (profile !== wasActive) {
|
|
183
|
+
// Hinted profile differs from active — switch on first attempt too.
|
|
184
|
+
switchedFrom = wasActive;
|
|
185
|
+
ctx.setActiveProfile(profile);
|
|
186
|
+
console.error(`[feishu-user-plugin] profile-router: ${wasActive} → ${profile} on ${name} (hint match)`);
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
try {
|
|
190
|
+
const res = await callHandler();
|
|
191
|
+
// Detect handler-level isError responses (not all errors throw).
|
|
192
|
+
if (res?.isError && res.content?.[0]?.text) {
|
|
193
|
+
const decision = shouldSwitchOnError({ message: res.content[0].text });
|
|
194
|
+
if (decision.yes && i + 1 < order.length) {
|
|
195
|
+
failures.push({ profile, error: res.content[0].text.slice(0, 200) });
|
|
196
|
+
switchedReason = decision.reason;
|
|
197
|
+
continue;
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
// Success — cache hint if we switched, then return.
|
|
201
|
+
if (switchedFrom && profile !== switchedFrom) {
|
|
202
|
+
const rk = extractResourceKey(args);
|
|
203
|
+
if (rk) {
|
|
204
|
+
try { credentials.setProfileHint(rk, profile); }
|
|
205
|
+
catch (e) { console.error(`[feishu-user-plugin] profile-router: hint persist failed (${e.message})`); }
|
|
206
|
+
}
|
|
207
|
+
// Annotate response.
|
|
208
|
+
if (res?.content?.[0]?.type === 'text' && typeof res.content[0].text === 'string') {
|
|
209
|
+
res.content[0].text = `[autoSwitched: ${switchedFrom} → ${profile} on ${rk || 'no-key'}]\n` + res.content[0].text;
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
// Restore active to whatever the user had — auto-switch is per-call.
|
|
213
|
+
if (switchedFrom) ctx.setActiveProfile(wasActive);
|
|
214
|
+
return res;
|
|
215
|
+
} catch (err) {
|
|
216
|
+
const decision = shouldSwitchOnError(err);
|
|
217
|
+
failures.push({ profile, error: err.message });
|
|
218
|
+
if (!decision.yes || i + 1 >= order.length) {
|
|
219
|
+
if (switchedFrom) ctx.setActiveProfile(wasActive);
|
|
220
|
+
// Compose a comprehensive error if all profiles failed.
|
|
221
|
+
if (failures.length > 1) {
|
|
222
|
+
const lines = failures.map(f => ` ${f.profile}: ${f.error}`).join('\n');
|
|
223
|
+
throw new Error(`All ${failures.length} profiles failed on ${name}:\n${lines}`);
|
|
224
|
+
}
|
|
225
|
+
throw err;
|
|
226
|
+
}
|
|
227
|
+
switchedReason = decision.reason;
|
|
228
|
+
// Loop to next profile.
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
if (switchedFrom) ctx.setActiveProfile(wasActive);
|
|
233
|
+
// Should not reach: loop returns on success or throws.
|
|
234
|
+
throw new Error(`profile-router: exhausted ${order.length} profiles on ${name}`);
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
module.exports = {
|
|
238
|
+
withProfileRouting,
|
|
239
|
+
isReadOnlyCall,
|
|
240
|
+
shouldSwitchOnError,
|
|
241
|
+
extractResourceKey,
|
|
242
|
+
profileOrder,
|
|
243
|
+
// Constants exposed for tests.
|
|
244
|
+
READ_ONLY_PREFIXES,
|
|
245
|
+
READ_ONLY_OVERRIDES,
|
|
246
|
+
SWITCH_CODES,
|
|
247
|
+
SWITCH_PATTERNS,
|
|
248
|
+
};
|
package/src/auth/uat.js
ADDED
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
// src/auth/uat.js — UAT lifecycle: refresh, cross-process file lock, persist.
|
|
2
|
+
//
|
|
3
|
+
// State lives on the LarkOfficialClient instance (this._uat, this._uatRefresh,
|
|
4
|
+
// this._uatExpires). These functions take `client` as first arg and mutate its
|
|
5
|
+
// fields. Lifted out of clients/official/base.js for clarity; called only from
|
|
6
|
+
// there.
|
|
7
|
+
//
|
|
8
|
+
// What this owns:
|
|
9
|
+
// - decodeTokenExpiry(token) — JWT exp parsing
|
|
10
|
+
// - getValidUAT(client) — returns current UAT, refreshes if expiring
|
|
11
|
+
// - refreshUAT(client) — full refresh dance with file lock + persist
|
|
12
|
+
// - withUAT(client, fn) — wrapper that retries fn once on auth-error codes
|
|
13
|
+
// - uatREST(client, method, path, opts) — generic UAT REST helper
|
|
14
|
+
// - asUserOrApp(client, opts) — UAT-first, bot-fallback wrapper
|
|
15
|
+
// - persistUAT(client) — writes through auth/credentials
|
|
16
|
+
// - adoptPersistedUATIfNewer(client) — peer-rotation adoption
|
|
17
|
+
// - acquireRefreshLock / releaseRefreshLock — cross-process advisory lock
|
|
18
|
+
|
|
19
|
+
const fs = require('fs');
|
|
20
|
+
const path = require('path');
|
|
21
|
+
const os = require('os');
|
|
22
|
+
const { fetchWithTimeout } = require('../utils');
|
|
23
|
+
|
|
24
|
+
function decodeTokenExpiry(token) {
|
|
25
|
+
try {
|
|
26
|
+
const payload = token?.split('.')?.[1];
|
|
27
|
+
if (!payload) return 0;
|
|
28
|
+
const data = JSON.parse(Buffer.from(payload, 'base64url').toString('utf8'));
|
|
29
|
+
return typeof data.exp === 'number' ? data.exp : 0;
|
|
30
|
+
} catch (_) {
|
|
31
|
+
return 0;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
async function getValidUAT(client) {
|
|
36
|
+
if (!client._uat) throw new Error('No user_access_token. Run: npx feishu-user-plugin oauth');
|
|
37
|
+
const now = Math.floor(Date.now() / 1000);
|
|
38
|
+
if (!client._uatExpires) client._uatExpires = decodeTokenExpiry(client._uat);
|
|
39
|
+
if (client._uatExpires > 0 && client._uatExpires <= now + 300) {
|
|
40
|
+
return refreshUAT(client);
|
|
41
|
+
}
|
|
42
|
+
return client._uat;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function adoptPersistedUATIfNewer(client) {
|
|
46
|
+
try {
|
|
47
|
+
const { readCredentials } = require('./credentials');
|
|
48
|
+
const creds = readCredentials();
|
|
49
|
+
const token = creds.LARK_USER_ACCESS_TOKEN;
|
|
50
|
+
const refresh = creds.LARK_USER_REFRESH_TOKEN;
|
|
51
|
+
if (!token && !refresh) return false;
|
|
52
|
+
const expires = parseInt(creds.LARK_UAT_EXPIRES || '0') || decodeTokenExpiry(token);
|
|
53
|
+
const changed = (token && token !== client._uat)
|
|
54
|
+
|| (refresh && refresh !== client._uatRefresh)
|
|
55
|
+
|| (expires && expires !== client._uatExpires);
|
|
56
|
+
if (!changed) return false;
|
|
57
|
+
if (token) client._uat = token;
|
|
58
|
+
if (refresh) client._uatRefresh = refresh;
|
|
59
|
+
client._uatExpires = expires || 0;
|
|
60
|
+
console.error('[feishu-user-plugin] UAT adopted latest persisted token before refresh');
|
|
61
|
+
return true;
|
|
62
|
+
} catch (e) {
|
|
63
|
+
console.error(`[feishu-user-plugin] UAT persisted-token check failed: ${e.message}`);
|
|
64
|
+
return false;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function uatLockPath() {
|
|
69
|
+
return path.join(os.homedir(), '.claude', 'feishu-uat-refresh.lock');
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
async function acquireRefreshLock(lockPath, { staleMs = 30000, pollMs = 200, timeoutMs = 20000 } = {}) {
|
|
73
|
+
try { fs.mkdirSync(path.dirname(lockPath), { recursive: true }); } catch (_) {}
|
|
74
|
+
const start = Date.now();
|
|
75
|
+
while (Date.now() - start < timeoutMs) {
|
|
76
|
+
try {
|
|
77
|
+
const fd = fs.openSync(lockPath, 'wx');
|
|
78
|
+
fs.writeSync(fd, `${process.pid}\n${Date.now()}\n`);
|
|
79
|
+
fs.closeSync(fd);
|
|
80
|
+
return true;
|
|
81
|
+
} catch (e) {
|
|
82
|
+
if (e.code !== 'EEXIST') throw e;
|
|
83
|
+
try {
|
|
84
|
+
const stat = fs.statSync(lockPath);
|
|
85
|
+
if (Date.now() - stat.mtimeMs > staleMs) {
|
|
86
|
+
try { fs.unlinkSync(lockPath); } catch (_) {}
|
|
87
|
+
continue;
|
|
88
|
+
}
|
|
89
|
+
} catch (_) { /* lock vanished — retry */ }
|
|
90
|
+
await new Promise(r => setTimeout(r, pollMs));
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
return false;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function releaseRefreshLock(lockPath) {
|
|
97
|
+
try { fs.unlinkSync(lockPath); } catch (_) {}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
async function refreshUAT(client) {
|
|
101
|
+
const lockPath = uatLockPath();
|
|
102
|
+
const acquired = await acquireRefreshLock(lockPath);
|
|
103
|
+
if (!acquired) {
|
|
104
|
+
console.error('[feishu-user-plugin] UAT refresh lock timed out; proceeding without mutual exclusion');
|
|
105
|
+
}
|
|
106
|
+
try {
|
|
107
|
+
const now = Math.floor(Date.now() / 1000);
|
|
108
|
+
if (adoptPersistedUATIfNewer(client) && client._uatExpires > now + 300) {
|
|
109
|
+
return client._uat;
|
|
110
|
+
}
|
|
111
|
+
if (!client._uatRefresh) throw new Error('UAT expired and no refresh token. Run: npx feishu-user-plugin oauth');
|
|
112
|
+
const res = await fetchWithTimeout('https://open.feishu.cn/open-apis/authen/v2/oauth/token', {
|
|
113
|
+
method: 'POST',
|
|
114
|
+
headers: { 'content-type': 'application/json' },
|
|
115
|
+
body: JSON.stringify({
|
|
116
|
+
grant_type: 'refresh_token',
|
|
117
|
+
client_id: client.appId,
|
|
118
|
+
client_secret: client.appSecret,
|
|
119
|
+
refresh_token: client._uatRefresh,
|
|
120
|
+
}),
|
|
121
|
+
});
|
|
122
|
+
const data = await res.json();
|
|
123
|
+
const tokenData = data.access_token ? data : data.data;
|
|
124
|
+
if (!tokenData?.access_token) throw new Error(`UAT refresh failed: ${JSON.stringify(data)}. Run: npx feishu-user-plugin oauth`);
|
|
125
|
+
client._uat = tokenData.access_token;
|
|
126
|
+
client._uatRefresh = tokenData.refresh_token || client._uatRefresh;
|
|
127
|
+
const expiresIn = typeof tokenData.expires_in === 'number' && tokenData.expires_in > 0 ? tokenData.expires_in : 7200;
|
|
128
|
+
client._uatExpires = Math.floor(Date.now() / 1000) + expiresIn;
|
|
129
|
+
persistUAT(client);
|
|
130
|
+
console.error('[feishu-user-plugin] UAT refreshed successfully');
|
|
131
|
+
return client._uat;
|
|
132
|
+
} finally {
|
|
133
|
+
if (acquired) releaseRefreshLock(lockPath);
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function persistUAT(client) {
|
|
138
|
+
const { persistToConfig } = require('./credentials');
|
|
139
|
+
persistToConfig({
|
|
140
|
+
LARK_USER_ACCESS_TOKEN: client._uat,
|
|
141
|
+
LARK_USER_REFRESH_TOKEN: client._uatRefresh,
|
|
142
|
+
LARK_UAT_EXPIRES: String(client._uatExpires),
|
|
143
|
+
});
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
async function withUAT(client, fn) {
|
|
147
|
+
let uat = await getValidUAT(client);
|
|
148
|
+
const data = await fn(uat);
|
|
149
|
+
if (data.code === 99991668 || data.code === 99991663 || data.code === 99991677) {
|
|
150
|
+
if (data.code === 99991668 && typeof data.msg === 'string' && /not support/i.test(data.msg)) {
|
|
151
|
+
return data;
|
|
152
|
+
}
|
|
153
|
+
uat = await refreshUAT(client);
|
|
154
|
+
return fn(uat);
|
|
155
|
+
}
|
|
156
|
+
return data;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
async function uatREST(client, method, urlPath, { body, query } = {}) {
|
|
160
|
+
let qs = '';
|
|
161
|
+
if (query) {
|
|
162
|
+
const sp = new URLSearchParams();
|
|
163
|
+
for (const [k, v] of Object.entries(query)) {
|
|
164
|
+
if (v === undefined || v === null) continue;
|
|
165
|
+
if (Array.isArray(v)) { for (const item of v) sp.append(k, String(item)); }
|
|
166
|
+
else sp.append(k, String(v));
|
|
167
|
+
}
|
|
168
|
+
const str = sp.toString();
|
|
169
|
+
if (str) qs = '?' + str;
|
|
170
|
+
}
|
|
171
|
+
const url = 'https://open.feishu.cn' + urlPath + qs;
|
|
172
|
+
return withUAT(client, async (uat) => {
|
|
173
|
+
const headers = { 'Authorization': `Bearer ${uat}` };
|
|
174
|
+
const init = { method, headers };
|
|
175
|
+
if (body !== undefined) {
|
|
176
|
+
headers['content-type'] = 'application/json';
|
|
177
|
+
init.body = JSON.stringify(body);
|
|
178
|
+
}
|
|
179
|
+
const res = await fetchWithTimeout(url, init);
|
|
180
|
+
return res.json();
|
|
181
|
+
});
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
async function asUserOrApp(client, { uatPath, method = 'GET', body, query, sdkFn, label }) {
|
|
185
|
+
let uatSummary = null;
|
|
186
|
+
if (client.hasUAT) {
|
|
187
|
+
try {
|
|
188
|
+
const data = await uatREST(client, method, uatPath, { body, query });
|
|
189
|
+
if (data.code === 0) {
|
|
190
|
+
data._viaUser = true;
|
|
191
|
+
return data;
|
|
192
|
+
}
|
|
193
|
+
uatSummary = `as user: code=${data.code} msg=${data.msg}`;
|
|
194
|
+
console.error(`[feishu-user-plugin] ${label} ${uatSummary}, retrying as app`);
|
|
195
|
+
} catch (err) {
|
|
196
|
+
uatSummary = `as user: ${err.message}`;
|
|
197
|
+
console.error(`[feishu-user-plugin] ${label} as user threw (${err.message}), retrying as app`);
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
try {
|
|
201
|
+
const appData = await client._safeSDKCall(sdkFn, label);
|
|
202
|
+
if (appData && typeof appData === 'object') {
|
|
203
|
+
appData._viaUser = false;
|
|
204
|
+
if (uatSummary) {
|
|
205
|
+
appData._fallbackWarning = `⚠️ UAT 不可用 (${uatSummary}),本次操作以 bot 身份执行。资源归属于共享 bot「Claude聊天助手」,不是你。恢复方法:运行 \`npx feishu-user-plugin oauth\` 后重启 Claude Code / Codex。`;
|
|
206
|
+
} else if (!client.hasUAT) {
|
|
207
|
+
appData._fallbackWarning = `⚠️ 未配置 UAT,本次操作以 bot 身份执行。资源归属于共享 bot「Claude聊天助手」,不是你。想让资源归你所有,先跑 \`npx feishu-user-plugin oauth\` 然后重启 Claude Code / Codex。`;
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
return appData;
|
|
211
|
+
} catch (appErr) {
|
|
212
|
+
if (uatSummary) {
|
|
213
|
+
const err = new Error(`${label} failed on both identities. ${uatSummary}. as app: ${appErr.message}`);
|
|
214
|
+
err.uatSummary = uatSummary;
|
|
215
|
+
err.appError = appErr;
|
|
216
|
+
throw err;
|
|
217
|
+
}
|
|
218
|
+
throw appErr;
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
module.exports = {
|
|
223
|
+
decodeTokenExpiry,
|
|
224
|
+
getValidUAT,
|
|
225
|
+
refreshUAT,
|
|
226
|
+
withUAT,
|
|
227
|
+
uatREST,
|
|
228
|
+
asUserOrApp,
|
|
229
|
+
persistUAT,
|
|
230
|
+
adoptPersistedUATIfNewer,
|
|
231
|
+
};
|
package/src/cli.js
CHANGED
|
@@ -25,6 +25,17 @@ switch (cmd) {
|
|
|
25
25
|
case 'keepalive':
|
|
26
26
|
keepalive();
|
|
27
27
|
break;
|
|
28
|
+
case 'list-prompts': {
|
|
29
|
+
const { listPrompts } = require('./prompts');
|
|
30
|
+
for (const p of listPrompts()) {
|
|
31
|
+
console.log(`/${p.name} — ${p.description}`);
|
|
32
|
+
for (const a of (p.arguments || [])) console.log(` - ${a.name}${a.required ? ' (required)' : ''}: ${a.description}`);
|
|
33
|
+
}
|
|
34
|
+
break;
|
|
35
|
+
}
|
|
36
|
+
case 'migrate':
|
|
37
|
+
migrate();
|
|
38
|
+
break;
|
|
28
39
|
case 'help':
|
|
29
40
|
case '--help':
|
|
30
41
|
case '-h':
|
|
@@ -46,6 +57,9 @@ Commands:
|
|
|
46
57
|
oauth Run OAuth flow to obtain user_access_token
|
|
47
58
|
status Check authentication status
|
|
48
59
|
keepalive Refresh cookie + UAT to prevent expiration (for cron jobs)
|
|
60
|
+
migrate One-time consolidation: copy creds from harness configs into
|
|
61
|
+
~/.feishu-user-plugin/credentials.json (single source of truth).
|
|
62
|
+
Dry-run by default. Add --confirm to actually write.
|
|
49
63
|
help Show this help
|
|
50
64
|
|
|
51
65
|
Setup options:
|
|
@@ -53,6 +67,9 @@ Setup options:
|
|
|
53
67
|
--app-secret <s> App Secret (non-interactive mode)
|
|
54
68
|
--cookie <c> Cookie string (optional)
|
|
55
69
|
--client <target> Config target: claude (default), codex, or both
|
|
70
|
+
--pointer-only Write only FEISHU_PLUGIN_PROFILE=default to harness env.
|
|
71
|
+
Real creds live in ~/.feishu-user-plugin/credentials.json
|
|
72
|
+
(run "migrate --confirm" first if not yet migrated).
|
|
56
73
|
|
|
57
74
|
Quick Start (Claude Code):
|
|
58
75
|
1. npx feishu-user-plugin setup
|
|
@@ -70,17 +87,23 @@ Auto-renewal (optional):
|
|
|
70
87
|
`);
|
|
71
88
|
}
|
|
72
89
|
|
|
90
|
+
function migrate() {
|
|
91
|
+
const { migrate: runMigrate } = require('./auth/credentials');
|
|
92
|
+
const confirm = process.argv.includes('--confirm');
|
|
93
|
+
const result = runMigrate({ dryRun: !confirm });
|
|
94
|
+
process.exit(result.ok ? 0 : 1);
|
|
95
|
+
}
|
|
96
|
+
|
|
73
97
|
async function keepalive() {
|
|
74
|
-
const { LarkUserClient } = require('./
|
|
75
|
-
const { LarkOfficialClient } = require('./official');
|
|
76
|
-
const {
|
|
98
|
+
const { LarkUserClient } = require('./clients/user');
|
|
99
|
+
const { LarkOfficialClient } = require('./clients/official');
|
|
100
|
+
const { readCredentials, persistToConfig } = require('./auth/credentials');
|
|
77
101
|
|
|
78
|
-
const
|
|
79
|
-
if (!
|
|
80
|
-
console.error('[keepalive] No
|
|
102
|
+
const creds = readCredentials();
|
|
103
|
+
if (!creds.LARK_COOKIE && !creds.LARK_APP_ID) {
|
|
104
|
+
console.error('[keepalive] No credentials found. Run: npx feishu-user-plugin setup');
|
|
81
105
|
process.exit(1);
|
|
82
106
|
}
|
|
83
|
-
const creds = found.serverEnv;
|
|
84
107
|
let ok = true;
|
|
85
108
|
|
|
86
109
|
// 1. Refresh Cookie
|
|
@@ -124,18 +147,27 @@ async function keepalive() {
|
|
|
124
147
|
}
|
|
125
148
|
|
|
126
149
|
async function checkStatus() {
|
|
127
|
-
const { LarkUserClient } = require('./
|
|
128
|
-
const { LarkOfficialClient } = require('./official');
|
|
150
|
+
const { LarkUserClient } = require('./clients/user');
|
|
151
|
+
const { LarkOfficialClient } = require('./clients/official');
|
|
129
152
|
const { findMcpConfig } = require('./config');
|
|
153
|
+
const { readCanonical, getActiveProfileName, listProfileNames, readCredentials } = require('./auth/credentials');
|
|
130
154
|
|
|
155
|
+
const canonical = readCanonical();
|
|
131
156
|
const found = findMcpConfig();
|
|
132
|
-
const creds =
|
|
157
|
+
const creds = readCredentials();
|
|
133
158
|
|
|
134
159
|
console.log('=== feishu-user-plugin Auth Status ===\n');
|
|
135
|
-
if (
|
|
136
|
-
|
|
160
|
+
if (canonical) {
|
|
161
|
+
const path = require('path');
|
|
162
|
+
const os = require('os');
|
|
163
|
+
console.log(`Source: ${path.join(os.homedir(), '.feishu-user-plugin', 'credentials.json')} (canonical)`);
|
|
164
|
+
console.log(`Active profile: ${getActiveProfileName()}`);
|
|
165
|
+
console.log(`Available profiles: ${listProfileNames().join(', ')}`);
|
|
166
|
+
} else if (found) {
|
|
167
|
+
console.log(`Source: ${found.configPath}${found.projectPath ? ` (project: ${found.projectPath})` : ''} (legacy)`);
|
|
168
|
+
console.log('Tip: run `npx feishu-user-plugin migrate --confirm` to consolidate creds into ~/.feishu-user-plugin/credentials.json.');
|
|
137
169
|
} else {
|
|
138
|
-
console.log('
|
|
170
|
+
console.log('Source: NOT FOUND (run: npx feishu-user-plugin setup)');
|
|
139
171
|
}
|
|
140
172
|
console.log('');
|
|
141
173
|
|