feishu-user-plugin 1.3.3 → 1.3.5

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/src/official.js CHANGED
@@ -1,5 +1,7 @@
1
1
  const lark = require('@larksuiteoapi/node-sdk');
2
2
  const { fetchWithTimeout } = require('./utils');
3
+ const { classifyError } = require('./error-codes');
4
+ const { buildEmptyImageBlock, buildReplaceImagePayload } = require('./doc-blocks');
3
5
 
4
6
  // Redirect all Lark SDK logs to stderr.
5
7
  // The SDK's defaultLogger.error uses console.log (stdout), which corrupts
@@ -32,7 +34,7 @@ class LarkOfficialClient {
32
34
  if (token) {
33
35
  this._uat = token;
34
36
  this._uatRefresh = refresh || null;
35
- this._uatExpires = expires;
37
+ this._uatExpires = expires || this._decodeTokenExpiry(token);
36
38
  }
37
39
  }
38
40
 
@@ -88,6 +90,7 @@ class LarkOfficialClient {
88
90
  if (!this._uat) throw new Error('No user_access_token. Run: npx feishu-user-plugin oauth');
89
91
 
90
92
  const now = Math.floor(Date.now() / 1000);
93
+ if (!this._uatExpires) this._uatExpires = this._decodeTokenExpiry(this._uat);
91
94
  // Proactively refresh if we know it's expiring within 5 min
92
95
  if (this._uatExpires > 0 && this._uatExpires <= now + 300) {
93
96
  return this._refreshUAT();
@@ -95,30 +98,125 @@ class LarkOfficialClient {
95
98
  return this._uat;
96
99
  }
97
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
+
98
180
  async _refreshUAT() {
99
- if (!this._uatRefresh) throw new Error('UAT expired and no refresh token. Run: npx feishu-user-plugin oauth');
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
+ }
100
193
 
101
- const res = await fetchWithTimeout('https://open.feishu.cn/open-apis/authen/v2/oauth/token', {
102
- method: 'POST',
103
- headers: { 'content-type': 'application/json' },
104
- body: JSON.stringify({
105
- grant_type: 'refresh_token',
106
- client_id: this.appId,
107
- client_secret: this.appSecret,
108
- refresh_token: this._uatRefresh,
109
- }),
110
- });
111
- const data = await res.json();
112
- const tokenData = data.access_token ? data : data.data;
113
- if (!tokenData?.access_token) throw new Error(`UAT refresh failed: ${JSON.stringify(data)}. Run: npx feishu-user-plugin oauth`);
114
-
115
- this._uat = tokenData.access_token;
116
- this._uatRefresh = tokenData.refresh_token || this._uatRefresh;
117
- const expiresIn = typeof tokenData.expires_in === 'number' && tokenData.expires_in > 0 ? tokenData.expires_in : 7200;
118
- this._uatExpires = Math.floor(Date.now() / 1000) + expiresIn;
119
- this._persistUAT();
120
- console.error('[feishu-user-plugin] UAT refreshed successfully');
121
- return this._uat;
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
+ }
122
220
  }
123
221
 
124
222
  _persistUAT() {
@@ -139,6 +237,14 @@ class LarkOfficialClient {
139
237
  const data = await fn(uat);
140
238
  // Known auth error codes: 99991668 (invalid), 99991663 (expired), 99991677 (auth_expired)
141
239
  if (data.code === 99991668 || data.code === 99991663 || data.code === 99991677) {
240
+ // 99991668 is overloaded: "invalid token" (→ refresh helps) vs
241
+ // "endpoint doesn't support UAT at all" (→ refresh is pointless, and
242
+ // worse, it consumes a one-shot refresh_token rotation). The second
243
+ // case is identifiable by the msg "user access token not support" or
244
+ // "not support". If so, surface the code to the caller without refresh.
245
+ if (data.code === 99991668 && typeof data.msg === 'string' && /not support/i.test(data.msg)) {
246
+ return data;
247
+ }
142
248
  // Token invalid/expired — try refresh once
143
249
  uat = await this._refreshUAT();
144
250
  return fn(uat);
@@ -147,8 +253,20 @@ class LarkOfficialClient {
147
253
  }
148
254
 
149
255
  // Generic UAT REST helper. Returns parsed JSON ({code, msg, data}).
256
+ // Array query values are expanded to repeated keys (period_ids=a&period_ids=b)
257
+ // because several Feishu endpoints (OKR, calendar) rely on that convention.
150
258
  async _uatREST(method, path, { body, query } = {}) {
151
- const qs = query ? '?' + new URLSearchParams(query).toString() : '';
259
+ let qs = '';
260
+ if (query) {
261
+ const sp = new URLSearchParams();
262
+ for (const [k, v] of Object.entries(query)) {
263
+ if (v === undefined || v === null) continue;
264
+ if (Array.isArray(v)) { for (const item of v) sp.append(k, String(item)); }
265
+ else sp.append(k, String(v));
266
+ }
267
+ const str = sp.toString();
268
+ if (str) qs = '?' + str;
269
+ }
152
270
  const url = 'https://open.feishu.cn' + path + qs;
153
271
  return this._withUAT(async (uat) => {
154
272
  const headers = { 'Authorization': `Bearer ${uat}` };
@@ -166,7 +284,12 @@ class LarkOfficialClient {
166
284
  // Returns SDK-shaped {code, msg, data, _viaUser}. _viaUser is true iff the UAT call succeeded;
167
285
  // callers can surface this to distinguish "created by user" vs "created by app" for resources
168
286
  // whose ownership matters (docs, bitables, folders).
287
+ //
288
+ // When BOTH paths fail (common for OKR/Calendar if neither UAT nor app has the scope),
289
+ // the final error includes the UAT-side reason too, so the user can tell whether they
290
+ // need a new OAuth (UAT missing scope) or a different app (app missing scope).
169
291
  async _asUserOrApp({ uatPath, method = 'GET', body, query, sdkFn, label }) {
292
+ let uatSummary = null;
170
293
  if (this.hasUAT) {
171
294
  try {
172
295
  const data = await this._uatREST(method, uatPath, { body, query });
@@ -174,14 +297,36 @@ class LarkOfficialClient {
174
297
  data._viaUser = true;
175
298
  return data;
176
299
  }
177
- console.error(`[feishu-user-plugin] ${label} as user failed (${data.code}: ${data.msg}), retrying as app`);
300
+ uatSummary = `as user: code=${data.code} msg=${data.msg}`;
301
+ console.error(`[feishu-user-plugin] ${label} ${uatSummary}, retrying as app`);
178
302
  } catch (err) {
303
+ uatSummary = `as user: ${err.message}`;
179
304
  console.error(`[feishu-user-plugin] ${label} as user threw (${err.message}), retrying as app`);
180
305
  }
181
306
  }
182
- const appData = await this._safeSDKCall(sdkFn, label);
183
- if (appData && typeof appData === 'object') appData._viaUser = false;
184
- return appData;
307
+ try {
308
+ const appData = await this._safeSDKCall(sdkFn, label);
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
+ }
320
+ return appData;
321
+ } catch (appErr) {
322
+ if (uatSummary) {
323
+ const err = new Error(`${label} failed on both identities. ${uatSummary}. as app: ${appErr.message}`);
324
+ err.uatSummary = uatSummary;
325
+ err.appError = appErr;
326
+ throw err;
327
+ }
328
+ throw appErr;
329
+ }
185
330
  }
186
331
 
187
332
  async listChatsAsUser({ pageSize = 20, pageToken } = {}) {
@@ -197,7 +342,7 @@ class LarkOfficialClient {
197
342
  return { items: data.data.items || [], pageToken: data.data.page_token, hasMore: data.data.has_more };
198
343
  }
199
344
 
200
- 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) {
201
346
  // Feishu API requires end_time >= start_time; auto-set end_time to now if missing
202
347
  if (startTime && !endTime) {
203
348
  endTime = String(Math.floor(Date.now() / 1000));
@@ -218,6 +363,7 @@ class LarkOfficialClient {
218
363
  if (data.code !== 0) throw new Error(`readMessagesAsUser failed (${data.code}): ${data.msg}`);
219
364
  const items = (data.data.items || []).map(m => this._formatMessage(m));
220
365
  await this._populateSenderNames(items, userClient);
366
+ if (expandMergeForward) await this._expandMergeForwardItems(items, userClient, { preferUAT: true });
221
367
  return { items, hasMore: data.data.has_more, pageToken: data.data.page_token };
222
368
  }
223
369
 
@@ -231,7 +377,7 @@ class LarkOfficialClient {
231
377
  return { items: res.data.items || [], pageToken: res.data.page_token, hasMore: res.data.has_more };
232
378
  }
233
379
 
234
- 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) {
235
381
  const params = { container_id_type: 'chat', container_id: chatId, page_size: pageSize, sort_type: sortType };
236
382
  if (startTime) params.start_time = startTime;
237
383
  if (endTime) params.end_time = endTime;
@@ -239,6 +385,7 @@ class LarkOfficialClient {
239
385
  const res = await this._safeSDKCall(() => this.client.im.message.list({ params }), 'readMessages');
240
386
  const items = (res.data.items || []).map(m => this._formatMessage(m));
241
387
  await this._populateSenderNames(items, userClient);
388
+ if (expandMergeForward) await this._expandMergeForwardItems(items, userClient, { preferUAT: false });
242
389
  return { items, hasMore: res.data.has_more, pageToken: res.data.page_token };
243
390
  }
244
391
 
@@ -514,7 +661,7 @@ class LarkOfficialClient {
514
661
  return { content: res.data.content };
515
662
  }
516
663
 
517
- async createDoc(title, folderId) {
664
+ async createDoc(title, folderId, { wikiSpaceId, wikiParentNodeToken } = {}) {
518
665
  const res = await this._asUserOrApp({
519
666
  uatPath: `/open-apis/docx/v1/documents`,
520
667
  method: 'POST',
@@ -522,7 +669,18 @@ class LarkOfficialClient {
522
669
  sdkFn: () => this.client.docx.document.create({ data: { title, folder_token: folderId || '' } }),
523
670
  label: 'createDoc',
524
671
  });
525
- return { documentId: res.data.document?.document_id, viaUser: !!res._viaUser };
672
+ const documentId = res.data.document?.document_id;
673
+ const out = { documentId, viaUser: !!res._viaUser, fallbackWarning: res._fallbackWarning || null };
674
+ if (documentId && wikiSpaceId) {
675
+ try {
676
+ const node = await this.attachToWiki(wikiSpaceId, 'docx', documentId, wikiParentNodeToken);
677
+ if (node?.node_token) out.wikiNodeToken = node.node_token;
678
+ else if (node?.task_id) out.wikiAttachTaskId = node.task_id;
679
+ } catch (e) {
680
+ out.wikiAttachError = e.message;
681
+ }
682
+ }
683
+ return out;
526
684
  }
527
685
 
528
686
  async getDocBlocks(documentId) {
@@ -548,7 +706,7 @@ class LarkOfficialClient {
548
706
  }),
549
707
  label: 'createDocBlock',
550
708
  });
551
- return { blocks: res.data.children || [] };
709
+ return { blocks: res.data.children || [], fallbackWarning: res._fallbackWarning || null };
552
710
  }
553
711
 
554
712
  async updateDocBlock(documentId, blockId, updateBody) {
@@ -591,7 +749,7 @@ class LarkOfficialClient {
591
749
 
592
750
  // --- Bitable ---
593
751
 
594
- async createBitable(name, folderId) {
752
+ async createBitable(name, folderId, { wikiSpaceId, wikiParentNodeToken } = {}) {
595
753
  const data = {};
596
754
  if (name) data.name = name;
597
755
  if (folderId) data.folder_token = folderId;
@@ -602,7 +760,18 @@ class LarkOfficialClient {
602
760
  sdkFn: () => this.client.bitable.app.create({ data }),
603
761
  label: 'createBitable',
604
762
  });
605
- return { appToken: res.data.app?.app_token, name: res.data.app?.name, url: res.data.app?.url, viaUser: !!res._viaUser };
763
+ const appToken = res.data.app?.app_token;
764
+ const out = { appToken, name: res.data.app?.name, url: res.data.app?.url, viaUser: !!res._viaUser, fallbackWarning: res._fallbackWarning || null };
765
+ if (appToken && wikiSpaceId) {
766
+ try {
767
+ const node = await this.attachToWiki(wikiSpaceId, 'bitable', appToken, wikiParentNodeToken);
768
+ if (node?.node_token) out.wikiNodeToken = node.node_token;
769
+ else if (node?.task_id) out.wikiAttachTaskId = node.task_id;
770
+ } catch (e) {
771
+ out.wikiAttachError = e.message;
772
+ }
773
+ }
774
+ return out;
606
775
  }
607
776
 
608
777
  async listBitableTables(appToken) {
@@ -625,7 +794,7 @@ class LarkOfficialClient {
625
794
  sdkFn: () => this.client.bitable.appTable.create({ path: { app_token: appToken }, data }),
626
795
  label: 'createTable',
627
796
  });
628
- return { tableId: res.data.table_id };
797
+ return { tableId: res.data.table_id, fallbackWarning: res._fallbackWarning || null };
629
798
  }
630
799
 
631
800
  async listBitableFields(appToken, tableId) {
@@ -645,7 +814,7 @@ class LarkOfficialClient {
645
814
  sdkFn: () => this.client.bitable.appTableField.create({ path: { app_token: appToken, table_id: tableId }, data: fieldConfig }),
646
815
  label: 'createField',
647
816
  });
648
- return { field: res.data.field };
817
+ return { field: res.data.field, fallbackWarning: res._fallbackWarning || null };
649
818
  }
650
819
 
651
820
  async updateBitableField(appToken, tableId, fieldId, fieldConfig) {
@@ -699,7 +868,7 @@ class LarkOfficialClient {
699
868
  sdkFn: () => this.client.bitable.appTableRecord.create({ path: { app_token: appToken, table_id: tableId }, data: { fields } }),
700
869
  label: 'createRecord',
701
870
  });
702
- return { recordId: res.data.record?.record_id };
871
+ return { recordId: res.data.record?.record_id, fallbackWarning: res._fallbackWarning || null };
703
872
  }
704
873
 
705
874
  async updateBitableRecord(appToken, tableId, recordId, fields) {
@@ -731,7 +900,7 @@ class LarkOfficialClient {
731
900
  sdkFn: () => this.client.bitable.appTableRecord.batchCreate({ path: { app_token: appToken, table_id: tableId }, data: { records } }),
732
901
  label: 'batchCreateRecords',
733
902
  });
734
- return { records: res.data.records || [] };
903
+ return { records: res.data.records || [], fallbackWarning: res._fallbackWarning || null };
735
904
  }
736
905
 
737
906
  async batchUpdateBitableRecords(appToken, tableId, records) {
@@ -813,7 +982,7 @@ class LarkOfficialClient {
813
982
  sdkFn: () => this.client.bitable.appTableView.create({ path: { app_token: appToken, table_id: tableId }, data: { view_name: viewName, view_type: viewType } }),
814
983
  label: 'createView',
815
984
  });
816
- return { view: res.data.view };
985
+ return { view: res.data.view, fallbackWarning: res._fallbackWarning || null };
817
986
  }
818
987
 
819
988
  async deleteBitableView(appToken, tableId, viewId) {
@@ -836,7 +1005,7 @@ class LarkOfficialClient {
836
1005
  sdkFn: () => this.client.bitable.app.copy({ path: { app_token: appToken }, data }),
837
1006
  label: 'copyBitable',
838
1007
  });
839
- return { app: res.data.app };
1008
+ return { app: res.data.app, fallbackWarning: res._fallbackWarning || null };
840
1009
  }
841
1010
 
842
1011
  // --- Wiki ---
@@ -854,7 +1023,10 @@ class LarkOfficialClient {
854
1023
  return { items: res.data.docs_entities || [] };
855
1024
  }
856
1025
 
857
- async getWikiNode(spaceId, nodeToken) {
1026
+ // Resolves a wiki node token to its underlying object (docx / sheet / bitable / ...).
1027
+ // `spaceId` argument is kept for backward compatibility but isn't used — the Feishu
1028
+ // endpoint `wiki.v2.getNode` takes only the token.
1029
+ async getWikiNode(nodeToken, _spaceId) {
858
1030
  const res = await this._safeSDKCall(() => this.client.wiki.space.getNode({ params: { token: nodeToken } }), 'getNode');
859
1031
  return res.data.node;
860
1032
  }
@@ -888,7 +1060,7 @@ class LarkOfficialClient {
888
1060
  sdkFn: () => this.client.drive.file.createFolder({ data: body }),
889
1061
  label: 'createFolder',
890
1062
  });
891
- return { token: res.data.token, viaUser: !!res._viaUser };
1063
+ return { token: res.data.token, viaUser: !!res._viaUser, fallbackWarning: res._fallbackWarning || null };
892
1064
  }
893
1065
 
894
1066
  // --- Drive: File Operations ---
@@ -1046,15 +1218,517 @@ class LarkOfficialClient {
1046
1218
  updateTime: this._normalizeTimestamp(m.update_time),
1047
1219
  };
1048
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
+ }
1049
1235
  return out;
1050
1236
  }
1051
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
+
1052
1323
  _normalizeTimestamp(ts) {
1053
1324
  if (!ts) return null;
1054
1325
  const n = parseInt(ts);
1055
1326
  // Feishu returns millisecond strings; normalize to seconds
1056
1327
  return String(n > 1e12 ? Math.floor(n / 1000) : n);
1057
1328
  }
1329
+
1330
+ // --- Hardened Message Read (v1.3.4) ---
1331
+
1332
+ // Reads messages with explicit fallback routing: tries the bot path first,
1333
+ // classifies any failure via error-codes.js, and escalates to UAT when
1334
+ // appropriate. Returns the same shape as readMessages/readMessagesAsUser
1335
+ // plus `via` ('bot' | 'user' | 'contacts') and, if fallback fired,
1336
+ // `via_reason` (a short enum from classifyError).
1337
+ //
1338
+ // If `skipBot` is true, the bot path is never attempted (callers use this
1339
+ // when the chat_id came from search_contacts — i.e. definitely external).
1340
+ //
1341
+ // Throws a single, wrapped error if BOTH paths fail or if UAT is absent and
1342
+ // the bot failed; the message points the user at `npx feishu-user-plugin oauth`.
1343
+ async readMessagesWithFallback(chatId, options, userClient, { skipBot = false, via = 'bot' } = {}) {
1344
+ const tryUAT = async (viaLabel, reason) => {
1345
+ if (!this.hasUAT) {
1346
+ const hint = 'To read external / private groups, configure UAT via: npx feishu-user-plugin oauth';
1347
+ const err = new Error(`Cannot read chat ${chatId} as bot (${reason || 'bot failed and no UAT configured'}). ${hint}`);
1348
+ err.viaReason = reason;
1349
+ throw err;
1350
+ }
1351
+ const data = await this.readMessagesAsUser(chatId, options, userClient);
1352
+ data.via = viaLabel;
1353
+ if (reason) data.via_reason = reason;
1354
+ return data;
1355
+ };
1356
+
1357
+ if (skipBot) {
1358
+ return tryUAT(via || 'contacts', 'contacts_resolved_external');
1359
+ }
1360
+
1361
+ // Attempt 1 — bot identity.
1362
+ try {
1363
+ const data = await this.readMessages(chatId, options, userClient);
1364
+ data.via = 'bot';
1365
+ return data;
1366
+ } catch (botErr) {
1367
+ const klass = classifyError(botErr);
1368
+ console.error(`[feishu-user-plugin] read_messages bot failed for ${chatId}: ${botErr.message} [class=${klass.action}, reason=${klass.reason}, code=${klass.code}]`);
1369
+
1370
+ if (klass.action === 'retry') {
1371
+ // One retry after short backoff before hopping to UAT.
1372
+ await new Promise(r => setTimeout(r, 2000));
1373
+ try {
1374
+ const data = await this.readMessages(chatId, options, userClient);
1375
+ data.via = 'bot';
1376
+ data.via_reason = klass.reason + '_recovered';
1377
+ return data;
1378
+ } catch (retryErr) {
1379
+ console.error(`[feishu-user-plugin] read_messages bot retry failed for ${chatId}: ${retryErr.message}`);
1380
+ }
1381
+ }
1382
+
1383
+ // Fall through to UAT — if UAT is missing, tryUAT throws the user-friendly
1384
+ // "run npx feishu-user-plugin oauth" error instead of the raw Feishu payload.
1385
+ return tryUAT('user', klass.reason);
1386
+ }
1387
+ }
1388
+
1389
+ // --- Docx Image Read (v1.3.4) ---
1390
+
1391
+ // Download a media asset (image, file, etc.) referenced from inside a Feishu
1392
+ // docx block. The model actually gets the pixels via MCP image content in the
1393
+ // handler layer; here we just return base64 + metadata.
1394
+ //
1395
+ // Feishu's drive/v1/medias/{token}/download requires a query `extra` with
1396
+ // a JSON-encoded doc_token when the media lives inside a doc (to pass
1397
+ // tenant-scoped auth). Passing extra is harmless for generic drive files.
1398
+ async downloadDocImage(imageToken, docToken, docType = 'docx') {
1399
+ if (!imageToken) throw new Error('downloadDocImage: imageToken is required');
1400
+ // Feishu's drive media download uses `extra` as a JSON-string query param to
1401
+ // identify the enclosing doc context. Most observed forms carry both
1402
+ // `doc_type` and `doc_token`; omitting docType falls back to 'docx' which
1403
+ // is the by-far most common case. Omitting extra entirely is safe for
1404
+ // standalone drive-media tokens that don't live inside a doc.
1405
+ const extra = docToken
1406
+ ? `?extra=${encodeURIComponent(JSON.stringify({ doc_type: docType, doc_token: docToken }))}`
1407
+ : '';
1408
+ const path = `/open-apis/drive/v1/medias/${encodeURIComponent(imageToken)}/download${extra}`;
1409
+ const url = 'https://open.feishu.cn' + path;
1410
+
1411
+ // Attempt 1 — user identity (most reliable for user-owned docs).
1412
+ if (this.hasUAT) {
1413
+ try {
1414
+ const uat = await this._getValidUAT();
1415
+ const res = await fetchWithTimeout(url, { headers: { 'Authorization': `Bearer ${uat}` }, timeoutMs: 60000 });
1416
+ if (res.ok && !res.headers.get('content-type')?.includes('application/json')) {
1417
+ const buf = Buffer.from(await res.arrayBuffer());
1418
+ return {
1419
+ base64: buf.toString('base64'),
1420
+ mimeType: res.headers.get('content-type') || 'application/octet-stream',
1421
+ bytes: buf.length,
1422
+ viaUser: true,
1423
+ };
1424
+ }
1425
+ const errJson = await res.json().catch(() => null);
1426
+ console.error(`[feishu-user-plugin] downloadDocImage as user failed: ${errJson?.code}: ${errJson?.msg || res.statusText}, retrying as app`);
1427
+ } catch (e) {
1428
+ console.error(`[feishu-user-plugin] downloadDocImage as user threw (${e.message}), retrying as app`);
1429
+ }
1430
+ }
1431
+
1432
+ // Attempt 2 — app identity. Requires the app to have drive access to the doc.
1433
+ const token = await this._getAppToken();
1434
+ const res = await fetchWithTimeout(url, { headers: { 'Authorization': `Bearer ${token}` }, timeoutMs: 60000 });
1435
+ if (!res.ok || res.headers.get('content-type')?.includes('application/json')) {
1436
+ const errJson = await res.json().catch(() => null);
1437
+ 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.`);
1438
+ }
1439
+ const buf = Buffer.from(await res.arrayBuffer());
1440
+ return {
1441
+ base64: buf.toString('base64'),
1442
+ mimeType: res.headers.get('content-type') || 'application/octet-stream',
1443
+ bytes: buf.length,
1444
+ viaUser: false,
1445
+ };
1446
+ }
1447
+
1448
+ // --- Docx Image Write (v1.3.4) ---
1449
+
1450
+ // Upload binary media (typically an image) to Feishu's drive layer so it can
1451
+ // be attached to a docx block. Returns the media's file_token, which is what
1452
+ // the image block's `replace_image.token` expects.
1453
+ //
1454
+ // parentType = 'docx_image' for doc-embedded images (most common).
1455
+ // parentNode = the block_id of the image placeholder (NOT the document_id).
1456
+ async uploadDocMedia(filePath, parentNode, parentType = 'docx_image') {
1457
+ const fs = require('fs');
1458
+ const path = require('path');
1459
+ if (!filePath) throw new Error('uploadDocMedia: filePath is required');
1460
+ if (!parentNode) throw new Error('uploadDocMedia: parentNode (block_id) is required');
1461
+
1462
+ const stat = fs.statSync(filePath);
1463
+ const fileName = path.basename(filePath);
1464
+ const buf = fs.readFileSync(filePath);
1465
+
1466
+ // Best-effort content-type from extension. Feishu doesn't require it but
1467
+ // some CDNs behind the API key off it; the Blob default is text/plain
1468
+ // which would look wrong for binary images.
1469
+ const ext = path.extname(fileName).toLowerCase();
1470
+ const mimeMap = {
1471
+ '.png': 'image/png', '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg',
1472
+ '.gif': 'image/gif', '.webp': 'image/webp', '.svg': 'image/svg+xml',
1473
+ '.bmp': 'image/bmp', '.tiff': 'image/tiff', '.ico': 'image/x-icon',
1474
+ };
1475
+ const contentType = mimeMap[ext] || 'application/octet-stream';
1476
+
1477
+ const doUpload = async (bearer) => {
1478
+ const form = new FormData();
1479
+ form.append('file_name', fileName);
1480
+ form.append('parent_type', parentType);
1481
+ form.append('parent_node', parentNode);
1482
+ form.append('size', String(stat.size));
1483
+ form.append('file', new Blob([buf], { type: contentType }), fileName);
1484
+ const res = await fetchWithTimeout('https://open.feishu.cn/open-apis/drive/v1/medias/upload_all', {
1485
+ method: 'POST',
1486
+ headers: { 'Authorization': `Bearer ${bearer}` },
1487
+ body: form,
1488
+ timeoutMs: 120000,
1489
+ });
1490
+ return res.json();
1491
+ };
1492
+
1493
+ // User identity first — docx_image usually belongs to a user-owned doc.
1494
+ if (this.hasUAT) {
1495
+ try {
1496
+ const data = await this._withUAT(doUpload);
1497
+ if (data.code === 0 && data.data?.file_token) {
1498
+ return { fileToken: data.data.file_token, viaUser: true };
1499
+ }
1500
+ console.error(`[feishu-user-plugin] uploadDocMedia as user failed (${data.code}: ${data.msg}), retrying as app`);
1501
+ } catch (e) {
1502
+ console.error(`[feishu-user-plugin] uploadDocMedia as user threw (${e.message}), retrying as app`);
1503
+ }
1504
+ }
1505
+ const appToken = await this._getAppToken();
1506
+ const data = await doUpload(appToken);
1507
+ if (data.code !== 0 || !data.data?.file_token) {
1508
+ throw new Error(`uploadDocMedia failed: ${data.code}: ${data.msg || 'no file_token returned'}`);
1509
+ }
1510
+ return { fileToken: data.data.file_token, viaUser: false };
1511
+ }
1512
+
1513
+ // Create a new image block and populate it from either a local file path or
1514
+ // an already-uploaded media token. Orchestrates the three-step Feishu flow:
1515
+ // 1) create empty image placeholder block
1516
+ // 2) upload pixels (skipped if caller passes a ready-made imageToken)
1517
+ // 3) patch the placeholder with the uploaded token
1518
+ // Returns { blockId, imageToken, viaUser }.
1519
+ async createDocBlockWithImage(documentId, parentBlockId, { imagePath, imageToken, index } = {}) {
1520
+ if (!imagePath && !imageToken) {
1521
+ throw new Error('createDocBlockWithImage: either imagePath or imageToken is required');
1522
+ }
1523
+
1524
+ // Step 1 — empty placeholder.
1525
+ const placeholder = buildEmptyImageBlock();
1526
+ const createBody = { children: [placeholder] };
1527
+ if (index !== undefined) createBody.index = index;
1528
+ const created = await this._asUserOrApp({
1529
+ uatPath: `/open-apis/docx/v1/documents/${documentId}/blocks/${parentBlockId}/children`,
1530
+ method: 'POST',
1531
+ body: createBody,
1532
+ sdkFn: () => this.client.docx.documentBlockChildren.create({
1533
+ path: { document_id: documentId, block_id: parentBlockId },
1534
+ data: createBody,
1535
+ }),
1536
+ label: 'createDocBlockWithImage.placeholder',
1537
+ });
1538
+ const newBlock = (created.data.children || [])[0];
1539
+ const blockId = newBlock?.block_id;
1540
+ if (!blockId) throw new Error(`createDocBlockWithImage: placeholder creation returned no block_id: ${JSON.stringify(created.data).slice(0, 400)}`);
1541
+
1542
+ // Step 2 — upload (if needed).
1543
+ let finalToken = imageToken;
1544
+ let viaUser = !!created._viaUser;
1545
+ let fallbackWarning = created._fallbackWarning || null;
1546
+ if (!finalToken) {
1547
+ const uploaded = await this.uploadDocMedia(imagePath, blockId, 'docx_image');
1548
+ finalToken = uploaded.fileToken;
1549
+ viaUser = viaUser && uploaded.viaUser; // true iff both steps went via user
1550
+ }
1551
+
1552
+ // Step 3 — attach token to the placeholder via PATCH replace_image.
1553
+ const patch = buildReplaceImagePayload(finalToken);
1554
+ await this._asUserOrApp({
1555
+ uatPath: `/open-apis/docx/v1/documents/${documentId}/blocks/${blockId}`,
1556
+ method: 'PATCH',
1557
+ body: patch,
1558
+ sdkFn: () => this.client.docx.documentBlock.patch({
1559
+ path: { document_id: documentId, block_id: blockId },
1560
+ data: patch,
1561
+ }),
1562
+ label: 'createDocBlockWithImage.replaceImage',
1563
+ });
1564
+
1565
+ return { blockId, imageToken: finalToken, viaUser, fallbackWarning };
1566
+ }
1567
+
1568
+ // Replace an existing image block's media token (e.g. swap the picture in an
1569
+ // already-created image block). Expects an uploaded media token — use
1570
+ // uploadDocMedia or create_doc_block's image_path shortcut to obtain one.
1571
+ async updateDocBlockImage(documentId, blockId, imageToken) {
1572
+ const patch = buildReplaceImagePayload(imageToken);
1573
+ await this._asUserOrApp({
1574
+ uatPath: `/open-apis/docx/v1/documents/${documentId}/blocks/${blockId}`,
1575
+ method: 'PATCH',
1576
+ body: patch,
1577
+ sdkFn: () => this.client.docx.documentBlock.patch({
1578
+ path: { document_id: documentId, block_id: blockId },
1579
+ data: patch,
1580
+ }),
1581
+ label: 'updateDocBlockImage',
1582
+ });
1583
+ return { blockId, imageToken };
1584
+ }
1585
+
1586
+ // --- Wiki attach (v1.3.4) ---
1587
+
1588
+ // Move an existing drive resource (docx / bitable / sheet / ...) into a Wiki
1589
+ // space as an 'origin' node. Used by createDoc / createBitable when their
1590
+ // wikiSpaceId option is set.
1591
+ //
1592
+ // Uses wiki/v2/spaces/{space_id}/nodes/move_docs_to_wiki — the documented path
1593
+ // for migrating an existing drive doc into wiki. Note: this endpoint is async;
1594
+ // if the move completes immediately (typical for newly-created docs) we get
1595
+ // back a wiki_token and surface it as node_token. If it's queued we return
1596
+ // { task_id } so the caller can see the async state — we don't currently poll.
1597
+ async attachToWiki(spaceId, objType, objToken, parentNodeToken) {
1598
+ if (!spaceId) throw new Error('attachToWiki: spaceId is required');
1599
+ if (!objType) throw new Error('attachToWiki: objType is required');
1600
+ if (!objToken) throw new Error('attachToWiki: objToken is required');
1601
+ const body = { obj_type: objType, obj_token: objToken, apply: true };
1602
+ if (parentNodeToken) body.parent_wiki_token = parentNodeToken;
1603
+ const res = await this._asUserOrApp({
1604
+ uatPath: `/open-apis/wiki/v2/spaces/${encodeURIComponent(spaceId)}/nodes/move_docs_to_wiki`,
1605
+ method: 'POST',
1606
+ body,
1607
+ sdkFn: () => this.client.wiki.spaceNode.moveDocsToWiki({ path: { space_id: spaceId }, data: body }),
1608
+ label: 'attachToWiki',
1609
+ });
1610
+ const data = res.data || {};
1611
+ if (data.wiki_token) return { node_token: data.wiki_token, applied: !!data.applied };
1612
+ if (data.task_id) return { task_id: data.task_id, applied: false };
1613
+ return data;
1614
+ }
1615
+
1616
+ // --- OKR (v1.3.4) ---
1617
+
1618
+ async listUserOkrs(userId, { periodIds, offset = 0, limit = 10, lang, userIdType = 'open_id' } = {}) {
1619
+ 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.');
1620
+ const params = { user_id_type: userIdType, offset: String(offset), limit: String(limit) };
1621
+ if (lang) params.lang = lang;
1622
+ if (periodIds && periodIds.length) params.period_ids = periodIds;
1623
+ const res = await this._asUserOrApp({
1624
+ uatPath: `/open-apis/okr/v1/users/${encodeURIComponent(userId)}/okrs`,
1625
+ query: params,
1626
+ sdkFn: () => this.client.okr.userOkr.list({
1627
+ path: { user_id: userId },
1628
+ params: {
1629
+ user_id_type: userIdType,
1630
+ offset: String(offset),
1631
+ limit: String(limit),
1632
+ ...(lang ? { lang } : {}),
1633
+ ...(periodIds && periodIds.length ? { period_ids: periodIds } : {}),
1634
+ },
1635
+ }),
1636
+ label: 'listUserOkrs',
1637
+ });
1638
+ return { total: res.data.total, items: res.data.okr_list || [] };
1639
+ }
1640
+
1641
+ async getOkrs(okrIds, { lang, userIdType = 'open_id' } = {}) {
1642
+ if (!Array.isArray(okrIds) || okrIds.length === 0) {
1643
+ throw new Error('getOkrs: okrIds must be a non-empty array');
1644
+ }
1645
+ const params = { user_id_type: userIdType, okr_ids: okrIds };
1646
+ if (lang) params.lang = lang;
1647
+ // UAT REST path takes repeated okr_ids= params; URLSearchParams will serialize an array properly
1648
+ const res = await this._asUserOrApp({
1649
+ uatPath: `/open-apis/okr/v1/okrs/batch_get`,
1650
+ query: params,
1651
+ sdkFn: () => this.client.okr.okr.batchGet({ params }),
1652
+ label: 'getOkrs',
1653
+ });
1654
+ return { items: res.data.okr_list || [] };
1655
+ }
1656
+
1657
+ async listOkrPeriods({ pageSize = 10, pageToken } = {}) {
1658
+ const params = { page_size: String(pageSize) };
1659
+ if (pageToken) params.page_token = pageToken;
1660
+ const res = await this._asUserOrApp({
1661
+ uatPath: `/open-apis/okr/v1/periods`,
1662
+ query: params,
1663
+ sdkFn: () => this.client.okr.period.list({ params: { page_size: pageSize, ...(pageToken ? { page_token: pageToken } : {}) } }),
1664
+ label: 'listOkrPeriods',
1665
+ });
1666
+ return { items: res.data.items || [], pageToken: res.data.page_token, hasMore: res.data.has_more };
1667
+ }
1668
+
1669
+ // --- Calendar (v1.3.4) ---
1670
+
1671
+ async listCalendars({ pageSize = 50, pageToken, syncToken } = {}) {
1672
+ // Feishu's calendar/v4/calendars endpoint rejects page_size < 50 with
1673
+ // `99992402 field validation failed` ("the min value is 50"). The docs don't
1674
+ // flag this — smoke-tested against the real API. Clamp to be safe.
1675
+ const ps = Math.max(50, Number(pageSize) || 50);
1676
+ const params = { page_size: String(ps) };
1677
+ if (pageToken) params.page_token = pageToken;
1678
+ if (syncToken) params.sync_token = syncToken;
1679
+ const res = await this._asUserOrApp({
1680
+ uatPath: `/open-apis/calendar/v4/calendars`,
1681
+ query: params,
1682
+ sdkFn: () => this.client.calendar.calendar.list({ params: { page_size: ps, ...(pageToken ? { page_token: pageToken } : {}), ...(syncToken ? { sync_token: syncToken } : {}) } }),
1683
+ label: 'listCalendars',
1684
+ });
1685
+ return {
1686
+ items: res.data.calendar_list || [],
1687
+ pageToken: res.data.page_token,
1688
+ syncToken: res.data.sync_token,
1689
+ hasMore: res.data.has_more,
1690
+ };
1691
+ }
1692
+
1693
+ async listCalendarEvents(calendarId, { startTime, endTime, pageSize = 50, pageToken, syncToken } = {}) {
1694
+ if (!calendarId) throw new Error('listCalendarEvents: calendarId is required');
1695
+ const params = { page_size: String(pageSize) };
1696
+ if (startTime) params.start_time = String(startTime);
1697
+ if (endTime) params.end_time = String(endTime);
1698
+ if (pageToken) params.page_token = pageToken;
1699
+ if (syncToken) params.sync_token = syncToken;
1700
+ const res = await this._asUserOrApp({
1701
+ uatPath: `/open-apis/calendar/v4/calendars/${encodeURIComponent(calendarId)}/events`,
1702
+ query: params,
1703
+ sdkFn: () => this.client.calendar.calendarEvent.list({
1704
+ path: { calendar_id: calendarId },
1705
+ params: {
1706
+ page_size: pageSize,
1707
+ ...(startTime ? { start_time: String(startTime) } : {}),
1708
+ ...(endTime ? { end_time: String(endTime) } : {}),
1709
+ ...(pageToken ? { page_token: pageToken } : {}),
1710
+ ...(syncToken ? { sync_token: syncToken } : {}),
1711
+ },
1712
+ }),
1713
+ label: 'listCalendarEvents',
1714
+ });
1715
+ return {
1716
+ items: res.data.items || [],
1717
+ pageToken: res.data.page_token,
1718
+ syncToken: res.data.sync_token,
1719
+ hasMore: res.data.has_more,
1720
+ };
1721
+ }
1722
+
1723
+ async getCalendarEvent(calendarId, eventId) {
1724
+ if (!calendarId || !eventId) throw new Error('getCalendarEvent: calendarId and eventId are required');
1725
+ const res = await this._asUserOrApp({
1726
+ uatPath: `/open-apis/calendar/v4/calendars/${encodeURIComponent(calendarId)}/events/${encodeURIComponent(eventId)}`,
1727
+ sdkFn: () => this.client.calendar.calendarEvent.get({ path: { calendar_id: calendarId, event_id: eventId } }),
1728
+ label: 'getCalendarEvent',
1729
+ });
1730
+ return { event: res.data.event };
1731
+ }
1058
1732
  }
1059
1733
 
1060
1734
  module.exports = { LarkOfficialClient };