feishu-user-plugin 1.3.13 → 1.3.15
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 +1 -1
- package/.cursor-plugin/plugin.json +1 -1
- package/.mcpb/manifest.json +1 -1
- package/CHANGELOG.md +91 -0
- package/README.en.md +3 -1
- package/README.md +4 -2
- package/package.json +1 -1
- package/scripts/check-broken-links.js +117 -0
- package/scripts/generate-release-artifacts.js +7 -1
- package/scripts/probe-feishu-docx.js +6 -6
- package/scripts/test-uat-race-child.js +8 -5
- package/scripts/test-uat-race.js +9 -2
- package/scripts/test-wiki-attach-fallback.js +7 -0
- package/skills/feishu-user-plugin/SKILL.md +2 -2
- package/skills/feishu-user-plugin/references/doc.md +12 -0
- package/src/auth/cookie.js +67 -14
- package/src/auth/credentials-monitor.js +5 -0
- package/src/auth/env-backfill.js +37 -0
- package/src/auth/identity-state.js +17 -1
- package/src/auth/uat.js +124 -8
- package/src/cli.js +4 -3
- package/src/clients/official/base.js +12 -7
- package/src/clients/official/docs.js +95 -0
- package/src/error-codes.js +2 -1
- package/src/oauth.js +19 -7
- package/src/server.js +22 -4
- package/src/setup.js +3 -1
- package/src/test-all.js +15 -0
- package/src/test-comprehensive.js +7 -2
- package/src/test-cookie-heartbeat.js +132 -0
- package/src/test-doc-table.js +123 -0
- package/src/test-uat-lifecycle.js +449 -0
- package/src/tools/docs.js +13 -4
- package/src/oauth-auto.js +0 -175
|
@@ -84,7 +84,22 @@ function _refineIdentity(client, state) {
|
|
|
84
84
|
// should keep the original VALID_USER state and just record the via_reason).
|
|
85
85
|
function _classifyUatFailure(uatResp, uatError) {
|
|
86
86
|
if (uatError) {
|
|
87
|
-
|
|
87
|
+
// v1.3.14 — explicit short-circuit when refreshUAT set `err.uatRevoked`.
|
|
88
|
+
// Lets refresh-side rejections (invalid_grant from /authen/v2/oauth/token)
|
|
89
|
+
// flow into the same UAT_REVOKED state as tool-call-side 20064 responses,
|
|
90
|
+
// and lets `withIdentityFallback` build a clear "请重跑 oauth" warning.
|
|
91
|
+
if (uatError.uatRevoked) {
|
|
92
|
+
return {
|
|
93
|
+
state: IdentityState.UAT_REVOKED,
|
|
94
|
+
viaReason: 'as user: refresh_token rejected by Feishu (invalid_grant)',
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
// v1.3.14 — redact base64-ish tokens (40+ chars of [A-Za-z0-9._-]) in
|
|
98
|
+
// case an upstream throw site leaked refresh_token or access_token bytes
|
|
99
|
+
// into the error message. Defense-in-depth on top of uat.js::refreshUAT
|
|
100
|
+
// which already avoids dumping the raw response body.
|
|
101
|
+
const rawMsg = uatError.message || String(uatError);
|
|
102
|
+
const msg = rawMsg.replace(/[A-Za-z0-9._-]{40,}/g, '<redacted>');
|
|
88
103
|
// Network/JSON parse errors don't refine identity — UAT is still presumed
|
|
89
104
|
// valid, we just couldn't reach Feishu this call.
|
|
90
105
|
return { state: null, viaReason: `as user: ${msg}` };
|
|
@@ -206,4 +221,5 @@ module.exports = {
|
|
|
206
221
|
invalidateIdentity,
|
|
207
222
|
_refineIdentity, // exported for D's CredentialsMonitor hook (private API)
|
|
208
223
|
_readInMemoryState, // exported for testing edge cases (private API)
|
|
224
|
+
_classifyUatFailure, // v1.3.14 — exported for testing redact + uatRevoked wiring
|
|
209
225
|
};
|
package/src/auth/uat.js
CHANGED
|
@@ -28,13 +28,36 @@ const path = require('path');
|
|
|
28
28
|
const os = require('os');
|
|
29
29
|
const { fetchWithTimeout } = require('../utils');
|
|
30
30
|
|
|
31
|
+
// One-warning-per-malformed-token tracker. Without this, a persistently bad
|
|
32
|
+
// JWT would flood stderr every tool call (getValidUAT calls decode whenever
|
|
33
|
+
// _uatExpires falsy, which it stays at 0 if decode returns 0). 1024-entry cap
|
|
34
|
+
// is conservative — real tokens are rotated rarely; cap prevents OOM in the
|
|
35
|
+
// unlikely event of a malformed-token spray.
|
|
36
|
+
const _warnedMalformedTokens = new Set();
|
|
37
|
+
function _markWarnedMalformedToken(token) {
|
|
38
|
+
if (typeof token !== 'string' || token.length === 0) return false;
|
|
39
|
+
const key = require('crypto').createHash('sha256').update(token, 'utf8').digest('hex').slice(0, 16);
|
|
40
|
+
if (_warnedMalformedTokens.has(key)) return false;
|
|
41
|
+
if (_warnedMalformedTokens.size >= 1024) _warnedMalformedTokens.clear();
|
|
42
|
+
_warnedMalformedTokens.add(key);
|
|
43
|
+
return true;
|
|
44
|
+
}
|
|
45
|
+
|
|
31
46
|
function decodeTokenExpiry(token) {
|
|
32
47
|
try {
|
|
33
48
|
const payload = token?.split('.')?.[1];
|
|
34
49
|
if (!payload) return 0;
|
|
35
50
|
const data = JSON.parse(Buffer.from(payload, 'base64url').toString('utf8'));
|
|
36
51
|
return typeof data.exp === 'number' ? data.exp : 0;
|
|
37
|
-
} catch (
|
|
52
|
+
} catch (e) {
|
|
53
|
+
// Log breadcrumb so silently-malformed UATs are observable, but only once
|
|
54
|
+
// per distinct bad token (hashed). We still return 0 (caller treats 0 as
|
|
55
|
+
// "never decoded — let refresh path decide") instead of throwing, because
|
|
56
|
+
// a bad JWT shouldn't crash tool dispatch — the next 99991663/99991668
|
|
57
|
+
// response will trigger refresh anyway.
|
|
58
|
+
if (_markWarnedMalformedToken(token)) {
|
|
59
|
+
console.error(`[feishu-user-plugin] decodeTokenExpiry: malformed JWT payload (${e.message}); will rely on Feishu rejection for refresh trigger`);
|
|
60
|
+
}
|
|
38
61
|
return 0;
|
|
39
62
|
}
|
|
40
63
|
}
|
|
@@ -73,7 +96,12 @@ function adoptPersistedUATIfNewer(client) {
|
|
|
73
96
|
}
|
|
74
97
|
|
|
75
98
|
function uatLockPath() {
|
|
76
|
-
|
|
99
|
+
// v1.3.14: moved from ~/.claude/feishu-uat-refresh.lock to canonical
|
|
100
|
+
// ~/.feishu-user-plugin/ so Codex-only / non-Claude-Code users (whose
|
|
101
|
+
// ~/.claude/ may not exist) still get cross-process mutual exclusion.
|
|
102
|
+
// Mixed-version concern is N/A — running two different versions of this
|
|
103
|
+
// plugin in parallel is not a supported configuration.
|
|
104
|
+
return path.join(os.homedir(), '.feishu-user-plugin', 'uat-refresh.lock');
|
|
77
105
|
}
|
|
78
106
|
|
|
79
107
|
async function acquireRefreshLock(lockPath, { staleMs = 30000, pollMs = 200, timeoutMs = 20000 } = {}) {
|
|
@@ -105,17 +133,42 @@ function releaseRefreshLock(lockPath) {
|
|
|
105
133
|
}
|
|
106
134
|
|
|
107
135
|
async function refreshUAT(client) {
|
|
136
|
+
// v1.3.14 — Pre-lock cheap path: maybe a peer process already refreshed and
|
|
137
|
+
// persisted a valid token. Adopt it before paying for the file lock. This
|
|
138
|
+
// dramatically reduces lock contention in deployments with 10+ concurrent
|
|
139
|
+
// MCP server processes (one per Claude Code session).
|
|
140
|
+
let now = Math.floor(Date.now() / 1000);
|
|
141
|
+
if (adoptPersistedUATIfNewer(client) && client._uatExpires > now + 300) {
|
|
142
|
+
return client._uat;
|
|
143
|
+
}
|
|
144
|
+
|
|
108
145
|
const lockPath = uatLockPath();
|
|
109
146
|
const acquired = await acquireRefreshLock(lockPath);
|
|
110
147
|
if (!acquired) {
|
|
111
|
-
|
|
148
|
+
// Lock timed out (>20s of contention). Before falling through to an
|
|
149
|
+
// un-coordinated refresh — which can burn the refresh_token chain on the
|
|
150
|
+
// Feishu side — give disk one more chance: a peer may have written a
|
|
151
|
+
// fresh token between our pre-check and now.
|
|
152
|
+
now = Math.floor(Date.now() / 1000);
|
|
153
|
+
if (adoptPersistedUATIfNewer(client) && client._uatExpires > now + 300) {
|
|
154
|
+
return client._uat;
|
|
155
|
+
}
|
|
156
|
+
console.error('[feishu-user-plugin] UAT refresh lock timed out; proceeding without mutual exclusion (this may burn refresh_token chain — investigate if it happens often)');
|
|
112
157
|
}
|
|
113
158
|
try {
|
|
114
|
-
|
|
159
|
+
// Inside the lock: re-check disk one more time. Between acquireRefreshLock
|
|
160
|
+
// returning and this point, another holder may have released after writing.
|
|
161
|
+
now = Math.floor(Date.now() / 1000);
|
|
115
162
|
if (adoptPersistedUATIfNewer(client) && client._uatExpires > now + 300) {
|
|
116
163
|
return client._uat;
|
|
117
164
|
}
|
|
118
165
|
if (!client._uatRefresh) throw new Error('UAT expired and no refresh token. Run: npx feishu-user-plugin oauth');
|
|
166
|
+
// Snapshot the refresh_token we are about to send BEFORE awaiting. A peer
|
|
167
|
+
// in-process refresh or the credentials monitor can hot-reload
|
|
168
|
+
// client._uatRefresh during the round-trip; the invalid_grant self-heal
|
|
169
|
+
// below must compare against the token actually sent, not a field that may
|
|
170
|
+
// have already rotated. (Codex review, PR #111.)
|
|
171
|
+
const attemptedRefresh = client._uatRefresh;
|
|
119
172
|
const res = await fetchWithTimeout('https://open.feishu.cn/open-apis/authen/v2/oauth/token', {
|
|
120
173
|
method: 'POST',
|
|
121
174
|
headers: { 'content-type': 'application/json' },
|
|
@@ -123,12 +176,64 @@ async function refreshUAT(client) {
|
|
|
123
176
|
grant_type: 'refresh_token',
|
|
124
177
|
client_id: client.appId,
|
|
125
178
|
client_secret: client.appSecret,
|
|
126
|
-
refresh_token:
|
|
179
|
+
refresh_token: attemptedRefresh,
|
|
127
180
|
}),
|
|
128
181
|
});
|
|
129
182
|
const data = await res.json();
|
|
130
183
|
const tokenData = data.access_token ? data : data.data;
|
|
131
|
-
if (!tokenData?.access_token)
|
|
184
|
+
if (!tokenData?.access_token) {
|
|
185
|
+
// v1.3.14 — never dump the raw response body. Some Feishu error paths
|
|
186
|
+
// echo back portions of the request including refresh_token bytes, and
|
|
187
|
+
// this message bubbles up to Error.message → MCP content[0].text →
|
|
188
|
+
// LLM transcript. Surface only the structured error code/msg.
|
|
189
|
+
const errCode = data?.error ?? data?.code ?? 'unknown';
|
|
190
|
+
const errMsg = data?.error_description ?? data?.msg ?? '(no error message from Feishu)';
|
|
191
|
+
// Distinguish refresh_token rejection (must re-oauth) from transient
|
|
192
|
+
// server-side errors so the identity state machine can flip to
|
|
193
|
+
// UAT_REVOKED, and withIdentityFallback can give the LLM clear guidance.
|
|
194
|
+
const isInvalidGrant = errCode === 'invalid_grant' || errCode === 20064;
|
|
195
|
+
if (isInvalidGrant) {
|
|
196
|
+
// v1.3.15 — self-heal a benign refresh_token rotation race before
|
|
197
|
+
// declaring the 7-day chain dead. When cross-process mutual exclusion
|
|
198
|
+
// is defeated (lock-acquire timeout fallthrough above, or a transient
|
|
199
|
+
// mixed-version upgrade window where old/new instances use different
|
|
200
|
+
// lock paths), two processes can refresh with the same refresh_token;
|
|
201
|
+
// Feishu rotates it for the winner and rejects the loser with
|
|
202
|
+
// invalid_grant. By the time the loser lands here, the winner has very
|
|
203
|
+
// likely already persisted a fresh, valid, DIFFERENT token to disk.
|
|
204
|
+
// Re-read disk: if it now holds a different, still-valid token, our
|
|
205
|
+
// invalid_grant just means "our copy was rotated away" — adopt it and
|
|
206
|
+
// recover, instead of flipping to UAT_REVOKED and pushing the user
|
|
207
|
+
// through a needless `oauth` re-consent (the "授权操作通知 没撑过一晚上"
|
|
208
|
+
// symptom). Only when disk still holds the SAME (now-dead) refresh_token
|
|
209
|
+
// is this a genuine revocation. Covered by test-uat-lifecycle
|
|
210
|
+
// "invalid_grant + peer rotated fresh token to disk".
|
|
211
|
+
now = Math.floor(Date.now() / 1000);
|
|
212
|
+
// Best-effort re-sync from disk (a no-op if a peer/monitor already
|
|
213
|
+
// updated this client in memory). Then recover iff we now hold a
|
|
214
|
+
// DIFFERENT, still-valid token than the one we actually sent — this
|
|
215
|
+
// covers both the cross-process race (disk holds the winner) and the
|
|
216
|
+
// in-process / hot-reload race (client already holds the winner).
|
|
217
|
+
// Gating on the resulting client state, rather than on
|
|
218
|
+
// adoptPersistedUATIfNewer()'s return value, is what lets the
|
|
219
|
+
// hot-reload case recover (adopt is a no-op there). (Codex review, PR #111.)
|
|
220
|
+
adoptPersistedUATIfNewer(client);
|
|
221
|
+
if (client._uat
|
|
222
|
+
&& client._uatRefresh !== attemptedRefresh
|
|
223
|
+
&& client._uatExpires > now + 300) {
|
|
224
|
+
console.error('[feishu-user-plugin] UAT invalid_grant on the sent refresh_token; a different valid token is present (peer won the rotation) — adopted, no re-consent needed');
|
|
225
|
+
return client._uat;
|
|
226
|
+
}
|
|
227
|
+
try {
|
|
228
|
+
const { _refineIdentity, IdentityState } = require('./identity-state');
|
|
229
|
+
_refineIdentity(client, IdentityState.UAT_REVOKED);
|
|
230
|
+
} catch (_) { /* identity-state may not be loaded in CLI subcommands; non-fatal */ }
|
|
231
|
+
const err = new Error('UAT refresh_token rejected by Feishu (invalid_grant). The 7-day refresh chain is broken. Run: npx feishu-user-plugin oauth to re-authorize.');
|
|
232
|
+
err.uatRevoked = true;
|
|
233
|
+
throw err;
|
|
234
|
+
}
|
|
235
|
+
throw new Error(`UAT refresh failed (code=${errCode}: ${errMsg}). If persistent, run: npx feishu-user-plugin oauth.`);
|
|
236
|
+
}
|
|
132
237
|
client._uat = tokenData.access_token;
|
|
133
238
|
client._uatRefresh = tokenData.refresh_token || client._uatRefresh;
|
|
134
239
|
const expiresIn = typeof tokenData.expires_in === 'number' && tokenData.expires_in > 0 ? tokenData.expires_in : 7200;
|
|
@@ -164,9 +269,15 @@ async function withUAT(client, fn) {
|
|
|
164
269
|
} catch (err) {
|
|
165
270
|
const cls = classifyError(err);
|
|
166
271
|
if (cls.action === 'retry') {
|
|
167
|
-
|
|
272
|
+
// v1.3.14 — fall through into the auth-code check below instead of
|
|
273
|
+
// returning the retry result raw. A token rotated between our first
|
|
274
|
+
// attempt and the retry (peer process refreshed) can manifest as a
|
|
275
|
+
// 99991663/99991668 in the retry response, and we want refreshUAT to
|
|
276
|
+
// run before bubbling that up as a hard failure.
|
|
277
|
+
data = await fn(uat);
|
|
278
|
+
} else {
|
|
279
|
+
throw err;
|
|
168
280
|
}
|
|
169
|
-
throw err;
|
|
170
281
|
}
|
|
171
282
|
|
|
172
283
|
if (data.code === 99991668 || data.code === 99991663 || data.code === 99991677) {
|
|
@@ -242,4 +353,9 @@ module.exports = {
|
|
|
242
353
|
asUserOrApp,
|
|
243
354
|
persistUAT,
|
|
244
355
|
adoptPersistedUATIfNewer,
|
|
356
|
+
// v1.3.14 — exposed for testing (lifecycle + race tests). Not part of the
|
|
357
|
+
// stable API; do not use outside src/test-* or scripts/test-* harnesses.
|
|
358
|
+
uatLockPath,
|
|
359
|
+
acquireRefreshLock,
|
|
360
|
+
releaseRefreshLock,
|
|
245
361
|
};
|
package/src/cli.js
CHANGED
|
@@ -248,9 +248,10 @@ async function keepalive() {
|
|
|
248
248
|
const needSwitch = all && prevActive !== profileName;
|
|
249
249
|
try {
|
|
250
250
|
if (needSwitch) cred.setActiveProfile(profileName);
|
|
251
|
-
//
|
|
252
|
-
process.env
|
|
253
|
-
process.env
|
|
251
|
+
// v1.3.14 — direct field assignment is the source of truth; do NOT
|
|
252
|
+
// also set process.env (previous comment claimed LarkOfficialClient
|
|
253
|
+
// would read process.env, but loadUAT() is dead code and process.env
|
|
254
|
+
// pollution leaked between iterations of the --all loop).
|
|
254
255
|
const official = new LarkOfficialClient(env.LARK_APP_ID, env.LARK_APP_SECRET);
|
|
255
256
|
official._uat = env.LARK_USER_ACCESS_TOKEN;
|
|
256
257
|
official._uatRefresh = env.LARK_USER_REFRESH_TOKEN;
|
|
@@ -21,16 +21,21 @@ class LarkOfficialClient {
|
|
|
21
21
|
}
|
|
22
22
|
|
|
23
23
|
// --- UAT (User Access Token) Management ---
|
|
24
|
+
//
|
|
25
|
+
// v1.3.14 — preferred entry is src/server.js::loadUATFromEnv(client, env),
|
|
26
|
+
// which reads from a specific env block (credentials.json profile or harness
|
|
27
|
+
// env) rather than from process.env. `loadUAT()` below is kept for backward
|
|
28
|
+
// compat with `src/test-all.js` and any external callers, but new code in
|
|
29
|
+
// this repo should NOT use it — it can't see credentials.json profiles and
|
|
30
|
+
// doesn't participate in the profile-switch hot-reload path.
|
|
24
31
|
|
|
32
|
+
/** @deprecated v1.3.14 — use server.loadUATFromEnv(client, env) instead. */
|
|
25
33
|
loadUAT() {
|
|
26
34
|
const token = process.env.LARK_USER_ACCESS_TOKEN;
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
this._uatRefresh = refresh || null;
|
|
32
|
-
this._uatExpires = expires || this._decodeTokenExpiry(token);
|
|
33
|
-
}
|
|
35
|
+
if (!token) return;
|
|
36
|
+
this._uat = token;
|
|
37
|
+
this._uatRefresh = process.env.LARK_USER_REFRESH_TOKEN || null;
|
|
38
|
+
this._uatExpires = parseInt(process.env.LARK_UAT_EXPIRES || '0') || this._decodeTokenExpiry(token);
|
|
34
39
|
}
|
|
35
40
|
|
|
36
41
|
get hasUAT() {
|
|
@@ -63,6 +63,22 @@ module.exports = {
|
|
|
63
63
|
return { items: res.data.items || [] };
|
|
64
64
|
},
|
|
65
65
|
|
|
66
|
+
// Direct children of a single block — scoped, so it does not inherit the
|
|
67
|
+
// whole-document 500-block cap of getDocBlocks. Used by createDocTable to map
|
|
68
|
+
// a table's cells (and each cell's text block) reliably in large documents.
|
|
69
|
+
async getBlockChildren(documentId, blockId) {
|
|
70
|
+
const res = await this._asUserOrApp({
|
|
71
|
+
uatPath: `/open-apis/docx/v1/documents/${documentId}/blocks/${blockId}/children`,
|
|
72
|
+
query: { page_size: '500' },
|
|
73
|
+
sdkFn: () => this.client.docx.documentBlockChildren.get({
|
|
74
|
+
path: { document_id: documentId, block_id: blockId },
|
|
75
|
+
params: { page_size: 500 },
|
|
76
|
+
}),
|
|
77
|
+
label: 'getBlockChildren',
|
|
78
|
+
});
|
|
79
|
+
return { items: res.data.items || [] };
|
|
80
|
+
},
|
|
81
|
+
|
|
66
82
|
async createDocBlock(documentId, parentBlockId, children, index) {
|
|
67
83
|
const data = { children };
|
|
68
84
|
if (index !== undefined) data.index = index;
|
|
@@ -79,6 +95,85 @@ module.exports = {
|
|
|
79
95
|
return { blocks: res.data.children || [], fallbackWarning: res._fallbackWarning || null };
|
|
80
96
|
},
|
|
81
97
|
|
|
98
|
+
// Create a Feishu docx table (block_type=31) and optionally fill its cells —
|
|
99
|
+
// so callers never have to know docx block types. Added after field reports
|
|
100
|
+
// of agents guessing the table block_type (40 is wrong; 31 table / 32 cell).
|
|
101
|
+
// Flow:
|
|
102
|
+
// 1) create the table block with row_size/column_size — Feishu auto-creates
|
|
103
|
+
// the table_cell (32) children (row-major) and gives each cell an empty
|
|
104
|
+
// text block.
|
|
105
|
+
// 2) read the table back to map cell_id -> its auto-created text block.
|
|
106
|
+
// 3) fill: UPDATE each cell's existing text block (clean — no stray empty
|
|
107
|
+
// block) when present, else CREATE a text block in the cell.
|
|
108
|
+
// `cells` is an optional row-major 2D array of plain strings.
|
|
109
|
+
// Returns { tableBlockId, cells:[[cellId,...],...], rows, columns, filled, viaUser, fallbackWarning }.
|
|
110
|
+
async createDocTable(documentId, parentBlockId, { rows, columns, cells, columnWidth, headerRow, headerColumn, index } = {}) {
|
|
111
|
+
rows = Number(rows); columns = Number(columns);
|
|
112
|
+
if (!Number.isInteger(rows) || !Number.isInteger(columns) || rows < 1 || columns < 1) {
|
|
113
|
+
throw new Error('createDocTable: rows and columns must be integers >= 1');
|
|
114
|
+
}
|
|
115
|
+
const property = { row_size: rows, column_size: columns };
|
|
116
|
+
if (Array.isArray(columnWidth) && columnWidth.length === columns) property.column_width = columnWidth;
|
|
117
|
+
if (headerRow) property.header_row = true;
|
|
118
|
+
if (headerColumn) property.header_column = true;
|
|
119
|
+
const createBody = { children: [{ block_type: 31, table: { property } }] };
|
|
120
|
+
if (index !== undefined) createBody.index = index;
|
|
121
|
+
const created = await this._asUserOrApp({
|
|
122
|
+
uatPath: `/open-apis/docx/v1/documents/${documentId}/blocks/${parentBlockId}/children`,
|
|
123
|
+
method: 'POST',
|
|
124
|
+
body: createBody,
|
|
125
|
+
sdkFn: () => this.client.docx.documentBlockChildren.create({
|
|
126
|
+
path: { document_id: documentId, block_id: parentBlockId },
|
|
127
|
+
data: createBody,
|
|
128
|
+
}),
|
|
129
|
+
label: 'createDocTable',
|
|
130
|
+
});
|
|
131
|
+
const tableCreated = (created.data.children || [])[0];
|
|
132
|
+
const tableBlockId = tableCreated?.block_id;
|
|
133
|
+
if (!tableBlockId) throw new Error(`createDocTable: no table block_id returned: ${JSON.stringify(created.data).slice(0, 400)}`);
|
|
134
|
+
const viaUser = !!created._viaUser;
|
|
135
|
+
const fallbackWarning = created._fallbackWarning || null;
|
|
136
|
+
|
|
137
|
+
// Resolve the cell IDs. Prefer the create response; else fetch the table
|
|
138
|
+
// block's children directly (scoped — NOT the whole-doc getDocBlocks, which
|
|
139
|
+
// caps at 500 blocks and would silently lose an appended table's cells in a
|
|
140
|
+
// large document). Fail loud rather than silently dropping requested content.
|
|
141
|
+
let flatCellIds = tableCreated.table?.cells || tableCreated.children || [];
|
|
142
|
+
if (flatCellIds.length < rows * columns) {
|
|
143
|
+
flatCellIds = ((await this.getBlockChildren(documentId, tableBlockId)).items || []).map(b => b.block_id);
|
|
144
|
+
}
|
|
145
|
+
if (flatCellIds.length < rows * columns) {
|
|
146
|
+
throw new Error(`createDocTable: created table ${tableBlockId} but resolved only ${flatCellIds.length}/${rows * columns} cells — aborting fill to avoid silently dropping content.`);
|
|
147
|
+
}
|
|
148
|
+
const grid = [];
|
|
149
|
+
for (let r = 0; r < rows; r++) grid.push(flatCellIds.slice(r * columns, (r + 1) * columns));
|
|
150
|
+
|
|
151
|
+
let filled = 0;
|
|
152
|
+
if (Array.isArray(cells)) {
|
|
153
|
+
for (let r = 0; r < rows; r++) {
|
|
154
|
+
for (let c = 0; c < columns; c++) {
|
|
155
|
+
const content = cells[r] ? cells[r][c] : undefined;
|
|
156
|
+
if (content === undefined || content === null || content === '') continue;
|
|
157
|
+
const cellId = grid[r][c];
|
|
158
|
+
if (!cellId) throw new Error(`createDocTable: missing cell id at row ${r}, col ${c}`);
|
|
159
|
+
// Each fresh cell auto-creates exactly one empty text block — UPDATE it
|
|
160
|
+
// (clean) rather than CREATE a second. Scoped per-cell fetch stays
|
|
161
|
+
// correct regardless of overall document size.
|
|
162
|
+
const cellChildren = (await this.getBlockChildren(documentId, cellId)).items || [];
|
|
163
|
+
const textChild = cellChildren.find(b => b.block_type === 2);
|
|
164
|
+
const elements = { elements: [{ text_run: { content: String(content) } }] };
|
|
165
|
+
if (textChild) {
|
|
166
|
+
await this.updateDocBlock(documentId, textChild.block_id, { update_text_elements: elements });
|
|
167
|
+
} else {
|
|
168
|
+
await this.createDocBlock(documentId, cellId, [{ block_type: 2, text: elements }]);
|
|
169
|
+
}
|
|
170
|
+
filled++;
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
return { tableBlockId, cells: grid, rows, columns, filled, viaUser, fallbackWarning };
|
|
175
|
+
},
|
|
176
|
+
|
|
82
177
|
async updateDocBlock(documentId, blockId, updateBody) {
|
|
83
178
|
const res = await this._asUserOrApp({
|
|
84
179
|
uatPath: `/open-apis/docx/v1/documents/${documentId}/blocks/${blockId}`,
|
package/src/error-codes.js
CHANGED
|
@@ -27,7 +27,8 @@ const FAILURE_MAP = {
|
|
|
27
27
|
19001: { action: 'uat', reason: 'bot_chat_not_found' },
|
|
28
28
|
|
|
29
29
|
// UAT revoked — refresh_token explicitly invalid_grant (user revoked OAuth
|
|
30
|
-
// or
|
|
30
|
+
// or the 7-day refresh_token window elapsed without any successful refresh
|
|
31
|
+
// to roll it forward). The live trigger for this code lives in
|
|
31
32
|
// identity-state.js::_classifyUatFailure (UAT REST throws / returns 20064);
|
|
32
33
|
// this entry exists for *symmetry* — should a bot-side surface ever return
|
|
33
34
|
// 20064 (it shouldn't, bot uses app_access_token not refresh_token), the
|
package/src/oauth.js
CHANGED
|
@@ -16,6 +16,7 @@ const http = require('http');
|
|
|
16
16
|
const { execSync } = require('child_process');
|
|
17
17
|
const credentialsModule = require('./auth/credentials');
|
|
18
18
|
const legacyConfig = require('./config');
|
|
19
|
+
const { fetchWithTimeout } = require('./utils');
|
|
19
20
|
|
|
20
21
|
// v1.3.9: profile-aware. Accepts `--profile <name>` (defaults to credentials.json::active);
|
|
21
22
|
// reads APP_ID/SECRET from that profile, persists UAT back to that profile.
|
|
@@ -105,10 +106,11 @@ if (!APP_ID || !APP_SECRET) {
|
|
|
105
106
|
async function getAppInfo() {
|
|
106
107
|
try {
|
|
107
108
|
// Get app_access_token to query app details
|
|
108
|
-
const tokenRes = await
|
|
109
|
+
const tokenRes = await fetchWithTimeout('https://open.feishu.cn/open-apis/auth/v3/app_access_token/internal', {
|
|
109
110
|
method: 'POST',
|
|
110
111
|
headers: { 'content-type': 'application/json' },
|
|
111
112
|
body: JSON.stringify({ app_id: APP_ID, app_secret: APP_SECRET }),
|
|
113
|
+
timeoutMs: 10000,
|
|
112
114
|
});
|
|
113
115
|
const tokenData = await tokenRes.json();
|
|
114
116
|
if (!tokenData.app_access_token) {
|
|
@@ -118,8 +120,9 @@ async function getAppInfo() {
|
|
|
118
120
|
|
|
119
121
|
// Get app info — try the direct app query first, fall back to underauditlist
|
|
120
122
|
let appName = null;
|
|
121
|
-
const directRes = await
|
|
123
|
+
const directRes = await fetchWithTimeout(`https://open.feishu.cn/open-apis/application/v6/applications/${APP_ID}?lang=zh_cn`, {
|
|
122
124
|
headers: { 'Authorization': `Bearer ${tokenData.app_access_token}` },
|
|
125
|
+
timeoutMs: 10000,
|
|
123
126
|
});
|
|
124
127
|
const directData = await directRes.json();
|
|
125
128
|
appName = directData?.data?.app?.app_name;
|
|
@@ -134,8 +137,9 @@ async function getAppInfo() {
|
|
|
134
137
|
} else if (directData?.code && directData.code !== 0) {
|
|
135
138
|
console.error(`[oauth] App name resolve failed: code=${directData.code} msg=${directData.msg}`);
|
|
136
139
|
}
|
|
137
|
-
const listRes = await
|
|
140
|
+
const listRes = await fetchWithTimeout('https://open.feishu.cn/open-apis/application/v6/applications/underauditlist?lang=zh_cn&page_size=1', {
|
|
138
141
|
headers: { 'Authorization': `Bearer ${tokenData.app_access_token}` },
|
|
142
|
+
timeoutMs: 10000,
|
|
139
143
|
});
|
|
140
144
|
const listData = await listRes.json();
|
|
141
145
|
appName = listData?.data?.items?.[0]?.app_name;
|
|
@@ -156,11 +160,15 @@ async function exchangeCode(code) {
|
|
|
156
160
|
code,
|
|
157
161
|
redirect_uri: REDIRECT_URI,
|
|
158
162
|
};
|
|
159
|
-
|
|
160
|
-
|
|
163
|
+
// v1.3.14 — redact `code` too: the authorization code is short-lived but
|
|
164
|
+
// it IS an exchangeable credential while it's still valid (~60s pre-exchange).
|
|
165
|
+
// Logging it to stdout means transcripts retain a usable credential window.
|
|
166
|
+
console.log('Token exchange request:', JSON.stringify({ ...body, client_secret: '***', code: '***' }));
|
|
167
|
+
const tokenRes = await fetchWithTimeout('https://open.feishu.cn/open-apis/authen/v2/oauth/token', {
|
|
161
168
|
method: 'POST',
|
|
162
169
|
headers: { 'content-type': 'application/json' },
|
|
163
170
|
body: JSON.stringify(body),
|
|
171
|
+
timeoutMs: 15000,
|
|
164
172
|
});
|
|
165
173
|
const raw = await tokenRes.text();
|
|
166
174
|
// v1.3.13 security followup: don't log the full raw body — it contains the
|
|
@@ -236,11 +244,15 @@ const server = http.createServer(async (req, res) => {
|
|
|
236
244
|
|
|
237
245
|
const hasRefresh = !!tokenData.refresh_token;
|
|
238
246
|
res.writeHead(200, { 'content-type': 'text/html; charset=utf-8' });
|
|
247
|
+
// v1.3.14 — do not display any token bytes in the browser callback.
|
|
248
|
+
// Browser history / screenshots / OS screen recordings would otherwise
|
|
249
|
+
// retain the first 20 chars of the access token. Replaced with a length
|
|
250
|
+
// attestation so the user still sees the flow succeeded.
|
|
239
251
|
res.end(`<h2>✅ 授权成功!</h2>
|
|
240
|
-
<p>access_token:
|
|
252
|
+
<p>access_token: ✅ 已获取(${tokenData.access_token.length} chars)</p>
|
|
241
253
|
<p>scope: ${tokenData.scope}</p>
|
|
242
254
|
<p>expires_in: ${tokenData.expires_in}s</p>
|
|
243
|
-
<p>refresh_token: ${hasRefresh ? '✅ 已获取(
|
|
255
|
+
<p>refresh_token: ${hasRefresh ? '✅ 已获取(7天有效,支持自动续期;每次 refresh 滚动续 7 天)' : '❌ 未返回(token 将在 2 小时后过期,需重新授权)'}</p>
|
|
244
256
|
<p>已保存到 MCP 配置文件,可以关闭此页面。</p>`);
|
|
245
257
|
|
|
246
258
|
console.log('\n=== OAuth 授权成功 ===');
|
package/src/server.js
CHANGED
|
@@ -83,8 +83,15 @@ let nonOwnerPollTimer = null;
|
|
|
83
83
|
let _ownerStartCallbacks = [];
|
|
84
84
|
|
|
85
85
|
// Lark Desktop reactor state (v1.3.11 §A) — owned by the heartbeat callback.
|
|
86
|
+
// v1.3.14 — `_lastSwitchAt = 0` was previously misinterpreted as "no recent
|
|
87
|
+
// switch" by the lark-desktop reactor's debounce, so a cold-start owner that
|
|
88
|
+
// hadn't observed any switch would still fire-on-first-tick if the snapshot
|
|
89
|
+
// looked stale. We rebaseline `_lastSwitchAt` to the owner-claim wallclock on
|
|
90
|
+
// first reactor invocation so the cold start window has the same debounce
|
|
91
|
+
// budget as a long-running owner. See `_runLarkDesktopReactor` below.
|
|
86
92
|
let _lastHashMtimes = {};
|
|
87
93
|
let _lastSwitchAt = 0;
|
|
94
|
+
let _reactorFirstTickDone = false;
|
|
88
95
|
const _seenUnboundHashes = new Set();
|
|
89
96
|
|
|
90
97
|
function _onBecomeOwner(cb) { _ownerStartCallbacks.push(cb); }
|
|
@@ -157,11 +164,14 @@ function getOfficialClient() {
|
|
|
157
164
|
return officialClient;
|
|
158
165
|
}
|
|
159
166
|
|
|
160
|
-
//
|
|
161
|
-
//
|
|
162
|
-
//
|
|
163
|
-
//
|
|
167
|
+
// UAT loader sourced from a specific env block (credentials.json profile or
|
|
168
|
+
// harness env). Used both at startup and as the hot-reload entry point from
|
|
169
|
+
// credMonitor.onUatChange. When `env` has no UAT (user nuked the token via
|
|
170
|
+
// `oauth` --revoke or manual edit), clears the in-memory copy instead of
|
|
164
171
|
// silently leaving the stale token in place.
|
|
172
|
+
//
|
|
173
|
+
// v1.3.14 — replaced the dead `LarkOfficialClient.loadUAT()` helper that
|
|
174
|
+
// read from process.env. All UAT loading now goes through this function.
|
|
165
175
|
function loadUATFromEnv(client, env) {
|
|
166
176
|
const token = env?.LARK_USER_ACCESS_TOKEN || null;
|
|
167
177
|
const refresh = env?.LARK_USER_REFRESH_TOKEN || null;
|
|
@@ -274,6 +284,14 @@ function _credMtime() {
|
|
|
274
284
|
// the WS client with the new profile's events list).
|
|
275
285
|
function _runLarkDesktopReactor() {
|
|
276
286
|
const ld = require('./auth/lark-desktop');
|
|
287
|
+
// v1.3.14 — cold-start debounce: prevent the first reactor tick from
|
|
288
|
+
// treating a long-pre-existing snapshot as a "recent switch". By stamping
|
|
289
|
+
// _lastSwitchAt at first tick we give the debounce the same baseline as a
|
|
290
|
+
// long-running owner that just hot-took-over.
|
|
291
|
+
if (!_reactorFirstTickDone) {
|
|
292
|
+
_reactorFirstTickDone = true;
|
|
293
|
+
if (_lastSwitchAt === 0) _lastSwitchAt = Date.now();
|
|
294
|
+
}
|
|
277
295
|
const out = ld.detectSwitch({
|
|
278
296
|
prevSnapshot: _lastHashMtimes,
|
|
279
297
|
lastSwitchAt: _lastSwitchAt,
|
package/src/setup.js
CHANGED
|
@@ -102,10 +102,12 @@ async function main() {
|
|
|
102
102
|
// Validate app credentials
|
|
103
103
|
console.log('\nValidating app credentials...');
|
|
104
104
|
try {
|
|
105
|
-
const
|
|
105
|
+
const { fetchWithTimeout } = require('./utils');
|
|
106
|
+
const res = await fetchWithTimeout('https://open.feishu.cn/open-apis/auth/v3/app_access_token/internal', {
|
|
106
107
|
method: 'POST',
|
|
107
108
|
headers: { 'content-type': 'application/json' },
|
|
108
109
|
body: JSON.stringify({ app_id: appId, app_secret: appSecret }),
|
|
110
|
+
timeoutMs: 10000,
|
|
109
111
|
});
|
|
110
112
|
const data = await res.json();
|
|
111
113
|
if (data.app_access_token) {
|
package/src/test-all.js
CHANGED
|
@@ -4,6 +4,12 @@
|
|
|
4
4
|
* Sends test messages to "飞书plugin测试群".
|
|
5
5
|
*/
|
|
6
6
|
require('dotenv').config({ path: require('path').join(__dirname, '..', '.env') });
|
|
7
|
+
|
|
8
|
+
// v1.3.14 — backfill process.env from canonical credentials store so
|
|
9
|
+
// `npm test` works for users who moved creds to ~/.feishu-user-plugin/.
|
|
10
|
+
// See src/auth/env-backfill.js for full rationale.
|
|
11
|
+
require('./auth/env-backfill').backfillFromCanonical();
|
|
12
|
+
|
|
7
13
|
const { LarkUserClient } = require('./clients/user');
|
|
8
14
|
const { LarkOfficialClient } = require('./clients/official');
|
|
9
15
|
|
|
@@ -316,6 +322,10 @@ async function main() {
|
|
|
316
322
|
main().catch(console.error).finally(() => {
|
|
317
323
|
// Fixture-based unit test — runs regardless of credential availability
|
|
318
324
|
require('./test-read-doc-markdown').run();
|
|
325
|
+
require('./test-doc-table').run().catch(e => {
|
|
326
|
+
console.error('doc-table: FAIL', e);
|
|
327
|
+
process.exitCode = 1;
|
|
328
|
+
});
|
|
319
329
|
require('./test-switch-profile').run().catch(e => {
|
|
320
330
|
console.error('switch-profile-e2e: FAIL', e);
|
|
321
331
|
process.exitCode = 1;
|
|
@@ -333,6 +343,11 @@ main().catch(console.error).finally(() => {
|
|
|
333
343
|
console.error('with-uat-retry: FAIL', e);
|
|
334
344
|
process.exitCode = 1;
|
|
335
345
|
});
|
|
346
|
+
require('./test-uat-lifecycle').run().catch(e => {
|
|
347
|
+
console.error('uat-lifecycle: FAIL', e);
|
|
348
|
+
process.exitCode = 1;
|
|
349
|
+
});
|
|
350
|
+
require('./test-cookie-heartbeat').run();
|
|
336
351
|
require('./test-populate-sender-names').run().catch(e => {
|
|
337
352
|
console.error('populate-sender-names: FAIL', e);
|
|
338
353
|
process.exitCode = 1;
|
|
@@ -1,10 +1,14 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
/**
|
|
3
3
|
* Comprehensive test: exercises every tool category in feishu-user-plugin.
|
|
4
|
-
* Reads credentials from .env,
|
|
4
|
+
* Reads credentials from .env, then backfills from canonical store. Tests
|
|
5
|
+
* each layer independently.
|
|
5
6
|
*/
|
|
6
7
|
const path = require('path');
|
|
7
8
|
require('dotenv').config({ path: path.join(__dirname, '..', '.env') });
|
|
9
|
+
// v1.3.14 — let users on canonical store run this without exporting env vars
|
|
10
|
+
// or maintaining a stale .env. .env still wins via dotenv when present.
|
|
11
|
+
require('./auth/env-backfill').backfillFromCanonical();
|
|
8
12
|
|
|
9
13
|
const { LarkUserClient } = require('./clients/user');
|
|
10
14
|
const { LarkOfficialClient } = require('./clients/official');
|
|
@@ -274,7 +278,8 @@ async function testUAT() {
|
|
|
274
278
|
}
|
|
275
279
|
|
|
276
280
|
async function main() {
|
|
277
|
-
|
|
281
|
+
const pkgVersion = require('../package.json').version;
|
|
282
|
+
console.log(`=== feishu-user-plugin v${pkgVersion} — Comprehensive Test ===\n`);
|
|
278
283
|
|
|
279
284
|
await testUserIdentity();
|
|
280
285
|
console.log('');
|