feishu-user-plugin 1.3.2 → 1.3.4
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 +37 -0
- package/README.md +19 -4
- package/package.json +2 -2
- package/skills/feishu-user-plugin/SKILL.md +3 -3
- package/skills/feishu-user-plugin/references/CLAUDE.md +114 -5
- package/src/client.js +4 -4
- package/src/doc-blocks.js +70 -0
- package/src/error-codes.js +78 -0
- package/src/index.js +318 -68
- package/src/oauth.js +6 -1
- package/src/official.js +584 -15
- package/src/resolver.js +151 -0
- package/src/utils.js +13 -0
package/src/official.js
CHANGED
|
@@ -1,4 +1,7 @@
|
|
|
1
1
|
const lark = require('@larksuiteoapi/node-sdk');
|
|
2
|
+
const { fetchWithTimeout } = require('./utils');
|
|
3
|
+
const { classifyError } = require('./error-codes');
|
|
4
|
+
const { buildEmptyImageBlock, buildReplaceImagePayload } = require('./doc-blocks');
|
|
2
5
|
|
|
3
6
|
// Redirect all Lark SDK logs to stderr.
|
|
4
7
|
// The SDK's defaultLogger.error uses console.log (stdout), which corrupts
|
|
@@ -39,6 +42,50 @@ class LarkOfficialClient {
|
|
|
39
42
|
return !!this._uat;
|
|
40
43
|
}
|
|
41
44
|
|
|
45
|
+
// Fetches (and caches) an app_access_token directly via the internal endpoint.
|
|
46
|
+
// Avoids relying on SDK-internal token-manager APIs that may change across versions.
|
|
47
|
+
async _getAppToken() {
|
|
48
|
+
const now = Math.floor(Date.now() / 1000);
|
|
49
|
+
if (this._appToken && this._appTokenExpires > now + 60) return this._appToken;
|
|
50
|
+
const res = await fetchWithTimeout('https://open.feishu.cn/open-apis/auth/v3/app_access_token/internal', {
|
|
51
|
+
method: 'POST',
|
|
52
|
+
headers: { 'content-type': 'application/json' },
|
|
53
|
+
body: JSON.stringify({ app_id: this.appId, app_secret: this.appSecret }),
|
|
54
|
+
timeoutMs: 10000,
|
|
55
|
+
});
|
|
56
|
+
const data = await res.json();
|
|
57
|
+
if (data.code !== 0 || !data.app_access_token) {
|
|
58
|
+
throw new Error(`app_access_token failed: ${data.code}: ${data.msg || 'unknown'}`);
|
|
59
|
+
}
|
|
60
|
+
this._appToken = data.app_access_token;
|
|
61
|
+
this._appTokenExpires = now + (typeof data.expire === 'number' ? data.expire : 7200);
|
|
62
|
+
return this._appToken;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// Probe APP_ID/SECRET validity by requesting a tenant access token.
|
|
66
|
+
// Catches the common "user's Claude filled in a wrong/stale APP_ID" failure mode
|
|
67
|
+
// (observed in production: 周宇's machine ran with an APP_ID nobody recognized,
|
|
68
|
+
// causing all Official API calls to 401 with cryptic messages that looked like
|
|
69
|
+
// MCP "掉线" to the user). Returns { valid, appId, appName?, error? }.
|
|
70
|
+
async verifyApp() {
|
|
71
|
+
try {
|
|
72
|
+
const token = await this._getAppToken();
|
|
73
|
+
// Try to fetch app display name (best-effort; requires application scope)
|
|
74
|
+
let appName = null;
|
|
75
|
+
try {
|
|
76
|
+
const infoRes = await fetchWithTimeout(`https://open.feishu.cn/open-apis/application/v6/applications/${this.appId}?lang=zh_cn`, {
|
|
77
|
+
headers: { 'Authorization': `Bearer ${token}` },
|
|
78
|
+
timeoutMs: 10000,
|
|
79
|
+
});
|
|
80
|
+
const info = await infoRes.json();
|
|
81
|
+
if (info.code === 0) appName = info.data?.app?.app_name || null;
|
|
82
|
+
} catch (_) { /* name is best-effort; valid creds still matter most */ }
|
|
83
|
+
return { valid: true, appId: this.appId, appName };
|
|
84
|
+
} catch (e) {
|
|
85
|
+
return { valid: false, appId: this.appId, error: e.message };
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
42
89
|
async _getValidUAT() {
|
|
43
90
|
if (!this._uat) throw new Error('No user_access_token. Run: npx feishu-user-plugin oauth');
|
|
44
91
|
|
|
@@ -53,7 +100,7 @@ class LarkOfficialClient {
|
|
|
53
100
|
async _refreshUAT() {
|
|
54
101
|
if (!this._uatRefresh) throw new Error('UAT expired and no refresh token. Run: npx feishu-user-plugin oauth');
|
|
55
102
|
|
|
56
|
-
const res = await
|
|
103
|
+
const res = await fetchWithTimeout('https://open.feishu.cn/open-apis/authen/v2/oauth/token', {
|
|
57
104
|
method: 'POST',
|
|
58
105
|
headers: { 'content-type': 'application/json' },
|
|
59
106
|
body: JSON.stringify({
|
|
@@ -94,6 +141,14 @@ class LarkOfficialClient {
|
|
|
94
141
|
const data = await fn(uat);
|
|
95
142
|
// Known auth error codes: 99991668 (invalid), 99991663 (expired), 99991677 (auth_expired)
|
|
96
143
|
if (data.code === 99991668 || data.code === 99991663 || data.code === 99991677) {
|
|
144
|
+
// 99991668 is overloaded: "invalid token" (→ refresh helps) vs
|
|
145
|
+
// "endpoint doesn't support UAT at all" (→ refresh is pointless, and
|
|
146
|
+
// worse, it consumes a one-shot refresh_token rotation). The second
|
|
147
|
+
// case is identifiable by the msg "user access token not support" or
|
|
148
|
+
// "not support". If so, surface the code to the caller without refresh.
|
|
149
|
+
if (data.code === 99991668 && typeof data.msg === 'string' && /not support/i.test(data.msg)) {
|
|
150
|
+
return data;
|
|
151
|
+
}
|
|
97
152
|
// Token invalid/expired — try refresh once
|
|
98
153
|
uat = await this._refreshUAT();
|
|
99
154
|
return fn(uat);
|
|
@@ -102,8 +157,20 @@ class LarkOfficialClient {
|
|
|
102
157
|
}
|
|
103
158
|
|
|
104
159
|
// Generic UAT REST helper. Returns parsed JSON ({code, msg, data}).
|
|
160
|
+
// Array query values are expanded to repeated keys (period_ids=a&period_ids=b)
|
|
161
|
+
// because several Feishu endpoints (OKR, calendar) rely on that convention.
|
|
105
162
|
async _uatREST(method, path, { body, query } = {}) {
|
|
106
|
-
|
|
163
|
+
let qs = '';
|
|
164
|
+
if (query) {
|
|
165
|
+
const sp = new URLSearchParams();
|
|
166
|
+
for (const [k, v] of Object.entries(query)) {
|
|
167
|
+
if (v === undefined || v === null) continue;
|
|
168
|
+
if (Array.isArray(v)) { for (const item of v) sp.append(k, String(item)); }
|
|
169
|
+
else sp.append(k, String(v));
|
|
170
|
+
}
|
|
171
|
+
const str = sp.toString();
|
|
172
|
+
if (str) qs = '?' + str;
|
|
173
|
+
}
|
|
107
174
|
const url = 'https://open.feishu.cn' + path + qs;
|
|
108
175
|
return this._withUAT(async (uat) => {
|
|
109
176
|
const headers = { 'Authorization': `Bearer ${uat}` };
|
|
@@ -112,31 +179,55 @@ class LarkOfficialClient {
|
|
|
112
179
|
headers['content-type'] = 'application/json';
|
|
113
180
|
init.body = JSON.stringify(body);
|
|
114
181
|
}
|
|
115
|
-
const res = await
|
|
182
|
+
const res = await fetchWithTimeout(url, init);
|
|
116
183
|
return res.json();
|
|
117
184
|
});
|
|
118
185
|
}
|
|
119
186
|
|
|
120
187
|
// Try UAT first (for resources likely owned by the user), fall back to app SDK on failure.
|
|
121
|
-
// Returns SDK-shaped {code, msg, data}.
|
|
188
|
+
// Returns SDK-shaped {code, msg, data, _viaUser}. _viaUser is true iff the UAT call succeeded;
|
|
189
|
+
// callers can surface this to distinguish "created by user" vs "created by app" for resources
|
|
190
|
+
// whose ownership matters (docs, bitables, folders).
|
|
191
|
+
//
|
|
192
|
+
// When BOTH paths fail (common for OKR/Calendar if neither UAT nor app has the scope),
|
|
193
|
+
// the final error includes the UAT-side reason too, so the user can tell whether they
|
|
194
|
+
// need a new OAuth (UAT missing scope) or a different app (app missing scope).
|
|
122
195
|
async _asUserOrApp({ uatPath, method = 'GET', body, query, sdkFn, label }) {
|
|
196
|
+
let uatSummary = null;
|
|
123
197
|
if (this.hasUAT) {
|
|
124
198
|
try {
|
|
125
199
|
const data = await this._uatREST(method, uatPath, { body, query });
|
|
126
|
-
if (data.code === 0)
|
|
127
|
-
|
|
200
|
+
if (data.code === 0) {
|
|
201
|
+
data._viaUser = true;
|
|
202
|
+
return data;
|
|
203
|
+
}
|
|
204
|
+
uatSummary = `as user: code=${data.code} msg=${data.msg}`;
|
|
205
|
+
console.error(`[feishu-user-plugin] ${label} ${uatSummary}, retrying as app`);
|
|
128
206
|
} catch (err) {
|
|
207
|
+
uatSummary = `as user: ${err.message}`;
|
|
129
208
|
console.error(`[feishu-user-plugin] ${label} as user threw (${err.message}), retrying as app`);
|
|
130
209
|
}
|
|
131
210
|
}
|
|
132
|
-
|
|
211
|
+
try {
|
|
212
|
+
const appData = await this._safeSDKCall(sdkFn, label);
|
|
213
|
+
if (appData && typeof appData === 'object') appData._viaUser = false;
|
|
214
|
+
return appData;
|
|
215
|
+
} catch (appErr) {
|
|
216
|
+
if (uatSummary) {
|
|
217
|
+
const err = new Error(`${label} failed on both identities. ${uatSummary}. as app: ${appErr.message}`);
|
|
218
|
+
err.uatSummary = uatSummary;
|
|
219
|
+
err.appError = appErr;
|
|
220
|
+
throw err;
|
|
221
|
+
}
|
|
222
|
+
throw appErr;
|
|
223
|
+
}
|
|
133
224
|
}
|
|
134
225
|
|
|
135
226
|
async listChatsAsUser({ pageSize = 20, pageToken } = {}) {
|
|
136
227
|
const params = new URLSearchParams({ page_size: String(pageSize) });
|
|
137
228
|
if (pageToken) params.set('page_token', pageToken);
|
|
138
229
|
const data = await this._withUAT(async (uat) => {
|
|
139
|
-
const res = await
|
|
230
|
+
const res = await fetchWithTimeout(`https://open.feishu.cn/open-apis/im/v1/chats?${params}`, {
|
|
140
231
|
headers: { 'Authorization': `Bearer ${uat}` },
|
|
141
232
|
});
|
|
142
233
|
return res.json();
|
|
@@ -158,7 +249,7 @@ class LarkOfficialClient {
|
|
|
158
249
|
if (endTime) params.set('end_time', endTime);
|
|
159
250
|
if (pageToken) params.set('page_token', pageToken);
|
|
160
251
|
const data = await this._withUAT(async (uat) => {
|
|
161
|
-
const res = await
|
|
252
|
+
const res = await fetchWithTimeout(`https://open.feishu.cn/open-apis/im/v1/messages?${params}`, {
|
|
162
253
|
headers: { 'Authorization': `Bearer ${uat}` },
|
|
163
254
|
});
|
|
164
255
|
return res.json();
|
|
@@ -198,6 +289,57 @@ class LarkOfficialClient {
|
|
|
198
289
|
return this._formatMessage(res.data);
|
|
199
290
|
}
|
|
200
291
|
|
|
292
|
+
// Download a resource (image/file) attached to a message.
|
|
293
|
+
// Tries UAT first (works for any chat the user is in), falls back to app token
|
|
294
|
+
// (requires the bot to be in the same chat — Feishu restriction).
|
|
295
|
+
// resourceType: 'image' | 'file'. Returns { base64, mimeType, viaUser }.
|
|
296
|
+
async downloadMessageResource(messageId, fileKey, resourceType = 'image') {
|
|
297
|
+
const path = `/open-apis/im/v1/messages/${encodeURIComponent(messageId)}/resources/${encodeURIComponent(fileKey)}?type=${encodeURIComponent(resourceType)}`;
|
|
298
|
+
const url = 'https://open.feishu.cn' + path;
|
|
299
|
+
|
|
300
|
+
// Attempt 1: user identity
|
|
301
|
+
if (this.hasUAT) {
|
|
302
|
+
try {
|
|
303
|
+
const uat = await this._getValidUAT();
|
|
304
|
+
const res = await fetchWithTimeout(url, {
|
|
305
|
+
headers: { 'Authorization': `Bearer ${uat}` },
|
|
306
|
+
timeoutMs: 60000,
|
|
307
|
+
});
|
|
308
|
+
if (res.ok && !res.headers.get('content-type')?.includes('application/json')) {
|
|
309
|
+
const buf = Buffer.from(await res.arrayBuffer());
|
|
310
|
+
return {
|
|
311
|
+
base64: buf.toString('base64'),
|
|
312
|
+
mimeType: res.headers.get('content-type') || 'application/octet-stream',
|
|
313
|
+
bytes: buf.length,
|
|
314
|
+
viaUser: true,
|
|
315
|
+
};
|
|
316
|
+
}
|
|
317
|
+
const errJson = await res.json().catch(() => null);
|
|
318
|
+
console.error(`[feishu-user-plugin] downloadMessageResource as user failed: ${errJson?.code}: ${errJson?.msg || res.statusText}, retrying as app`);
|
|
319
|
+
} catch (e) {
|
|
320
|
+
console.error(`[feishu-user-plugin] downloadMessageResource as user threw (${e.message}), retrying as app`);
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
// Attempt 2: app identity
|
|
325
|
+
const token = await this._getAppToken();
|
|
326
|
+
const res = await fetchWithTimeout(url, {
|
|
327
|
+
headers: { 'Authorization': `Bearer ${token}` },
|
|
328
|
+
timeoutMs: 60000,
|
|
329
|
+
});
|
|
330
|
+
if (!res.ok || res.headers.get('content-type')?.includes('application/json')) {
|
|
331
|
+
const errJson = await res.json().catch(() => null);
|
|
332
|
+
throw new Error(`downloadMessageResource failed: ${errJson?.code}: ${errJson?.msg || res.statusText}. Note: app identity requires the bot to be in the same chat.`);
|
|
333
|
+
}
|
|
334
|
+
const buf = Buffer.from(await res.arrayBuffer());
|
|
335
|
+
return {
|
|
336
|
+
base64: buf.toString('base64'),
|
|
337
|
+
mimeType: res.headers.get('content-type') || 'application/octet-stream',
|
|
338
|
+
bytes: buf.length,
|
|
339
|
+
viaUser: false,
|
|
340
|
+
};
|
|
341
|
+
}
|
|
342
|
+
|
|
201
343
|
async replyMessage(messageId, text, msgType = 'text') {
|
|
202
344
|
const content = msgType === 'text' ? JSON.stringify({ text }) : text;
|
|
203
345
|
const res = await this._safeSDKCall(
|
|
@@ -411,7 +553,7 @@ class LarkOfficialClient {
|
|
|
411
553
|
return { content: res.data.content };
|
|
412
554
|
}
|
|
413
555
|
|
|
414
|
-
async createDoc(title, folderId) {
|
|
556
|
+
async createDoc(title, folderId, { wikiSpaceId, wikiParentNodeToken } = {}) {
|
|
415
557
|
const res = await this._asUserOrApp({
|
|
416
558
|
uatPath: `/open-apis/docx/v1/documents`,
|
|
417
559
|
method: 'POST',
|
|
@@ -419,7 +561,18 @@ class LarkOfficialClient {
|
|
|
419
561
|
sdkFn: () => this.client.docx.document.create({ data: { title, folder_token: folderId || '' } }),
|
|
420
562
|
label: 'createDoc',
|
|
421
563
|
});
|
|
422
|
-
|
|
564
|
+
const documentId = res.data.document?.document_id;
|
|
565
|
+
const out = { documentId, viaUser: !!res._viaUser };
|
|
566
|
+
if (documentId && wikiSpaceId) {
|
|
567
|
+
try {
|
|
568
|
+
const node = await this.attachToWiki(wikiSpaceId, 'docx', documentId, wikiParentNodeToken);
|
|
569
|
+
if (node?.node_token) out.wikiNodeToken = node.node_token;
|
|
570
|
+
else if (node?.task_id) out.wikiAttachTaskId = node.task_id;
|
|
571
|
+
} catch (e) {
|
|
572
|
+
out.wikiAttachError = e.message;
|
|
573
|
+
}
|
|
574
|
+
}
|
|
575
|
+
return out;
|
|
423
576
|
}
|
|
424
577
|
|
|
425
578
|
async getDocBlocks(documentId) {
|
|
@@ -488,7 +641,7 @@ class LarkOfficialClient {
|
|
|
488
641
|
|
|
489
642
|
// --- Bitable ---
|
|
490
643
|
|
|
491
|
-
async createBitable(name, folderId) {
|
|
644
|
+
async createBitable(name, folderId, { wikiSpaceId, wikiParentNodeToken } = {}) {
|
|
492
645
|
const data = {};
|
|
493
646
|
if (name) data.name = name;
|
|
494
647
|
if (folderId) data.folder_token = folderId;
|
|
@@ -499,7 +652,18 @@ class LarkOfficialClient {
|
|
|
499
652
|
sdkFn: () => this.client.bitable.app.create({ data }),
|
|
500
653
|
label: 'createBitable',
|
|
501
654
|
});
|
|
502
|
-
|
|
655
|
+
const appToken = res.data.app?.app_token;
|
|
656
|
+
const out = { appToken, name: res.data.app?.name, url: res.data.app?.url, viaUser: !!res._viaUser };
|
|
657
|
+
if (appToken && wikiSpaceId) {
|
|
658
|
+
try {
|
|
659
|
+
const node = await this.attachToWiki(wikiSpaceId, 'bitable', appToken, wikiParentNodeToken);
|
|
660
|
+
if (node?.node_token) out.wikiNodeToken = node.node_token;
|
|
661
|
+
else if (node?.task_id) out.wikiAttachTaskId = node.task_id;
|
|
662
|
+
} catch (e) {
|
|
663
|
+
out.wikiAttachError = e.message;
|
|
664
|
+
}
|
|
665
|
+
}
|
|
666
|
+
return out;
|
|
503
667
|
}
|
|
504
668
|
|
|
505
669
|
async listBitableTables(appToken) {
|
|
@@ -751,7 +915,10 @@ class LarkOfficialClient {
|
|
|
751
915
|
return { items: res.data.docs_entities || [] };
|
|
752
916
|
}
|
|
753
917
|
|
|
754
|
-
|
|
918
|
+
// Resolves a wiki node token to its underlying object (docx / sheet / bitable / ...).
|
|
919
|
+
// `spaceId` argument is kept for backward compatibility but isn't used — the Feishu
|
|
920
|
+
// endpoint `wiki.v2.getNode` takes only the token.
|
|
921
|
+
async getWikiNode(nodeToken, _spaceId) {
|
|
755
922
|
const res = await this._safeSDKCall(() => this.client.wiki.space.getNode({ params: { token: nodeToken } }), 'getNode');
|
|
756
923
|
return res.data.node;
|
|
757
924
|
}
|
|
@@ -785,7 +952,7 @@ class LarkOfficialClient {
|
|
|
785
952
|
sdkFn: () => this.client.drive.file.createFolder({ data: body }),
|
|
786
953
|
label: 'createFolder',
|
|
787
954
|
});
|
|
788
|
-
return { token: res.data.token };
|
|
955
|
+
return { token: res.data.token, viaUser: !!res._viaUser };
|
|
789
956
|
}
|
|
790
957
|
|
|
791
958
|
// --- Drive: File Operations ---
|
|
@@ -952,6 +1119,408 @@ class LarkOfficialClient {
|
|
|
952
1119
|
// Feishu returns millisecond strings; normalize to seconds
|
|
953
1120
|
return String(n > 1e12 ? Math.floor(n / 1000) : n);
|
|
954
1121
|
}
|
|
1122
|
+
|
|
1123
|
+
// --- Hardened Message Read (v1.3.4) ---
|
|
1124
|
+
|
|
1125
|
+
// Reads messages with explicit fallback routing: tries the bot path first,
|
|
1126
|
+
// classifies any failure via error-codes.js, and escalates to UAT when
|
|
1127
|
+
// appropriate. Returns the same shape as readMessages/readMessagesAsUser
|
|
1128
|
+
// plus `via` ('bot' | 'user' | 'contacts') and, if fallback fired,
|
|
1129
|
+
// `via_reason` (a short enum from classifyError).
|
|
1130
|
+
//
|
|
1131
|
+
// If `skipBot` is true, the bot path is never attempted (callers use this
|
|
1132
|
+
// when the chat_id came from search_contacts — i.e. definitely external).
|
|
1133
|
+
//
|
|
1134
|
+
// Throws a single, wrapped error if BOTH paths fail or if UAT is absent and
|
|
1135
|
+
// the bot failed; the message points the user at `npx feishu-user-plugin oauth`.
|
|
1136
|
+
async readMessagesWithFallback(chatId, options, userClient, { skipBot = false, via = 'bot' } = {}) {
|
|
1137
|
+
const tryUAT = async (viaLabel, reason) => {
|
|
1138
|
+
if (!this.hasUAT) {
|
|
1139
|
+
const hint = 'To read external / private groups, configure UAT via: npx feishu-user-plugin oauth';
|
|
1140
|
+
const err = new Error(`Cannot read chat ${chatId} as bot (${reason || 'bot failed and no UAT configured'}). ${hint}`);
|
|
1141
|
+
err.viaReason = reason;
|
|
1142
|
+
throw err;
|
|
1143
|
+
}
|
|
1144
|
+
const data = await this.readMessagesAsUser(chatId, options, userClient);
|
|
1145
|
+
data.via = viaLabel;
|
|
1146
|
+
if (reason) data.via_reason = reason;
|
|
1147
|
+
return data;
|
|
1148
|
+
};
|
|
1149
|
+
|
|
1150
|
+
if (skipBot) {
|
|
1151
|
+
return tryUAT(via || 'contacts', 'contacts_resolved_external');
|
|
1152
|
+
}
|
|
1153
|
+
|
|
1154
|
+
// Attempt 1 — bot identity.
|
|
1155
|
+
try {
|
|
1156
|
+
const data = await this.readMessages(chatId, options, userClient);
|
|
1157
|
+
data.via = 'bot';
|
|
1158
|
+
return data;
|
|
1159
|
+
} catch (botErr) {
|
|
1160
|
+
const klass = classifyError(botErr);
|
|
1161
|
+
console.error(`[feishu-user-plugin] read_messages bot failed for ${chatId}: ${botErr.message} [class=${klass.action}, reason=${klass.reason}, code=${klass.code}]`);
|
|
1162
|
+
|
|
1163
|
+
if (klass.action === 'retry') {
|
|
1164
|
+
// One retry after short backoff before hopping to UAT.
|
|
1165
|
+
await new Promise(r => setTimeout(r, 2000));
|
|
1166
|
+
try {
|
|
1167
|
+
const data = await this.readMessages(chatId, options, userClient);
|
|
1168
|
+
data.via = 'bot';
|
|
1169
|
+
data.via_reason = klass.reason + '_recovered';
|
|
1170
|
+
return data;
|
|
1171
|
+
} catch (retryErr) {
|
|
1172
|
+
console.error(`[feishu-user-plugin] read_messages bot retry failed for ${chatId}: ${retryErr.message}`);
|
|
1173
|
+
}
|
|
1174
|
+
}
|
|
1175
|
+
|
|
1176
|
+
// Fall through to UAT — if UAT is missing, tryUAT throws the user-friendly
|
|
1177
|
+
// "run npx feishu-user-plugin oauth" error instead of the raw Feishu payload.
|
|
1178
|
+
return tryUAT('user', klass.reason);
|
|
1179
|
+
}
|
|
1180
|
+
}
|
|
1181
|
+
|
|
1182
|
+
// --- Docx Image Read (v1.3.4) ---
|
|
1183
|
+
|
|
1184
|
+
// Download a media asset (image, file, etc.) referenced from inside a Feishu
|
|
1185
|
+
// docx block. The model actually gets the pixels via MCP image content in the
|
|
1186
|
+
// handler layer; here we just return base64 + metadata.
|
|
1187
|
+
//
|
|
1188
|
+
// Feishu's drive/v1/medias/{token}/download requires a query `extra` with
|
|
1189
|
+
// a JSON-encoded doc_token when the media lives inside a doc (to pass
|
|
1190
|
+
// tenant-scoped auth). Passing extra is harmless for generic drive files.
|
|
1191
|
+
async downloadDocImage(imageToken, docToken, docType = 'docx') {
|
|
1192
|
+
if (!imageToken) throw new Error('downloadDocImage: imageToken is required');
|
|
1193
|
+
// Feishu's drive media download uses `extra` as a JSON-string query param to
|
|
1194
|
+
// identify the enclosing doc context. Most observed forms carry both
|
|
1195
|
+
// `doc_type` and `doc_token`; omitting docType falls back to 'docx' which
|
|
1196
|
+
// is the by-far most common case. Omitting extra entirely is safe for
|
|
1197
|
+
// standalone drive-media tokens that don't live inside a doc.
|
|
1198
|
+
const extra = docToken
|
|
1199
|
+
? `?extra=${encodeURIComponent(JSON.stringify({ doc_type: docType, doc_token: docToken }))}`
|
|
1200
|
+
: '';
|
|
1201
|
+
const path = `/open-apis/drive/v1/medias/${encodeURIComponent(imageToken)}/download${extra}`;
|
|
1202
|
+
const url = 'https://open.feishu.cn' + path;
|
|
1203
|
+
|
|
1204
|
+
// Attempt 1 — user identity (most reliable for user-owned docs).
|
|
1205
|
+
if (this.hasUAT) {
|
|
1206
|
+
try {
|
|
1207
|
+
const uat = await this._getValidUAT();
|
|
1208
|
+
const res = await fetchWithTimeout(url, { headers: { 'Authorization': `Bearer ${uat}` }, timeoutMs: 60000 });
|
|
1209
|
+
if (res.ok && !res.headers.get('content-type')?.includes('application/json')) {
|
|
1210
|
+
const buf = Buffer.from(await res.arrayBuffer());
|
|
1211
|
+
return {
|
|
1212
|
+
base64: buf.toString('base64'),
|
|
1213
|
+
mimeType: res.headers.get('content-type') || 'application/octet-stream',
|
|
1214
|
+
bytes: buf.length,
|
|
1215
|
+
viaUser: true,
|
|
1216
|
+
};
|
|
1217
|
+
}
|
|
1218
|
+
const errJson = await res.json().catch(() => null);
|
|
1219
|
+
console.error(`[feishu-user-plugin] downloadDocImage as user failed: ${errJson?.code}: ${errJson?.msg || res.statusText}, retrying as app`);
|
|
1220
|
+
} catch (e) {
|
|
1221
|
+
console.error(`[feishu-user-plugin] downloadDocImage as user threw (${e.message}), retrying as app`);
|
|
1222
|
+
}
|
|
1223
|
+
}
|
|
1224
|
+
|
|
1225
|
+
// Attempt 2 — app identity. Requires the app to have drive access to the doc.
|
|
1226
|
+
const token = await this._getAppToken();
|
|
1227
|
+
const res = await fetchWithTimeout(url, { headers: { 'Authorization': `Bearer ${token}` }, timeoutMs: 60000 });
|
|
1228
|
+
if (!res.ok || res.headers.get('content-type')?.includes('application/json')) {
|
|
1229
|
+
const errJson = await res.json().catch(() => null);
|
|
1230
|
+
throw new Error(`downloadDocImage failed: ${errJson?.code}: ${errJson?.msg || res.statusText}. Note: app identity requires drive access to the document; configure UAT for user-owned docs.`);
|
|
1231
|
+
}
|
|
1232
|
+
const buf = Buffer.from(await res.arrayBuffer());
|
|
1233
|
+
return {
|
|
1234
|
+
base64: buf.toString('base64'),
|
|
1235
|
+
mimeType: res.headers.get('content-type') || 'application/octet-stream',
|
|
1236
|
+
bytes: buf.length,
|
|
1237
|
+
viaUser: false,
|
|
1238
|
+
};
|
|
1239
|
+
}
|
|
1240
|
+
|
|
1241
|
+
// --- Docx Image Write (v1.3.4) ---
|
|
1242
|
+
|
|
1243
|
+
// Upload binary media (typically an image) to Feishu's drive layer so it can
|
|
1244
|
+
// be attached to a docx block. Returns the media's file_token, which is what
|
|
1245
|
+
// the image block's `replace_image.token` expects.
|
|
1246
|
+
//
|
|
1247
|
+
// parentType = 'docx_image' for doc-embedded images (most common).
|
|
1248
|
+
// parentNode = the block_id of the image placeholder (NOT the document_id).
|
|
1249
|
+
async uploadDocMedia(filePath, parentNode, parentType = 'docx_image') {
|
|
1250
|
+
const fs = require('fs');
|
|
1251
|
+
const path = require('path');
|
|
1252
|
+
if (!filePath) throw new Error('uploadDocMedia: filePath is required');
|
|
1253
|
+
if (!parentNode) throw new Error('uploadDocMedia: parentNode (block_id) is required');
|
|
1254
|
+
|
|
1255
|
+
const stat = fs.statSync(filePath);
|
|
1256
|
+
const fileName = path.basename(filePath);
|
|
1257
|
+
const buf = fs.readFileSync(filePath);
|
|
1258
|
+
|
|
1259
|
+
// Best-effort content-type from extension. Feishu doesn't require it but
|
|
1260
|
+
// some CDNs behind the API key off it; the Blob default is text/plain
|
|
1261
|
+
// which would look wrong for binary images.
|
|
1262
|
+
const ext = path.extname(fileName).toLowerCase();
|
|
1263
|
+
const mimeMap = {
|
|
1264
|
+
'.png': 'image/png', '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg',
|
|
1265
|
+
'.gif': 'image/gif', '.webp': 'image/webp', '.svg': 'image/svg+xml',
|
|
1266
|
+
'.bmp': 'image/bmp', '.tiff': 'image/tiff', '.ico': 'image/x-icon',
|
|
1267
|
+
};
|
|
1268
|
+
const contentType = mimeMap[ext] || 'application/octet-stream';
|
|
1269
|
+
|
|
1270
|
+
const doUpload = async (bearer) => {
|
|
1271
|
+
const form = new FormData();
|
|
1272
|
+
form.append('file_name', fileName);
|
|
1273
|
+
form.append('parent_type', parentType);
|
|
1274
|
+
form.append('parent_node', parentNode);
|
|
1275
|
+
form.append('size', String(stat.size));
|
|
1276
|
+
form.append('file', new Blob([buf], { type: contentType }), fileName);
|
|
1277
|
+
const res = await fetchWithTimeout('https://open.feishu.cn/open-apis/drive/v1/medias/upload_all', {
|
|
1278
|
+
method: 'POST',
|
|
1279
|
+
headers: { 'Authorization': `Bearer ${bearer}` },
|
|
1280
|
+
body: form,
|
|
1281
|
+
timeoutMs: 120000,
|
|
1282
|
+
});
|
|
1283
|
+
return res.json();
|
|
1284
|
+
};
|
|
1285
|
+
|
|
1286
|
+
// User identity first — docx_image usually belongs to a user-owned doc.
|
|
1287
|
+
if (this.hasUAT) {
|
|
1288
|
+
try {
|
|
1289
|
+
const data = await this._withUAT(doUpload);
|
|
1290
|
+
if (data.code === 0 && data.data?.file_token) {
|
|
1291
|
+
return { fileToken: data.data.file_token, viaUser: true };
|
|
1292
|
+
}
|
|
1293
|
+
console.error(`[feishu-user-plugin] uploadDocMedia as user failed (${data.code}: ${data.msg}), retrying as app`);
|
|
1294
|
+
} catch (e) {
|
|
1295
|
+
console.error(`[feishu-user-plugin] uploadDocMedia as user threw (${e.message}), retrying as app`);
|
|
1296
|
+
}
|
|
1297
|
+
}
|
|
1298
|
+
const appToken = await this._getAppToken();
|
|
1299
|
+
const data = await doUpload(appToken);
|
|
1300
|
+
if (data.code !== 0 || !data.data?.file_token) {
|
|
1301
|
+
throw new Error(`uploadDocMedia failed: ${data.code}: ${data.msg || 'no file_token returned'}`);
|
|
1302
|
+
}
|
|
1303
|
+
return { fileToken: data.data.file_token, viaUser: false };
|
|
1304
|
+
}
|
|
1305
|
+
|
|
1306
|
+
// Create a new image block and populate it from either a local file path or
|
|
1307
|
+
// an already-uploaded media token. Orchestrates the three-step Feishu flow:
|
|
1308
|
+
// 1) create empty image placeholder block
|
|
1309
|
+
// 2) upload pixels (skipped if caller passes a ready-made imageToken)
|
|
1310
|
+
// 3) patch the placeholder with the uploaded token
|
|
1311
|
+
// Returns { blockId, imageToken, viaUser }.
|
|
1312
|
+
async createDocBlockWithImage(documentId, parentBlockId, { imagePath, imageToken, index } = {}) {
|
|
1313
|
+
if (!imagePath && !imageToken) {
|
|
1314
|
+
throw new Error('createDocBlockWithImage: either imagePath or imageToken is required');
|
|
1315
|
+
}
|
|
1316
|
+
|
|
1317
|
+
// Step 1 — empty placeholder.
|
|
1318
|
+
const placeholder = buildEmptyImageBlock();
|
|
1319
|
+
const createBody = { children: [placeholder] };
|
|
1320
|
+
if (index !== undefined) createBody.index = index;
|
|
1321
|
+
const created = await this._asUserOrApp({
|
|
1322
|
+
uatPath: `/open-apis/docx/v1/documents/${documentId}/blocks/${parentBlockId}/children`,
|
|
1323
|
+
method: 'POST',
|
|
1324
|
+
body: createBody,
|
|
1325
|
+
sdkFn: () => this.client.docx.documentBlockChildren.create({
|
|
1326
|
+
path: { document_id: documentId, block_id: parentBlockId },
|
|
1327
|
+
data: createBody,
|
|
1328
|
+
}),
|
|
1329
|
+
label: 'createDocBlockWithImage.placeholder',
|
|
1330
|
+
});
|
|
1331
|
+
const newBlock = (created.data.children || [])[0];
|
|
1332
|
+
const blockId = newBlock?.block_id;
|
|
1333
|
+
if (!blockId) throw new Error(`createDocBlockWithImage: placeholder creation returned no block_id: ${JSON.stringify(created.data).slice(0, 400)}`);
|
|
1334
|
+
|
|
1335
|
+
// Step 2 — upload (if needed).
|
|
1336
|
+
let finalToken = imageToken;
|
|
1337
|
+
let viaUser = !!created._viaUser;
|
|
1338
|
+
if (!finalToken) {
|
|
1339
|
+
const uploaded = await this.uploadDocMedia(imagePath, blockId, 'docx_image');
|
|
1340
|
+
finalToken = uploaded.fileToken;
|
|
1341
|
+
viaUser = viaUser && uploaded.viaUser; // true iff both steps went via user
|
|
1342
|
+
}
|
|
1343
|
+
|
|
1344
|
+
// Step 3 — attach token to the placeholder via PATCH replace_image.
|
|
1345
|
+
const patch = buildReplaceImagePayload(finalToken);
|
|
1346
|
+
await this._asUserOrApp({
|
|
1347
|
+
uatPath: `/open-apis/docx/v1/documents/${documentId}/blocks/${blockId}`,
|
|
1348
|
+
method: 'PATCH',
|
|
1349
|
+
body: patch,
|
|
1350
|
+
sdkFn: () => this.client.docx.documentBlock.patch({
|
|
1351
|
+
path: { document_id: documentId, block_id: blockId },
|
|
1352
|
+
data: patch,
|
|
1353
|
+
}),
|
|
1354
|
+
label: 'createDocBlockWithImage.replaceImage',
|
|
1355
|
+
});
|
|
1356
|
+
|
|
1357
|
+
return { blockId, imageToken: finalToken, viaUser };
|
|
1358
|
+
}
|
|
1359
|
+
|
|
1360
|
+
// Replace an existing image block's media token (e.g. swap the picture in an
|
|
1361
|
+
// already-created image block). Expects an uploaded media token — use
|
|
1362
|
+
// uploadDocMedia or create_doc_block's image_path shortcut to obtain one.
|
|
1363
|
+
async updateDocBlockImage(documentId, blockId, imageToken) {
|
|
1364
|
+
const patch = buildReplaceImagePayload(imageToken);
|
|
1365
|
+
await this._asUserOrApp({
|
|
1366
|
+
uatPath: `/open-apis/docx/v1/documents/${documentId}/blocks/${blockId}`,
|
|
1367
|
+
method: 'PATCH',
|
|
1368
|
+
body: patch,
|
|
1369
|
+
sdkFn: () => this.client.docx.documentBlock.patch({
|
|
1370
|
+
path: { document_id: documentId, block_id: blockId },
|
|
1371
|
+
data: patch,
|
|
1372
|
+
}),
|
|
1373
|
+
label: 'updateDocBlockImage',
|
|
1374
|
+
});
|
|
1375
|
+
return { blockId, imageToken };
|
|
1376
|
+
}
|
|
1377
|
+
|
|
1378
|
+
// --- Wiki attach (v1.3.4) ---
|
|
1379
|
+
|
|
1380
|
+
// Move an existing drive resource (docx / bitable / sheet / ...) into a Wiki
|
|
1381
|
+
// space as an 'origin' node. Used by createDoc / createBitable when their
|
|
1382
|
+
// wikiSpaceId option is set.
|
|
1383
|
+
//
|
|
1384
|
+
// Uses wiki/v2/spaces/{space_id}/nodes/move_docs_to_wiki — the documented path
|
|
1385
|
+
// for migrating an existing drive doc into wiki. Note: this endpoint is async;
|
|
1386
|
+
// if the move completes immediately (typical for newly-created docs) we get
|
|
1387
|
+
// back a wiki_token and surface it as node_token. If it's queued we return
|
|
1388
|
+
// { task_id } so the caller can see the async state — we don't currently poll.
|
|
1389
|
+
async attachToWiki(spaceId, objType, objToken, parentNodeToken) {
|
|
1390
|
+
if (!spaceId) throw new Error('attachToWiki: spaceId is required');
|
|
1391
|
+
if (!objType) throw new Error('attachToWiki: objType is required');
|
|
1392
|
+
if (!objToken) throw new Error('attachToWiki: objToken is required');
|
|
1393
|
+
const body = { obj_type: objType, obj_token: objToken, apply: true };
|
|
1394
|
+
if (parentNodeToken) body.parent_wiki_token = parentNodeToken;
|
|
1395
|
+
const res = await this._asUserOrApp({
|
|
1396
|
+
uatPath: `/open-apis/wiki/v2/spaces/${encodeURIComponent(spaceId)}/nodes/move_docs_to_wiki`,
|
|
1397
|
+
method: 'POST',
|
|
1398
|
+
body,
|
|
1399
|
+
sdkFn: () => this.client.wiki.spaceNode.moveDocsToWiki({ path: { space_id: spaceId }, data: body }),
|
|
1400
|
+
label: 'attachToWiki',
|
|
1401
|
+
});
|
|
1402
|
+
const data = res.data || {};
|
|
1403
|
+
if (data.wiki_token) return { node_token: data.wiki_token, applied: !!data.applied };
|
|
1404
|
+
if (data.task_id) return { task_id: data.task_id, applied: false };
|
|
1405
|
+
return data;
|
|
1406
|
+
}
|
|
1407
|
+
|
|
1408
|
+
// --- OKR (v1.3.4) ---
|
|
1409
|
+
|
|
1410
|
+
async listUserOkrs(userId, { periodIds, offset = 0, limit = 10, lang, userIdType = 'open_id' } = {}) {
|
|
1411
|
+
if (!userId) throw new Error('listUserOkrs: userId is required (the user whose OKRs to read). For your own, get your open_id from get_login_status or search_contacts.');
|
|
1412
|
+
const params = { user_id_type: userIdType, offset: String(offset), limit: String(limit) };
|
|
1413
|
+
if (lang) params.lang = lang;
|
|
1414
|
+
if (periodIds && periodIds.length) params.period_ids = periodIds;
|
|
1415
|
+
const res = await this._asUserOrApp({
|
|
1416
|
+
uatPath: `/open-apis/okr/v1/users/${encodeURIComponent(userId)}/okrs`,
|
|
1417
|
+
query: params,
|
|
1418
|
+
sdkFn: () => this.client.okr.userOkr.list({
|
|
1419
|
+
path: { user_id: userId },
|
|
1420
|
+
params: {
|
|
1421
|
+
user_id_type: userIdType,
|
|
1422
|
+
offset: String(offset),
|
|
1423
|
+
limit: String(limit),
|
|
1424
|
+
...(lang ? { lang } : {}),
|
|
1425
|
+
...(periodIds && periodIds.length ? { period_ids: periodIds } : {}),
|
|
1426
|
+
},
|
|
1427
|
+
}),
|
|
1428
|
+
label: 'listUserOkrs',
|
|
1429
|
+
});
|
|
1430
|
+
return { total: res.data.total, items: res.data.okr_list || [] };
|
|
1431
|
+
}
|
|
1432
|
+
|
|
1433
|
+
async getOkrs(okrIds, { lang, userIdType = 'open_id' } = {}) {
|
|
1434
|
+
if (!Array.isArray(okrIds) || okrIds.length === 0) {
|
|
1435
|
+
throw new Error('getOkrs: okrIds must be a non-empty array');
|
|
1436
|
+
}
|
|
1437
|
+
const params = { user_id_type: userIdType, okr_ids: okrIds };
|
|
1438
|
+
if (lang) params.lang = lang;
|
|
1439
|
+
// UAT REST path takes repeated okr_ids= params; URLSearchParams will serialize an array properly
|
|
1440
|
+
const res = await this._asUserOrApp({
|
|
1441
|
+
uatPath: `/open-apis/okr/v1/okrs/batch_get`,
|
|
1442
|
+
query: params,
|
|
1443
|
+
sdkFn: () => this.client.okr.okr.batchGet({ params }),
|
|
1444
|
+
label: 'getOkrs',
|
|
1445
|
+
});
|
|
1446
|
+
return { items: res.data.okr_list || [] };
|
|
1447
|
+
}
|
|
1448
|
+
|
|
1449
|
+
async listOkrPeriods({ pageSize = 10, pageToken } = {}) {
|
|
1450
|
+
const params = { page_size: String(pageSize) };
|
|
1451
|
+
if (pageToken) params.page_token = pageToken;
|
|
1452
|
+
const res = await this._asUserOrApp({
|
|
1453
|
+
uatPath: `/open-apis/okr/v1/periods`,
|
|
1454
|
+
query: params,
|
|
1455
|
+
sdkFn: () => this.client.okr.period.list({ params: { page_size: pageSize, ...(pageToken ? { page_token: pageToken } : {}) } }),
|
|
1456
|
+
label: 'listOkrPeriods',
|
|
1457
|
+
});
|
|
1458
|
+
return { items: res.data.items || [], pageToken: res.data.page_token, hasMore: res.data.has_more };
|
|
1459
|
+
}
|
|
1460
|
+
|
|
1461
|
+
// --- Calendar (v1.3.4) ---
|
|
1462
|
+
|
|
1463
|
+
async listCalendars({ pageSize = 50, pageToken, syncToken } = {}) {
|
|
1464
|
+
// Feishu's calendar/v4/calendars endpoint rejects page_size < 50 with
|
|
1465
|
+
// `99992402 field validation failed` ("the min value is 50"). The docs don't
|
|
1466
|
+
// flag this — smoke-tested against the real API. Clamp to be safe.
|
|
1467
|
+
const ps = Math.max(50, Number(pageSize) || 50);
|
|
1468
|
+
const params = { page_size: String(ps) };
|
|
1469
|
+
if (pageToken) params.page_token = pageToken;
|
|
1470
|
+
if (syncToken) params.sync_token = syncToken;
|
|
1471
|
+
const res = await this._asUserOrApp({
|
|
1472
|
+
uatPath: `/open-apis/calendar/v4/calendars`,
|
|
1473
|
+
query: params,
|
|
1474
|
+
sdkFn: () => this.client.calendar.calendar.list({ params: { page_size: ps, ...(pageToken ? { page_token: pageToken } : {}), ...(syncToken ? { sync_token: syncToken } : {}) } }),
|
|
1475
|
+
label: 'listCalendars',
|
|
1476
|
+
});
|
|
1477
|
+
return {
|
|
1478
|
+
items: res.data.calendar_list || [],
|
|
1479
|
+
pageToken: res.data.page_token,
|
|
1480
|
+
syncToken: res.data.sync_token,
|
|
1481
|
+
hasMore: res.data.has_more,
|
|
1482
|
+
};
|
|
1483
|
+
}
|
|
1484
|
+
|
|
1485
|
+
async listCalendarEvents(calendarId, { startTime, endTime, pageSize = 50, pageToken, syncToken } = {}) {
|
|
1486
|
+
if (!calendarId) throw new Error('listCalendarEvents: calendarId is required');
|
|
1487
|
+
const params = { page_size: String(pageSize) };
|
|
1488
|
+
if (startTime) params.start_time = String(startTime);
|
|
1489
|
+
if (endTime) params.end_time = String(endTime);
|
|
1490
|
+
if (pageToken) params.page_token = pageToken;
|
|
1491
|
+
if (syncToken) params.sync_token = syncToken;
|
|
1492
|
+
const res = await this._asUserOrApp({
|
|
1493
|
+
uatPath: `/open-apis/calendar/v4/calendars/${encodeURIComponent(calendarId)}/events`,
|
|
1494
|
+
query: params,
|
|
1495
|
+
sdkFn: () => this.client.calendar.calendarEvent.list({
|
|
1496
|
+
path: { calendar_id: calendarId },
|
|
1497
|
+
params: {
|
|
1498
|
+
page_size: pageSize,
|
|
1499
|
+
...(startTime ? { start_time: String(startTime) } : {}),
|
|
1500
|
+
...(endTime ? { end_time: String(endTime) } : {}),
|
|
1501
|
+
...(pageToken ? { page_token: pageToken } : {}),
|
|
1502
|
+
...(syncToken ? { sync_token: syncToken } : {}),
|
|
1503
|
+
},
|
|
1504
|
+
}),
|
|
1505
|
+
label: 'listCalendarEvents',
|
|
1506
|
+
});
|
|
1507
|
+
return {
|
|
1508
|
+
items: res.data.items || [],
|
|
1509
|
+
pageToken: res.data.page_token,
|
|
1510
|
+
syncToken: res.data.sync_token,
|
|
1511
|
+
hasMore: res.data.has_more,
|
|
1512
|
+
};
|
|
1513
|
+
}
|
|
1514
|
+
|
|
1515
|
+
async getCalendarEvent(calendarId, eventId) {
|
|
1516
|
+
if (!calendarId || !eventId) throw new Error('getCalendarEvent: calendarId and eventId are required');
|
|
1517
|
+
const res = await this._asUserOrApp({
|
|
1518
|
+
uatPath: `/open-apis/calendar/v4/calendars/${encodeURIComponent(calendarId)}/events/${encodeURIComponent(eventId)}`,
|
|
1519
|
+
sdkFn: () => this.client.calendar.calendarEvent.get({ path: { calendar_id: calendarId, event_id: eventId } }),
|
|
1520
|
+
label: 'getCalendarEvent',
|
|
1521
|
+
});
|
|
1522
|
+
return { event: res.data.event };
|
|
1523
|
+
}
|
|
955
1524
|
}
|
|
956
1525
|
|
|
957
1526
|
module.exports = { LarkOfficialClient };
|