feishu-user-plugin 1.3.4 → 1.3.6
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/README.md +25 -11
- package/package.json +2 -2
- package/scripts/test-uat-race-child.js +24 -0
- package/scripts/test-uat-race.js +68 -0
- package/skills/feishu-user-plugin/SKILL.md +3 -3
- package/skills/feishu-user-plugin/references/CLAUDE.md +39 -6
- package/src/doc-blocks.js +20 -5
- package/src/index.js +332 -30
- package/src/oauth.js +4 -1
- package/src/official.js +471 -53
package/src/official.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
const lark = require('@larksuiteoapi/node-sdk');
|
|
2
2
|
const { fetchWithTimeout } = require('./utils');
|
|
3
3
|
const { classifyError } = require('./error-codes');
|
|
4
|
-
const { buildEmptyImageBlock, buildReplaceImagePayload } = require('./doc-blocks');
|
|
4
|
+
const { buildEmptyImageBlock, buildReplaceImagePayload, buildEmptyFileBlock, buildReplaceFilePayload } = require('./doc-blocks');
|
|
5
5
|
|
|
6
6
|
// Redirect all Lark SDK logs to stderr.
|
|
7
7
|
// The SDK's defaultLogger.error uses console.log (stdout), which corrupts
|
|
@@ -34,7 +34,7 @@ class LarkOfficialClient {
|
|
|
34
34
|
if (token) {
|
|
35
35
|
this._uat = token;
|
|
36
36
|
this._uatRefresh = refresh || null;
|
|
37
|
-
this._uatExpires = expires;
|
|
37
|
+
this._uatExpires = expires || this._decodeTokenExpiry(token);
|
|
38
38
|
}
|
|
39
39
|
}
|
|
40
40
|
|
|
@@ -90,6 +90,7 @@ class LarkOfficialClient {
|
|
|
90
90
|
if (!this._uat) throw new Error('No user_access_token. Run: npx feishu-user-plugin oauth');
|
|
91
91
|
|
|
92
92
|
const now = Math.floor(Date.now() / 1000);
|
|
93
|
+
if (!this._uatExpires) this._uatExpires = this._decodeTokenExpiry(this._uat);
|
|
93
94
|
// Proactively refresh if we know it's expiring within 5 min
|
|
94
95
|
if (this._uatExpires > 0 && this._uatExpires <= now + 300) {
|
|
95
96
|
return this._refreshUAT();
|
|
@@ -97,30 +98,125 @@ class LarkOfficialClient {
|
|
|
97
98
|
return this._uat;
|
|
98
99
|
}
|
|
99
100
|
|
|
101
|
+
_decodeTokenExpiry(token) {
|
|
102
|
+
try {
|
|
103
|
+
const payload = token?.split('.')?.[1];
|
|
104
|
+
if (!payload) return 0;
|
|
105
|
+
const data = JSON.parse(Buffer.from(payload, 'base64url').toString('utf8'));
|
|
106
|
+
return typeof data.exp === 'number' ? data.exp : 0;
|
|
107
|
+
} catch (_) {
|
|
108
|
+
return 0;
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
_adoptPersistedUATIfNewer() {
|
|
113
|
+
try {
|
|
114
|
+
const { readCredentials } = require('./config');
|
|
115
|
+
const creds = readCredentials();
|
|
116
|
+
const token = creds.LARK_USER_ACCESS_TOKEN;
|
|
117
|
+
const refresh = creds.LARK_USER_REFRESH_TOKEN;
|
|
118
|
+
if (!token && !refresh) return false;
|
|
119
|
+
|
|
120
|
+
const expires = parseInt(creds.LARK_UAT_EXPIRES || '0') || this._decodeTokenExpiry(token);
|
|
121
|
+
const changed = (token && token !== this._uat)
|
|
122
|
+
|| (refresh && refresh !== this._uatRefresh)
|
|
123
|
+
|| (expires && expires !== this._uatExpires);
|
|
124
|
+
if (!changed) return false;
|
|
125
|
+
|
|
126
|
+
if (token) this._uat = token;
|
|
127
|
+
if (refresh) this._uatRefresh = refresh;
|
|
128
|
+
this._uatExpires = expires || 0;
|
|
129
|
+
console.error('[feishu-user-plugin] UAT adopted latest persisted token before refresh');
|
|
130
|
+
return true;
|
|
131
|
+
} catch (e) {
|
|
132
|
+
console.error(`[feishu-user-plugin] UAT persisted-token check failed: ${e.message}`);
|
|
133
|
+
return false;
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// Cross-process advisory lock for UAT refresh. Feishu rotates the refresh_token
|
|
138
|
+
// on every refresh (old one invalidated instantly). When multiple MCP server
|
|
139
|
+
// processes share the same persisted refresh_token and all wake up near expiry,
|
|
140
|
+
// they race: the first wins, the rest see `invalid_grant` and can't recover.
|
|
141
|
+
// This lock serialises refreshes across processes; inside the critical section
|
|
142
|
+
// we also re-read the persisted config so late arrivals adopt the winner's
|
|
143
|
+
// token instead of attempting a doomed refresh with the already-rotated one.
|
|
144
|
+
_uatLockPath() {
|
|
145
|
+
const path = require('path');
|
|
146
|
+
const os = require('os');
|
|
147
|
+
return path.join(os.homedir(), '.claude', 'feishu-uat-refresh.lock');
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
async _acquireRefreshLock(lockPath, { staleMs = 30000, pollMs = 200, timeoutMs = 20000 } = {}) {
|
|
151
|
+
const fs = require('fs');
|
|
152
|
+
const path = require('path');
|
|
153
|
+
try { fs.mkdirSync(path.dirname(lockPath), { recursive: true }); } catch (_) {}
|
|
154
|
+
const start = Date.now();
|
|
155
|
+
while (Date.now() - start < timeoutMs) {
|
|
156
|
+
try {
|
|
157
|
+
const fd = fs.openSync(lockPath, 'wx'); // O_CREAT | O_EXCL
|
|
158
|
+
fs.writeSync(fd, `${process.pid}\n${Date.now()}\n`);
|
|
159
|
+
fs.closeSync(fd);
|
|
160
|
+
return true;
|
|
161
|
+
} catch (e) {
|
|
162
|
+
if (e.code !== 'EEXIST') throw e;
|
|
163
|
+
try {
|
|
164
|
+
const stat = fs.statSync(lockPath);
|
|
165
|
+
if (Date.now() - stat.mtimeMs > staleMs) {
|
|
166
|
+
try { fs.unlinkSync(lockPath); } catch (_) {}
|
|
167
|
+
continue;
|
|
168
|
+
}
|
|
169
|
+
} catch (_) { /* lock vanished under us — retry */ }
|
|
170
|
+
await new Promise(r => setTimeout(r, pollMs));
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
return false;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
_releaseRefreshLock(lockPath) {
|
|
177
|
+
try { require('fs').unlinkSync(lockPath); } catch (_) {}
|
|
178
|
+
}
|
|
179
|
+
|
|
100
180
|
async _refreshUAT() {
|
|
101
|
-
|
|
181
|
+
const lockPath = this._uatLockPath();
|
|
182
|
+
const acquired = await this._acquireRefreshLock(lockPath);
|
|
183
|
+
if (!acquired) {
|
|
184
|
+
console.error('[feishu-user-plugin] UAT refresh lock timed out; proceeding without mutual exclusion');
|
|
185
|
+
}
|
|
186
|
+
try {
|
|
187
|
+
// Re-check under lock: another process may have already refreshed and
|
|
188
|
+
// persisted a new token while we waited. If so, adopt and skip the refresh.
|
|
189
|
+
const now = Math.floor(Date.now() / 1000);
|
|
190
|
+
if (this._adoptPersistedUATIfNewer() && this._uatExpires > now + 300) {
|
|
191
|
+
return this._uat;
|
|
192
|
+
}
|
|
102
193
|
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
194
|
+
if (!this._uatRefresh) throw new Error('UAT expired and no refresh token. Run: npx feishu-user-plugin oauth');
|
|
195
|
+
|
|
196
|
+
const res = await fetchWithTimeout('https://open.feishu.cn/open-apis/authen/v2/oauth/token', {
|
|
197
|
+
method: 'POST',
|
|
198
|
+
headers: { 'content-type': 'application/json' },
|
|
199
|
+
body: JSON.stringify({
|
|
200
|
+
grant_type: 'refresh_token',
|
|
201
|
+
client_id: this.appId,
|
|
202
|
+
client_secret: this.appSecret,
|
|
203
|
+
refresh_token: this._uatRefresh,
|
|
204
|
+
}),
|
|
205
|
+
});
|
|
206
|
+
const data = await res.json();
|
|
207
|
+
const tokenData = data.access_token ? data : data.data;
|
|
208
|
+
if (!tokenData?.access_token) throw new Error(`UAT refresh failed: ${JSON.stringify(data)}. Run: npx feishu-user-plugin oauth`);
|
|
209
|
+
|
|
210
|
+
this._uat = tokenData.access_token;
|
|
211
|
+
this._uatRefresh = tokenData.refresh_token || this._uatRefresh;
|
|
212
|
+
const expiresIn = typeof tokenData.expires_in === 'number' && tokenData.expires_in > 0 ? tokenData.expires_in : 7200;
|
|
213
|
+
this._uatExpires = Math.floor(Date.now() / 1000) + expiresIn;
|
|
214
|
+
this._persistUAT();
|
|
215
|
+
console.error('[feishu-user-plugin] UAT refreshed successfully');
|
|
216
|
+
return this._uat;
|
|
217
|
+
} finally {
|
|
218
|
+
if (acquired) this._releaseRefreshLock(lockPath);
|
|
219
|
+
}
|
|
124
220
|
}
|
|
125
221
|
|
|
126
222
|
_persistUAT() {
|
|
@@ -210,7 +306,17 @@ class LarkOfficialClient {
|
|
|
210
306
|
}
|
|
211
307
|
try {
|
|
212
308
|
const appData = await this._safeSDKCall(sdkFn, label);
|
|
213
|
-
if (appData && typeof appData === 'object')
|
|
309
|
+
if (appData && typeof appData === 'object') {
|
|
310
|
+
appData._viaUser = false;
|
|
311
|
+
// Attach a warning when we silently fell back to bot identity. This lets
|
|
312
|
+
// write handlers surface "⚠️ created as BOT, not you" so the user doesn't
|
|
313
|
+
// discover it days later when a teammate can read the "private" resource.
|
|
314
|
+
if (uatSummary) {
|
|
315
|
+
appData._fallbackWarning = `⚠️ UAT 不可用 (${uatSummary}),本次操作以 bot 身份执行。资源归属于共享 bot「Claude聊天助手」,不是你。恢复方法:运行 \`npx feishu-user-plugin oauth\` 后重启 Claude Code / Codex。`;
|
|
316
|
+
} else if (!this.hasUAT) {
|
|
317
|
+
appData._fallbackWarning = `⚠️ 未配置 UAT,本次操作以 bot 身份执行。资源归属于共享 bot「Claude聊天助手」,不是你。想让资源归你所有,先跑 \`npx feishu-user-plugin oauth\` 然后重启 Claude Code / Codex。`;
|
|
318
|
+
}
|
|
319
|
+
}
|
|
214
320
|
return appData;
|
|
215
321
|
} catch (appErr) {
|
|
216
322
|
if (uatSummary) {
|
|
@@ -236,7 +342,7 @@ class LarkOfficialClient {
|
|
|
236
342
|
return { items: data.data.items || [], pageToken: data.data.page_token, hasMore: data.data.has_more };
|
|
237
343
|
}
|
|
238
344
|
|
|
239
|
-
async readMessagesAsUser(chatId, { pageSize = 20, startTime, endTime, pageToken, sortType = 'ByCreateTimeDesc' } = {}, userClient) {
|
|
345
|
+
async readMessagesAsUser(chatId, { pageSize = 20, startTime, endTime, pageToken, sortType = 'ByCreateTimeDesc', expandMergeForward = true } = {}, userClient) {
|
|
240
346
|
// Feishu API requires end_time >= start_time; auto-set end_time to now if missing
|
|
241
347
|
if (startTime && !endTime) {
|
|
242
348
|
endTime = String(Math.floor(Date.now() / 1000));
|
|
@@ -257,6 +363,7 @@ class LarkOfficialClient {
|
|
|
257
363
|
if (data.code !== 0) throw new Error(`readMessagesAsUser failed (${data.code}): ${data.msg}`);
|
|
258
364
|
const items = (data.data.items || []).map(m => this._formatMessage(m));
|
|
259
365
|
await this._populateSenderNames(items, userClient);
|
|
366
|
+
if (expandMergeForward) await this._expandMergeForwardItems(items, userClient, { preferUAT: true });
|
|
260
367
|
return { items, hasMore: data.data.has_more, pageToken: data.data.page_token };
|
|
261
368
|
}
|
|
262
369
|
|
|
@@ -270,7 +377,7 @@ class LarkOfficialClient {
|
|
|
270
377
|
return { items: res.data.items || [], pageToken: res.data.page_token, hasMore: res.data.has_more };
|
|
271
378
|
}
|
|
272
379
|
|
|
273
|
-
async readMessages(chatId, { pageSize = 20, startTime, endTime, pageToken, sortType = 'ByCreateTimeDesc' } = {}, userClient) {
|
|
380
|
+
async readMessages(chatId, { pageSize = 20, startTime, endTime, pageToken, sortType = 'ByCreateTimeDesc', expandMergeForward = true } = {}, userClient) {
|
|
274
381
|
const params = { container_id_type: 'chat', container_id: chatId, page_size: pageSize, sort_type: sortType };
|
|
275
382
|
if (startTime) params.start_time = startTime;
|
|
276
383
|
if (endTime) params.end_time = endTime;
|
|
@@ -278,6 +385,7 @@ class LarkOfficialClient {
|
|
|
278
385
|
const res = await this._safeSDKCall(() => this.client.im.message.list({ params }), 'readMessages');
|
|
279
386
|
const items = (res.data.items || []).map(m => this._formatMessage(m));
|
|
280
387
|
await this._populateSenderNames(items, userClient);
|
|
388
|
+
if (expandMergeForward) await this._expandMergeForwardItems(items, userClient, { preferUAT: false });
|
|
281
389
|
return { items, hasMore: res.data.has_more, pageToken: res.data.page_token };
|
|
282
390
|
}
|
|
283
391
|
|
|
@@ -562,7 +670,7 @@ class LarkOfficialClient {
|
|
|
562
670
|
label: 'createDoc',
|
|
563
671
|
});
|
|
564
672
|
const documentId = res.data.document?.document_id;
|
|
565
|
-
const out = { documentId, viaUser: !!res._viaUser };
|
|
673
|
+
const out = { documentId, viaUser: !!res._viaUser, fallbackWarning: res._fallbackWarning || null };
|
|
566
674
|
if (documentId && wikiSpaceId) {
|
|
567
675
|
try {
|
|
568
676
|
const node = await this.attachToWiki(wikiSpaceId, 'docx', documentId, wikiParentNodeToken);
|
|
@@ -598,7 +706,7 @@ class LarkOfficialClient {
|
|
|
598
706
|
}),
|
|
599
707
|
label: 'createDocBlock',
|
|
600
708
|
});
|
|
601
|
-
return { blocks: res.data.children || [] };
|
|
709
|
+
return { blocks: res.data.children || [], fallbackWarning: res._fallbackWarning || null };
|
|
602
710
|
}
|
|
603
711
|
|
|
604
712
|
async updateDocBlock(documentId, blockId, updateBody) {
|
|
@@ -653,7 +761,7 @@ class LarkOfficialClient {
|
|
|
653
761
|
label: 'createBitable',
|
|
654
762
|
});
|
|
655
763
|
const appToken = res.data.app?.app_token;
|
|
656
|
-
const out = { appToken, name: res.data.app?.name, url: res.data.app?.url, viaUser: !!res._viaUser };
|
|
764
|
+
const out = { appToken, name: res.data.app?.name, url: res.data.app?.url, viaUser: !!res._viaUser, fallbackWarning: res._fallbackWarning || null };
|
|
657
765
|
if (appToken && wikiSpaceId) {
|
|
658
766
|
try {
|
|
659
767
|
const node = await this.attachToWiki(wikiSpaceId, 'bitable', appToken, wikiParentNodeToken);
|
|
@@ -686,7 +794,7 @@ class LarkOfficialClient {
|
|
|
686
794
|
sdkFn: () => this.client.bitable.appTable.create({ path: { app_token: appToken }, data }),
|
|
687
795
|
label: 'createTable',
|
|
688
796
|
});
|
|
689
|
-
return { tableId: res.data.table_id };
|
|
797
|
+
return { tableId: res.data.table_id, fallbackWarning: res._fallbackWarning || null };
|
|
690
798
|
}
|
|
691
799
|
|
|
692
800
|
async listBitableFields(appToken, tableId) {
|
|
@@ -706,7 +814,7 @@ class LarkOfficialClient {
|
|
|
706
814
|
sdkFn: () => this.client.bitable.appTableField.create({ path: { app_token: appToken, table_id: tableId }, data: fieldConfig }),
|
|
707
815
|
label: 'createField',
|
|
708
816
|
});
|
|
709
|
-
return { field: res.data.field };
|
|
817
|
+
return { field: res.data.field, fallbackWarning: res._fallbackWarning || null };
|
|
710
818
|
}
|
|
711
819
|
|
|
712
820
|
async updateBitableField(appToken, tableId, fieldId, fieldConfig) {
|
|
@@ -760,7 +868,7 @@ class LarkOfficialClient {
|
|
|
760
868
|
sdkFn: () => this.client.bitable.appTableRecord.create({ path: { app_token: appToken, table_id: tableId }, data: { fields } }),
|
|
761
869
|
label: 'createRecord',
|
|
762
870
|
});
|
|
763
|
-
return { recordId: res.data.record?.record_id };
|
|
871
|
+
return { recordId: res.data.record?.record_id, fallbackWarning: res._fallbackWarning || null };
|
|
764
872
|
}
|
|
765
873
|
|
|
766
874
|
async updateBitableRecord(appToken, tableId, recordId, fields) {
|
|
@@ -792,7 +900,7 @@ class LarkOfficialClient {
|
|
|
792
900
|
sdkFn: () => this.client.bitable.appTableRecord.batchCreate({ path: { app_token: appToken, table_id: tableId }, data: { records } }),
|
|
793
901
|
label: 'batchCreateRecords',
|
|
794
902
|
});
|
|
795
|
-
return { records: res.data.records || [] };
|
|
903
|
+
return { records: res.data.records || [], fallbackWarning: res._fallbackWarning || null };
|
|
796
904
|
}
|
|
797
905
|
|
|
798
906
|
async batchUpdateBitableRecords(appToken, tableId, records) {
|
|
@@ -874,7 +982,7 @@ class LarkOfficialClient {
|
|
|
874
982
|
sdkFn: () => this.client.bitable.appTableView.create({ path: { app_token: appToken, table_id: tableId }, data: { view_name: viewName, view_type: viewType } }),
|
|
875
983
|
label: 'createView',
|
|
876
984
|
});
|
|
877
|
-
return { view: res.data.view };
|
|
985
|
+
return { view: res.data.view, fallbackWarning: res._fallbackWarning || null };
|
|
878
986
|
}
|
|
879
987
|
|
|
880
988
|
async deleteBitableView(appToken, tableId, viewId) {
|
|
@@ -897,7 +1005,7 @@ class LarkOfficialClient {
|
|
|
897
1005
|
sdkFn: () => this.client.bitable.app.copy({ path: { app_token: appToken }, data }),
|
|
898
1006
|
label: 'copyBitable',
|
|
899
1007
|
});
|
|
900
|
-
return { app: res.data.app };
|
|
1008
|
+
return { app: res.data.app, fallbackWarning: res._fallbackWarning || null };
|
|
901
1009
|
}
|
|
902
1010
|
|
|
903
1011
|
// --- Wiki ---
|
|
@@ -952,7 +1060,7 @@ class LarkOfficialClient {
|
|
|
952
1060
|
sdkFn: () => this.client.drive.file.createFolder({ data: body }),
|
|
953
1061
|
label: 'createFolder',
|
|
954
1062
|
});
|
|
955
|
-
return { token: res.data.token, viaUser: !!res._viaUser };
|
|
1063
|
+
return { token: res.data.token, viaUser: !!res._viaUser, fallbackWarning: res._fallbackWarning || null };
|
|
956
1064
|
}
|
|
957
1065
|
|
|
958
1066
|
// --- Drive: File Operations ---
|
|
@@ -1110,9 +1218,108 @@ class LarkOfficialClient {
|
|
|
1110
1218
|
updateTime: this._normalizeTimestamp(m.update_time),
|
|
1111
1219
|
};
|
|
1112
1220
|
if (Array.isArray(m.mentions) && m.mentions.length > 0) out.mentions = m.mentions;
|
|
1221
|
+
if (m.upper_message_id) out.upperMessageId = m.upper_message_id;
|
|
1222
|
+
if (m.root_id) out.rootId = m.root_id;
|
|
1223
|
+
if (m.parent_id) out.parentId = m.parent_id;
|
|
1224
|
+
// Extract URL-like strings from text bodies so agents can call WebFetch /
|
|
1225
|
+
// read_doc / get_doc_blocks without having to regex the body themselves.
|
|
1226
|
+
if (out.msgType === 'text' && typeof body?.text === 'string') {
|
|
1227
|
+
const urls = body.text.match(/https?:\/\/[^\s一-鿿]+/g);
|
|
1228
|
+
if (urls && urls.length > 0) {
|
|
1229
|
+
out.urls = Array.from(new Set(urls));
|
|
1230
|
+
const feishuDocs = out.urls.filter(u =>
|
|
1231
|
+
/feishu\.cn\/(?:docx|wiki|base|sheets|docs|mindnotes)\//i.test(u));
|
|
1232
|
+
if (feishuDocs.length > 0) out.feishuDocs = feishuDocs;
|
|
1233
|
+
}
|
|
1234
|
+
}
|
|
1113
1235
|
return out;
|
|
1114
1236
|
}
|
|
1115
1237
|
|
|
1238
|
+
// Fetch the child messages inside a merge_forward parent. Feishu exposes them
|
|
1239
|
+
// via `/im/v1/messages/{parent_id}` (single-message GET). The response is
|
|
1240
|
+
// actually a list: items[0] is the parent merge_forward placeholder,
|
|
1241
|
+
// items[1..N] are the children carrying `upper_message_id` pointing back to
|
|
1242
|
+
// the parent and `chat_id` pointing to their ORIGIN chat (the one being
|
|
1243
|
+
// forwarded from, not where the merge_forward was posted).
|
|
1244
|
+
//
|
|
1245
|
+
// Media resources (image_key / file_key) on children must be downloaded
|
|
1246
|
+
// using the PARENT message id — a Feishu quirk: downloading with the child
|
|
1247
|
+
// id returns "File not in msg".
|
|
1248
|
+
async readMergeForwardChildren(parentMessageId, userClient, { preferUAT = true } = {}) {
|
|
1249
|
+
const url = `https://open.feishu.cn/open-apis/im/v1/messages/${encodeURIComponent(parentMessageId)}`;
|
|
1250
|
+
|
|
1251
|
+
const tryPath = async (bearer) => {
|
|
1252
|
+
const res = await fetchWithTimeout(url, {
|
|
1253
|
+
headers: { 'Authorization': `Bearer ${bearer}` },
|
|
1254
|
+
timeoutMs: 30000,
|
|
1255
|
+
});
|
|
1256
|
+
return res.json();
|
|
1257
|
+
};
|
|
1258
|
+
|
|
1259
|
+
let data = null;
|
|
1260
|
+
const order = preferUAT ? ['uat', 'bot'] : ['bot', 'uat'];
|
|
1261
|
+
const errors = [];
|
|
1262
|
+
for (const identity of order) {
|
|
1263
|
+
try {
|
|
1264
|
+
if (identity === 'uat') {
|
|
1265
|
+
if (!this.hasUAT) { errors.push('uat: not configured'); continue; }
|
|
1266
|
+
const uat = await this._getValidUAT();
|
|
1267
|
+
const resp = await tryPath(uat);
|
|
1268
|
+
if (resp.code === 0) { data = resp; break; }
|
|
1269
|
+
errors.push(`uat: code=${resp.code} msg=${resp.msg}`);
|
|
1270
|
+
} else {
|
|
1271
|
+
const tat = await this._getAppToken();
|
|
1272
|
+
const resp = await tryPath(tat);
|
|
1273
|
+
if (resp.code === 0) { data = resp; break; }
|
|
1274
|
+
errors.push(`bot: code=${resp.code} msg=${resp.msg}`);
|
|
1275
|
+
}
|
|
1276
|
+
} catch (e) {
|
|
1277
|
+
errors.push(`${identity}: ${e.message}`);
|
|
1278
|
+
}
|
|
1279
|
+
}
|
|
1280
|
+
if (!data) {
|
|
1281
|
+
throw new Error(`readMergeForwardChildren failed: ${errors.join(' | ')}`);
|
|
1282
|
+
}
|
|
1283
|
+
|
|
1284
|
+
// items[0] is the parent itself — filter it out. The rest are children.
|
|
1285
|
+
const rawChildren = (data.data?.items || []).filter(m =>
|
|
1286
|
+
m.message_id !== parentMessageId && m.upper_message_id);
|
|
1287
|
+
|
|
1288
|
+
const children = rawChildren.map(raw => {
|
|
1289
|
+
const f = this._formatMessage(raw);
|
|
1290
|
+
// Surface the parent id on the child so downstream tools (download_image /
|
|
1291
|
+
// download_file) know which id to pass to Feishu's resource endpoint.
|
|
1292
|
+
f.parentMessageId = parentMessageId;
|
|
1293
|
+
// Mark the origin chat explicitly — child.chatId is the ORIGINAL chat the
|
|
1294
|
+
// message came from, not the chat where the merge_forward was posted.
|
|
1295
|
+
f.originChatId = raw.chat_id;
|
|
1296
|
+
return f;
|
|
1297
|
+
});
|
|
1298
|
+
await this._populateSenderNames(children, userClient);
|
|
1299
|
+
return children;
|
|
1300
|
+
}
|
|
1301
|
+
|
|
1302
|
+
// Expand merge_forward placeholders in-place. Adds `children: [...]` or
|
|
1303
|
+
// `expandError` on each merge_forward item. `depth` guards against nesting
|
|
1304
|
+
// (Feishu does allow nested merge_forward, but we cap at 1 level to avoid
|
|
1305
|
+
// exponential fan-out in agent contexts).
|
|
1306
|
+
async _expandMergeForwardItems(items, userClient, { preferUAT = true, depth = 0, maxDepth = 1 } = {}) {
|
|
1307
|
+
if (!items || depth >= maxDepth) return;
|
|
1308
|
+
for (const m of items) {
|
|
1309
|
+
if (m.msgType !== 'merge_forward') continue;
|
|
1310
|
+
try {
|
|
1311
|
+
const children = await this.readMergeForwardChildren(m.messageId, userClient, { preferUAT });
|
|
1312
|
+
m.children = children;
|
|
1313
|
+
// One extra level deep if user really wants, via recursive call.
|
|
1314
|
+
if (depth + 1 < maxDepth) {
|
|
1315
|
+
await this._expandMergeForwardItems(children, userClient, { preferUAT, depth: depth + 1, maxDepth });
|
|
1316
|
+
}
|
|
1317
|
+
} catch (e) {
|
|
1318
|
+
m.expandError = e.message;
|
|
1319
|
+
}
|
|
1320
|
+
}
|
|
1321
|
+
}
|
|
1322
|
+
|
|
1116
1323
|
_normalizeTimestamp(ts) {
|
|
1117
1324
|
if (!ts) return null;
|
|
1118
1325
|
const n = parseInt(ts);
|
|
@@ -1240,17 +1447,36 @@ class LarkOfficialClient {
|
|
|
1240
1447
|
|
|
1241
1448
|
// --- Docx Image Write (v1.3.4) ---
|
|
1242
1449
|
|
|
1243
|
-
// Upload binary media
|
|
1244
|
-
//
|
|
1245
|
-
// the
|
|
1450
|
+
// Upload binary media to Feishu's drive layer so it can be attached to a
|
|
1451
|
+
// docx block, sheet cell, bitable attachment field, etc. Returns the
|
|
1452
|
+
// media's file_token, which is what the host block's replace_*.token
|
|
1453
|
+
// (or bitable attachment field value) expects.
|
|
1246
1454
|
//
|
|
1247
|
-
// parentType
|
|
1248
|
-
//
|
|
1249
|
-
|
|
1455
|
+
// parentType ∈ {
|
|
1456
|
+
// docx_image, docx_file,
|
|
1457
|
+
// sheet_image, sheet_file,
|
|
1458
|
+
// bitable_image, bitable_file,
|
|
1459
|
+
// doc_image, doc_file, // legacy doc (pre-docx)
|
|
1460
|
+
// ccm_import_open, // import-task host
|
|
1461
|
+
// vc_virtual_background // VC bg, grayscale-only
|
|
1462
|
+
// }
|
|
1463
|
+
// parentNode = the block_id (docx) / spreadsheet_token (sheet) / app_token
|
|
1464
|
+
// (bitable) / doc_token (legacy) — depends on parentType.
|
|
1465
|
+
async uploadMedia(filePath, parentNode, parentType = 'docx_image') {
|
|
1250
1466
|
const fs = require('fs');
|
|
1251
1467
|
const path = require('path');
|
|
1252
|
-
if (!filePath) throw new Error('
|
|
1253
|
-
if (!parentNode) throw new Error('
|
|
1468
|
+
if (!filePath) throw new Error('uploadMedia: filePath is required');
|
|
1469
|
+
if (!parentNode) throw new Error('uploadMedia: parentNode is required');
|
|
1470
|
+
const ALLOWED = new Set([
|
|
1471
|
+
'docx_image', 'docx_file',
|
|
1472
|
+
'sheet_image', 'sheet_file',
|
|
1473
|
+
'bitable_image', 'bitable_file',
|
|
1474
|
+
'doc_image', 'doc_file',
|
|
1475
|
+
'ccm_import_open', 'vc_virtual_background',
|
|
1476
|
+
]);
|
|
1477
|
+
if (!ALLOWED.has(parentType)) {
|
|
1478
|
+
throw new Error(`uploadMedia: unsupported parent_type "${parentType}". Allowed: ${[...ALLOWED].join(', ')}`);
|
|
1479
|
+
}
|
|
1254
1480
|
|
|
1255
1481
|
const stat = fs.statSync(filePath);
|
|
1256
1482
|
const fileName = path.basename(filePath);
|
|
@@ -1258,12 +1484,22 @@ class LarkOfficialClient {
|
|
|
1258
1484
|
|
|
1259
1485
|
// Best-effort content-type from extension. Feishu doesn't require it but
|
|
1260
1486
|
// some CDNs behind the API key off it; the Blob default is text/plain
|
|
1261
|
-
// which would look wrong for binary
|
|
1487
|
+
// which would look wrong for binary attachments.
|
|
1262
1488
|
const ext = path.extname(fileName).toLowerCase();
|
|
1263
1489
|
const mimeMap = {
|
|
1490
|
+
// image
|
|
1264
1491
|
'.png': 'image/png', '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg',
|
|
1265
1492
|
'.gif': 'image/gif', '.webp': 'image/webp', '.svg': 'image/svg+xml',
|
|
1266
1493
|
'.bmp': 'image/bmp', '.tiff': 'image/tiff', '.ico': 'image/x-icon',
|
|
1494
|
+
// doc / archive
|
|
1495
|
+
'.pdf': 'application/pdf', '.zip': 'application/zip',
|
|
1496
|
+
'.doc': 'application/msword',
|
|
1497
|
+
'.docx': 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
|
|
1498
|
+
'.xls': 'application/vnd.ms-excel',
|
|
1499
|
+
'.xlsx': 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
|
1500
|
+
'.ppt': 'application/vnd.ms-powerpoint',
|
|
1501
|
+
'.pptx': 'application/vnd.openxmlformats-officedocument.presentationml.presentation',
|
|
1502
|
+
'.txt': 'text/plain', '.md': 'text/markdown', '.csv': 'text/csv', '.json': 'application/json',
|
|
1267
1503
|
};
|
|
1268
1504
|
const contentType = mimeMap[ext] || 'application/octet-stream';
|
|
1269
1505
|
|
|
@@ -1283,22 +1519,84 @@ class LarkOfficialClient {
|
|
|
1283
1519
|
return res.json();
|
|
1284
1520
|
};
|
|
1285
1521
|
|
|
1286
|
-
// User identity first —
|
|
1522
|
+
// User identity first — host resources are usually user-owned.
|
|
1523
|
+
if (this.hasUAT) {
|
|
1524
|
+
try {
|
|
1525
|
+
const data = await this._withUAT(doUpload);
|
|
1526
|
+
if (data.code === 0 && data.data?.file_token) {
|
|
1527
|
+
return { fileToken: data.data.file_token, viaUser: true };
|
|
1528
|
+
}
|
|
1529
|
+
console.error(`[feishu-user-plugin] uploadMedia (${parentType}) as user failed (${data.code}: ${data.msg}), retrying as app`);
|
|
1530
|
+
} catch (e) {
|
|
1531
|
+
console.error(`[feishu-user-plugin] uploadMedia (${parentType}) as user threw (${e.message}), retrying as app`);
|
|
1532
|
+
}
|
|
1533
|
+
}
|
|
1534
|
+
const appToken = await this._getAppToken();
|
|
1535
|
+
const data = await doUpload(appToken);
|
|
1536
|
+
if (data.code !== 0 || !data.data?.file_token) {
|
|
1537
|
+
throw new Error(`uploadMedia (${parentType}) failed: ${data.code}: ${data.msg || 'no file_token returned'}`);
|
|
1538
|
+
}
|
|
1539
|
+
return { fileToken: data.data.file_token, viaUser: false };
|
|
1540
|
+
}
|
|
1541
|
+
|
|
1542
|
+
// Backwards-compat alias — old name from v1.3.4.
|
|
1543
|
+
async uploadDocMedia(filePath, parentNode, parentType = 'docx_image') {
|
|
1544
|
+
return this.uploadMedia(filePath, parentNode, parentType);
|
|
1545
|
+
}
|
|
1546
|
+
|
|
1547
|
+
// Upload a file to a drive folder (NOT for embedding in a doc — that's
|
|
1548
|
+
// uploadMedia). Uses drive/v1/files/upload_all with parent_type=explorer.
|
|
1549
|
+
// Returns { fileToken, viaUser } where fileToken is the cloud-doc file id.
|
|
1550
|
+
async uploadDriveFile(filePath, folderToken) {
|
|
1551
|
+
const fs = require('fs');
|
|
1552
|
+
const path = require('path');
|
|
1553
|
+
if (!filePath) throw new Error('uploadDriveFile: filePath is required');
|
|
1554
|
+
if (!folderToken) throw new Error('uploadDriveFile: folderToken is required (use the destination folder token; for "my space" root call list_files first to get it)');
|
|
1555
|
+
|
|
1556
|
+
const stat = fs.statSync(filePath);
|
|
1557
|
+
const fileName = path.basename(filePath);
|
|
1558
|
+
const buf = fs.readFileSync(filePath);
|
|
1559
|
+
const ext = path.extname(fileName).toLowerCase();
|
|
1560
|
+
const mimeMap = {
|
|
1561
|
+
'.pdf': 'application/pdf', '.zip': 'application/zip',
|
|
1562
|
+
'.png': 'image/png', '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg',
|
|
1563
|
+
'.txt': 'text/plain', '.md': 'text/markdown', '.csv': 'text/csv', '.json': 'application/json',
|
|
1564
|
+
'.docx': 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
|
|
1565
|
+
'.xlsx': 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
|
1566
|
+
};
|
|
1567
|
+
const contentType = mimeMap[ext] || 'application/octet-stream';
|
|
1568
|
+
|
|
1569
|
+
const doUpload = async (bearer) => {
|
|
1570
|
+
const form = new FormData();
|
|
1571
|
+
form.append('file_name', fileName);
|
|
1572
|
+
form.append('parent_type', 'explorer');
|
|
1573
|
+
form.append('parent_node', folderToken);
|
|
1574
|
+
form.append('size', String(stat.size));
|
|
1575
|
+
form.append('file', new Blob([buf], { type: contentType }), fileName);
|
|
1576
|
+
const res = await fetchWithTimeout('https://open.feishu.cn/open-apis/drive/v1/files/upload_all', {
|
|
1577
|
+
method: 'POST',
|
|
1578
|
+
headers: { 'Authorization': `Bearer ${bearer}` },
|
|
1579
|
+
body: form,
|
|
1580
|
+
timeoutMs: 120000,
|
|
1581
|
+
});
|
|
1582
|
+
return res.json();
|
|
1583
|
+
};
|
|
1584
|
+
|
|
1287
1585
|
if (this.hasUAT) {
|
|
1288
1586
|
try {
|
|
1289
1587
|
const data = await this._withUAT(doUpload);
|
|
1290
1588
|
if (data.code === 0 && data.data?.file_token) {
|
|
1291
1589
|
return { fileToken: data.data.file_token, viaUser: true };
|
|
1292
1590
|
}
|
|
1293
|
-
console.error(`[feishu-user-plugin]
|
|
1591
|
+
console.error(`[feishu-user-plugin] uploadDriveFile as user failed (${data.code}: ${data.msg}), retrying as app`);
|
|
1294
1592
|
} catch (e) {
|
|
1295
|
-
console.error(`[feishu-user-plugin]
|
|
1593
|
+
console.error(`[feishu-user-plugin] uploadDriveFile as user threw (${e.message}), retrying as app`);
|
|
1296
1594
|
}
|
|
1297
1595
|
}
|
|
1298
1596
|
const appToken = await this._getAppToken();
|
|
1299
1597
|
const data = await doUpload(appToken);
|
|
1300
1598
|
if (data.code !== 0 || !data.data?.file_token) {
|
|
1301
|
-
throw new Error(`
|
|
1599
|
+
throw new Error(`uploadDriveFile failed: ${data.code}: ${data.msg || 'no file_token returned'}`);
|
|
1302
1600
|
}
|
|
1303
1601
|
return { fileToken: data.data.file_token, viaUser: false };
|
|
1304
1602
|
}
|
|
@@ -1335,8 +1633,9 @@ class LarkOfficialClient {
|
|
|
1335
1633
|
// Step 2 — upload (if needed).
|
|
1336
1634
|
let finalToken = imageToken;
|
|
1337
1635
|
let viaUser = !!created._viaUser;
|
|
1636
|
+
let fallbackWarning = created._fallbackWarning || null;
|
|
1338
1637
|
if (!finalToken) {
|
|
1339
|
-
const uploaded = await this.
|
|
1638
|
+
const uploaded = await this.uploadMedia(imagePath, blockId, 'docx_image');
|
|
1340
1639
|
finalToken = uploaded.fileToken;
|
|
1341
1640
|
viaUser = viaUser && uploaded.viaUser; // true iff both steps went via user
|
|
1342
1641
|
}
|
|
@@ -1354,12 +1653,12 @@ class LarkOfficialClient {
|
|
|
1354
1653
|
label: 'createDocBlockWithImage.replaceImage',
|
|
1355
1654
|
});
|
|
1356
1655
|
|
|
1357
|
-
return { blockId, imageToken: finalToken, viaUser };
|
|
1656
|
+
return { blockId, imageToken: finalToken, viaUser, fallbackWarning };
|
|
1358
1657
|
}
|
|
1359
1658
|
|
|
1360
1659
|
// Replace an existing image block's media token (e.g. swap the picture in an
|
|
1361
1660
|
// already-created image block). Expects an uploaded media token — use
|
|
1362
|
-
//
|
|
1661
|
+
// uploadMedia or create_doc_block's image_path shortcut to obtain one.
|
|
1363
1662
|
async updateDocBlockImage(documentId, blockId, imageToken) {
|
|
1364
1663
|
const patch = buildReplaceImagePayload(imageToken);
|
|
1365
1664
|
await this._asUserOrApp({
|
|
@@ -1375,6 +1674,125 @@ class LarkOfficialClient {
|
|
|
1375
1674
|
return { blockId, imageToken };
|
|
1376
1675
|
}
|
|
1377
1676
|
|
|
1677
|
+
// Create a file-attachment block in a docx, mirroring createDocBlockWithImage:
|
|
1678
|
+
// 1) create empty file placeholder block
|
|
1679
|
+
// 2) upload the binary via uploadMedia(parent_type=docx_file)
|
|
1680
|
+
// 3) PATCH with replace_file.token to attach
|
|
1681
|
+
// Returns { blockId, fileToken, viaUser, fallbackWarning }.
|
|
1682
|
+
async createDocBlockWithFile(documentId, parentBlockId, { filePath, fileToken, index } = {}) {
|
|
1683
|
+
if (!filePath && !fileToken) {
|
|
1684
|
+
throw new Error('createDocBlockWithFile: either filePath or fileToken is required');
|
|
1685
|
+
}
|
|
1686
|
+
const placeholder = buildEmptyFileBlock();
|
|
1687
|
+
const createBody = { children: [placeholder] };
|
|
1688
|
+
if (index !== undefined) createBody.index = index;
|
|
1689
|
+
const created = await this._asUserOrApp({
|
|
1690
|
+
uatPath: `/open-apis/docx/v1/documents/${documentId}/blocks/${parentBlockId}/children`,
|
|
1691
|
+
method: 'POST',
|
|
1692
|
+
body: createBody,
|
|
1693
|
+
sdkFn: () => this.client.docx.documentBlockChildren.create({
|
|
1694
|
+
path: { document_id: documentId, block_id: parentBlockId },
|
|
1695
|
+
data: createBody,
|
|
1696
|
+
}),
|
|
1697
|
+
label: 'createDocBlockWithFile.placeholder',
|
|
1698
|
+
});
|
|
1699
|
+
// Feishu auto-wraps a FILE block (block_type=23) in a VIEW block
|
|
1700
|
+
// (block_type=33) — the create response returns the OUTER view block.
|
|
1701
|
+
// We need the inner file block's id for both the media upload (parent_node)
|
|
1702
|
+
// and the replace_file PATCH. Walk children to find it; fall back to a
|
|
1703
|
+
// get_doc_blocks lookup if the response didn't materialize the descendant.
|
|
1704
|
+
const newBlock = (created.data.children || [])[0];
|
|
1705
|
+
const outerBlockId = newBlock?.block_id;
|
|
1706
|
+
if (!outerBlockId) throw new Error(`createDocBlockWithFile: placeholder creation returned no block_id: ${JSON.stringify(created.data).slice(0, 400)}`);
|
|
1707
|
+
// Feishu auto-wraps a FILE block (23) in a VIEW block (33). The create
|
|
1708
|
+
// response's outer block is the view; we need to find the inner file
|
|
1709
|
+
// block for both the media upload (parent_node) and the replace_file PATCH.
|
|
1710
|
+
let blockId = outerBlockId;
|
|
1711
|
+
if (newBlock.block_type !== 23) {
|
|
1712
|
+
const inner = await this._findFileChildOf(documentId, outerBlockId, newBlock.children);
|
|
1713
|
+
if (!inner) throw new Error(`createDocBlockWithFile: could not locate inner FILE block under view ${outerBlockId}`);
|
|
1714
|
+
blockId = inner;
|
|
1715
|
+
}
|
|
1716
|
+
|
|
1717
|
+
let finalToken = fileToken;
|
|
1718
|
+
let viaUser = !!created._viaUser;
|
|
1719
|
+
let fallbackWarning = created._fallbackWarning || null;
|
|
1720
|
+
if (!finalToken) {
|
|
1721
|
+
const uploaded = await this.uploadMedia(filePath, blockId, 'docx_file');
|
|
1722
|
+
finalToken = uploaded.fileToken;
|
|
1723
|
+
viaUser = viaUser && uploaded.viaUser;
|
|
1724
|
+
}
|
|
1725
|
+
|
|
1726
|
+
const patch = buildReplaceFilePayload(finalToken);
|
|
1727
|
+
await this._asUserOrApp({
|
|
1728
|
+
uatPath: `/open-apis/docx/v1/documents/${documentId}/blocks/${blockId}`,
|
|
1729
|
+
method: 'PATCH',
|
|
1730
|
+
body: patch,
|
|
1731
|
+
sdkFn: () => this.client.docx.documentBlock.patch({
|
|
1732
|
+
path: { document_id: documentId, block_id: blockId },
|
|
1733
|
+
data: patch,
|
|
1734
|
+
}),
|
|
1735
|
+
label: 'createDocBlockWithFile.replaceFile',
|
|
1736
|
+
});
|
|
1737
|
+
|
|
1738
|
+
return { blockId, viewBlockId: outerBlockId !== blockId ? outerBlockId : undefined, fileToken: finalToken, viaUser, fallbackWarning };
|
|
1739
|
+
}
|
|
1740
|
+
|
|
1741
|
+
// Helper for createDocBlockWithFile — given a view block id and the children
|
|
1742
|
+
// array surfaced by the create response (just IDs in docx v1), find the
|
|
1743
|
+
// FILE child (block_type=23). If no children list was returned, fall back
|
|
1744
|
+
// to listing the doc and walking by parent_id.
|
|
1745
|
+
async _findFileChildOf(documentId, viewBlockId, childIds) {
|
|
1746
|
+
if (Array.isArray(childIds) && childIds.length > 0) {
|
|
1747
|
+
// childIds[0] is most likely the file block — verify with a get
|
|
1748
|
+
for (const childId of childIds) {
|
|
1749
|
+
try {
|
|
1750
|
+
const res = await this._asUserOrApp({
|
|
1751
|
+
uatPath: `/open-apis/docx/v1/documents/${documentId}/blocks/${childId}`,
|
|
1752
|
+
method: 'GET',
|
|
1753
|
+
sdkFn: () => this.client.docx.documentBlock.get({ path: { document_id: documentId, block_id: childId } }),
|
|
1754
|
+
label: '_findFileChildOf.get',
|
|
1755
|
+
});
|
|
1756
|
+
if (res?.data?.block?.block_type === 23) return childId;
|
|
1757
|
+
} catch (_) { /* fall through */ }
|
|
1758
|
+
}
|
|
1759
|
+
// None matched directly; return the first as best-effort
|
|
1760
|
+
return childIds[0];
|
|
1761
|
+
}
|
|
1762
|
+
// Fallback: list all blocks and find a 23 whose parent_id is the view block
|
|
1763
|
+
try {
|
|
1764
|
+
const res = await this._asUserOrApp({
|
|
1765
|
+
uatPath: `/open-apis/docx/v1/documents/${documentId}/blocks`,
|
|
1766
|
+
method: 'GET',
|
|
1767
|
+
sdkFn: () => this.client.docx.documentBlock.list({ path: { document_id: documentId } }),
|
|
1768
|
+
label: '_findFileChildOf.list',
|
|
1769
|
+
});
|
|
1770
|
+
const items = res?.data?.items || [];
|
|
1771
|
+
const match = items.find(b => b.block_type === 23 && b.parent_id === viewBlockId);
|
|
1772
|
+
return match?.block_id || null;
|
|
1773
|
+
} catch (_) {
|
|
1774
|
+
return null;
|
|
1775
|
+
}
|
|
1776
|
+
}
|
|
1777
|
+
|
|
1778
|
+
// Replace an existing file block's media token. Expects an already-uploaded
|
|
1779
|
+
// file token (use uploadMedia with parent_type=docx_file, or
|
|
1780
|
+
// create_doc_block's file_path shortcut).
|
|
1781
|
+
async updateDocBlockFile(documentId, blockId, fileToken) {
|
|
1782
|
+
const patch = buildReplaceFilePayload(fileToken);
|
|
1783
|
+
await this._asUserOrApp({
|
|
1784
|
+
uatPath: `/open-apis/docx/v1/documents/${documentId}/blocks/${blockId}`,
|
|
1785
|
+
method: 'PATCH',
|
|
1786
|
+
body: patch,
|
|
1787
|
+
sdkFn: () => this.client.docx.documentBlock.patch({
|
|
1788
|
+
path: { document_id: documentId, block_id: blockId },
|
|
1789
|
+
data: patch,
|
|
1790
|
+
}),
|
|
1791
|
+
label: 'updateDocBlockFile',
|
|
1792
|
+
});
|
|
1793
|
+
return { blockId, fileToken };
|
|
1794
|
+
}
|
|
1795
|
+
|
|
1378
1796
|
// --- Wiki attach (v1.3.4) ---
|
|
1379
1797
|
|
|
1380
1798
|
// Move an existing drive resource (docx / bitable / sheet / ...) into a Wiki
|