feishu-user-plugin 1.3.5 → 1.3.7

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.
Files changed (56) hide show
  1. package/.claude-plugin/plugin.json +2 -2
  2. package/CHANGELOG.md +22 -0
  3. package/README.md +66 -40
  4. package/package.json +10 -3
  5. package/scripts/check-tool-count.js +15 -0
  6. package/scripts/check-version.js +40 -0
  7. package/scripts/smoke.js +224 -0
  8. package/scripts/sync-claude-md.sh +12 -0
  9. package/scripts/sync-team-skills.sh +22 -0
  10. package/scripts/test-all-tools.js +158 -0
  11. package/skills/feishu-user-plugin/SKILL.md +5 -5
  12. package/skills/feishu-user-plugin/references/CLAUDE.md +152 -96
  13. package/skills/feishu-user-plugin/references/table.md +18 -9
  14. package/src/auth/credentials.js +350 -0
  15. package/src/cli.js +42 -13
  16. package/src/clients/official/base.js +424 -0
  17. package/src/clients/official/bitable.js +269 -0
  18. package/src/clients/official/calendar.js +176 -0
  19. package/src/clients/official/contacts.js +54 -0
  20. package/src/clients/official/docs.js +301 -0
  21. package/src/clients/official/drive.js +77 -0
  22. package/src/clients/official/groups.js +68 -0
  23. package/src/clients/official/im.js +414 -0
  24. package/src/clients/official/index.js +30 -0
  25. package/src/clients/official/okr.js +127 -0
  26. package/src/clients/official/tasks.js +142 -0
  27. package/src/clients/official/uploads.js +260 -0
  28. package/src/clients/official/wiki.js +207 -0
  29. package/src/{client.js → clients/user.js} +23 -17
  30. package/src/doc-blocks.js +20 -5
  31. package/src/index.js +4 -1744
  32. package/src/logger.js +20 -0
  33. package/src/oauth.js +8 -1
  34. package/src/official.js +5 -1734
  35. package/src/prompts/_registry.js +69 -0
  36. package/src/prompts/index.js +54 -0
  37. package/src/server.js +242 -0
  38. package/src/test-all.js +2 -2
  39. package/src/test-comprehensive.js +3 -3
  40. package/src/test-send.js +1 -1
  41. package/src/tools/_registry.js +30 -0
  42. package/src/tools/bitable.js +246 -0
  43. package/src/tools/calendar.js +207 -0
  44. package/src/tools/contacts.js +66 -0
  45. package/src/tools/diagnostics.js +172 -0
  46. package/src/tools/docs.js +158 -0
  47. package/src/tools/drive.js +111 -0
  48. package/src/tools/groups.js +81 -0
  49. package/src/tools/im-read.js +259 -0
  50. package/src/tools/messaging-bot.js +151 -0
  51. package/src/tools/messaging-user.js +292 -0
  52. package/src/tools/okr.js +159 -0
  53. package/src/tools/profile.js +43 -0
  54. package/src/tools/tasks.js +168 -0
  55. package/src/tools/uploads.js +63 -0
  56. package/src/tools/wiki.js +191 -0
@@ -0,0 +1,414 @@
1
+ // src/clients/official/im.js
2
+ // Mixed into LarkOfficialClient.prototype by ./base.js (temporarily during
3
+ // phase A.4–A.11; will move to ./index.js in Task 12). Methods receive `this`
4
+ // bound to the LarkOfficialClient instance, so they can use this.client,
5
+ // this._safeSDKCall, this._asUserOrApp, this._uatREST, this._withUAT,
6
+ // this._getValidUAT, this._getAppToken, this._populateSenderNames,
7
+ // this._formatMessage, this._normalizeTimestamp, this.getUserById, this.hasUAT
8
+ // — all defined in base.js.
9
+
10
+ const { fetchWithTimeout } = require('../../utils');
11
+ const { classifyError } = require('../../error-codes');
12
+
13
+ module.exports = {
14
+ // --- UAT-based IM operations (for P2P chats) ---
15
+
16
+ async listChatsAsUser({ pageSize = 20, pageToken } = {}) {
17
+ const params = new URLSearchParams({ page_size: String(pageSize) });
18
+ if (pageToken) params.set('page_token', pageToken);
19
+ const data = await this._withUAT(async (uat) => {
20
+ const res = await fetchWithTimeout(`https://open.feishu.cn/open-apis/im/v1/chats?${params}`, {
21
+ headers: { 'Authorization': `Bearer ${uat}` },
22
+ });
23
+ return res.json();
24
+ });
25
+ if (data.code !== 0) throw new Error(`listChatsAsUser failed (${data.code}): ${data.msg}`);
26
+ return { items: data.data.items || [], pageToken: data.data.page_token, hasMore: data.data.has_more };
27
+ },
28
+
29
+ async readMessagesAsUser(chatId, { pageSize = 20, startTime, endTime, pageToken, sortType = 'ByCreateTimeDesc', expandMergeForward = true } = {}, userClient) {
30
+ // Feishu API requires end_time >= start_time; auto-set end_time to now if missing
31
+ if (startTime && !endTime) {
32
+ endTime = String(Math.floor(Date.now() / 1000));
33
+ }
34
+ const params = new URLSearchParams({
35
+ container_id_type: 'chat', container_id: chatId, page_size: String(pageSize),
36
+ sort_type: sortType,
37
+ });
38
+ if (startTime) params.set('start_time', startTime);
39
+ if (endTime) params.set('end_time', endTime);
40
+ if (pageToken) params.set('page_token', pageToken);
41
+ const data = await this._withUAT(async (uat) => {
42
+ const res = await fetchWithTimeout(`https://open.feishu.cn/open-apis/im/v1/messages?${params}`, {
43
+ headers: { 'Authorization': `Bearer ${uat}` },
44
+ });
45
+ return res.json();
46
+ });
47
+ if (data.code !== 0) throw new Error(`readMessagesAsUser failed (${data.code}): ${data.msg}`);
48
+ const items = (data.data.items || []).map(m => this._formatMessage(m));
49
+ await this._populateSenderNames(items, userClient);
50
+ if (expandMergeForward) await this._expandMergeForwardItems(items, userClient, { preferUAT: true });
51
+ return { items, hasMore: data.data.has_more, pageToken: data.data.page_token };
52
+ },
53
+
54
+ // --- IM ---
55
+
56
+ async listChats({ pageSize = 20, pageToken } = {}) {
57
+ const res = await this._safeSDKCall(
58
+ () => this.client.im.chat.list({ params: { page_size: pageSize, page_token: pageToken } }),
59
+ 'listChats'
60
+ );
61
+ return { items: res.data.items || [], pageToken: res.data.page_token, hasMore: res.data.has_more };
62
+ },
63
+
64
+ async readMessages(chatId, { pageSize = 20, startTime, endTime, pageToken, sortType = 'ByCreateTimeDesc', expandMergeForward = true } = {}, userClient) {
65
+ const params = { container_id_type: 'chat', container_id: chatId, page_size: pageSize, sort_type: sortType };
66
+ if (startTime) params.start_time = startTime;
67
+ if (endTime) params.end_time = endTime;
68
+ if (pageToken) params.page_token = pageToken;
69
+ const res = await this._safeSDKCall(() => this.client.im.message.list({ params }), 'readMessages');
70
+ const items = (res.data.items || []).map(m => this._formatMessage(m));
71
+ await this._populateSenderNames(items, userClient);
72
+ if (expandMergeForward) await this._expandMergeForwardItems(items, userClient, { preferUAT: false });
73
+ return { items, hasMore: res.data.has_more, pageToken: res.data.page_token };
74
+ },
75
+
76
+ async getMessage(messageId) {
77
+ const res = await this._safeSDKCall(
78
+ () => this.client.im.message.get({ path: { message_id: messageId } }),
79
+ 'getMessage'
80
+ );
81
+ return this._formatMessage(res.data);
82
+ },
83
+
84
+ // Download a resource (image/file) attached to a message.
85
+ // Tries UAT first (works for any chat the user is in), falls back to app token
86
+ // (requires the bot to be in the same chat — Feishu restriction).
87
+ // resourceType: 'image' | 'file'. Returns { base64, mimeType, viaUser }.
88
+ async downloadMessageResource(messageId, fileKey, resourceType = 'image') {
89
+ const path = `/open-apis/im/v1/messages/${encodeURIComponent(messageId)}/resources/${encodeURIComponent(fileKey)}?type=${encodeURIComponent(resourceType)}`;
90
+ const url = 'https://open.feishu.cn' + path;
91
+
92
+ // Attempt 1: user identity
93
+ if (this.hasUAT) {
94
+ try {
95
+ const uat = await this._getValidUAT();
96
+ const res = await fetchWithTimeout(url, {
97
+ headers: { 'Authorization': `Bearer ${uat}` },
98
+ timeoutMs: 60000,
99
+ });
100
+ if (res.ok && !res.headers.get('content-type')?.includes('application/json')) {
101
+ const buf = Buffer.from(await res.arrayBuffer());
102
+ return {
103
+ base64: buf.toString('base64'),
104
+ mimeType: res.headers.get('content-type') || 'application/octet-stream',
105
+ bytes: buf.length,
106
+ viaUser: true,
107
+ };
108
+ }
109
+ const errJson = await res.json().catch(() => null);
110
+ console.error(`[feishu-user-plugin] downloadMessageResource as user failed: ${errJson?.code}: ${errJson?.msg || res.statusText}, retrying as app`);
111
+ } catch (e) {
112
+ console.error(`[feishu-user-plugin] downloadMessageResource as user threw (${e.message}), retrying as app`);
113
+ }
114
+ }
115
+
116
+ // Attempt 2: app identity
117
+ const token = await this._getAppToken();
118
+ const res = await fetchWithTimeout(url, {
119
+ headers: { 'Authorization': `Bearer ${token}` },
120
+ timeoutMs: 60000,
121
+ });
122
+ if (!res.ok || res.headers.get('content-type')?.includes('application/json')) {
123
+ const errJson = await res.json().catch(() => null);
124
+ throw new Error(`downloadMessageResource failed: ${errJson?.code}: ${errJson?.msg || res.statusText}. Note: app identity requires the bot to be in the same chat.`);
125
+ }
126
+ const buf = Buffer.from(await res.arrayBuffer());
127
+ return {
128
+ base64: buf.toString('base64'),
129
+ mimeType: res.headers.get('content-type') || 'application/octet-stream',
130
+ bytes: buf.length,
131
+ viaUser: false,
132
+ };
133
+ },
134
+
135
+ async replyMessage(messageId, text, msgType = 'text') {
136
+ const content = msgType === 'text' ? JSON.stringify({ text }) : text;
137
+ const res = await this._safeSDKCall(
138
+ () => this.client.im.message.reply({ path: { message_id: messageId }, data: { content, msg_type: msgType } }),
139
+ 'replyMessage'
140
+ );
141
+ return { messageId: res.data.message_id };
142
+ },
143
+
144
+ async forwardMessage(messageId, receiverId, receiveIdType = 'chat_id') {
145
+ const res = await this._safeSDKCall(
146
+ () => this.client.im.message.forward({
147
+ path: { message_id: messageId },
148
+ data: { receive_id: receiverId },
149
+ params: { receive_id_type: receiveIdType },
150
+ }),
151
+ 'forwardMessage'
152
+ );
153
+ return { messageId: res.data.message_id };
154
+ },
155
+
156
+ // --- IM: Send (Bot Identity) ---
157
+
158
+ async sendMessageAsBot(chatId, msgType, content, receiveIdType = 'chat_id') {
159
+ const res = await this._safeSDKCall(
160
+ () => this.client.im.message.create({
161
+ params: { receive_id_type: receiveIdType },
162
+ data: { receive_id: chatId, msg_type: msgType, content: typeof content === 'string' ? content : JSON.stringify(content) },
163
+ }),
164
+ 'sendMessage'
165
+ );
166
+ return { messageId: res.data.message_id };
167
+ },
168
+
169
+ async deleteMessage(messageId) {
170
+ await this._safeSDKCall(
171
+ () => this.client.im.message.delete({ path: { message_id: messageId } }),
172
+ 'deleteMessage'
173
+ );
174
+ return { deleted: true };
175
+ },
176
+
177
+ async updateMessage(messageId, msgType, content) {
178
+ const res = await this._safeSDKCall(
179
+ () => this.client.im.message.patch({
180
+ path: { message_id: messageId },
181
+ data: { msg_type: msgType, content: typeof content === 'string' ? content : JSON.stringify(content) },
182
+ }),
183
+ 'updateMessage'
184
+ );
185
+ return { messageId: res.data?.message_id || messageId };
186
+ },
187
+
188
+ // --- IM: Reactions ---
189
+
190
+ async addReaction(messageId, emojiType) {
191
+ const res = await this._safeSDKCall(
192
+ () => this.client.im.messageReaction.create({
193
+ path: { message_id: messageId },
194
+ data: { reaction_type: { emoji_type: emojiType } },
195
+ }),
196
+ 'addReaction'
197
+ );
198
+ return { reactionId: res.data.reaction_id };
199
+ },
200
+
201
+ async deleteReaction(messageId, reactionId) {
202
+ await this._safeSDKCall(
203
+ () => this.client.im.messageReaction.delete({
204
+ path: { message_id: messageId, reaction_id: reactionId },
205
+ }),
206
+ 'deleteReaction'
207
+ );
208
+ return { deleted: true };
209
+ },
210
+
211
+ // --- IM: Pins ---
212
+
213
+ async pinMessage(messageId, pinned = true) {
214
+ if (pinned) {
215
+ const res = await this._safeSDKCall(
216
+ () => this.client.im.pin.create({ data: { message_id: messageId } }),
217
+ 'pinMessage'
218
+ );
219
+ return { pin: res.data.pin };
220
+ }
221
+ // Feishu unpin is DELETE /pins/{message_id} — path param only, no body.
222
+ // SDK's pin.delete expects `path: {message_id}`. Sending `data: {message_id}`
223
+ // (the previous shape) yielded a 400 with "message_id is required" because
224
+ // the message_id never made it onto the URL.
225
+ await this._safeSDKCall(
226
+ () => this.client.im.pin.delete({ path: { message_id: messageId } }),
227
+ 'unpinMessage'
228
+ );
229
+ return { unpinned: true };
230
+ },
231
+
232
+ // --- Chat Info (Official API) ---
233
+
234
+ async getChatInfo(chatId) {
235
+ const res = await this._safeSDKCall(
236
+ () => this.client.im.chat.get({ path: { chat_id: chatId } }),
237
+ 'getChatInfo'
238
+ );
239
+ return res.data;
240
+ },
241
+
242
+
243
+ // --- Chat ID Resolution ---
244
+
245
+ async listAllChats() {
246
+ const allChats = [];
247
+ let pageToken;
248
+ let hasMore = true;
249
+ while (hasMore) {
250
+ const res = await this._safeSDKCall(
251
+ () => this.client.im.chat.list({ params: { page_size: 100, page_token: pageToken } }),
252
+ 'listAllChats'
253
+ );
254
+ allChats.push(...(res.data.items || []));
255
+ pageToken = res.data.page_token;
256
+ hasMore = res.data.has_more && !!pageToken;
257
+ }
258
+ return allChats;
259
+ },
260
+
261
+ // --- Chat Search (keyword-based, works even if bot isn't in the group's list) ---
262
+
263
+ async chatSearch(query) {
264
+ const res = await this._safeSDKCall(
265
+ () => this.client.im.chat.search({ params: { query, page_size: 20 } }),
266
+ 'chatSearch'
267
+ );
268
+ return res.data.items || [];
269
+ },
270
+
271
+ // Fetch the child messages inside a merge_forward parent. Feishu exposes them
272
+ // via `/im/v1/messages/{parent_id}` (single-message GET). The response is
273
+ // actually a list: items[0] is the parent merge_forward placeholder,
274
+ // items[1..N] are the children carrying `upper_message_id` pointing back to
275
+ // the parent and `chat_id` pointing to their ORIGIN chat (the one being
276
+ // forwarded from, not where the merge_forward was posted).
277
+ //
278
+ // Media resources (image_key / file_key) on children must be downloaded
279
+ // using the PARENT message id — a Feishu quirk: downloading with the child
280
+ // id returns "File not in msg".
281
+ async readMergeForwardChildren(parentMessageId, userClient, { preferUAT = true } = {}) {
282
+ const url = `https://open.feishu.cn/open-apis/im/v1/messages/${encodeURIComponent(parentMessageId)}`;
283
+
284
+ const tryPath = async (bearer) => {
285
+ const res = await fetchWithTimeout(url, {
286
+ headers: { 'Authorization': `Bearer ${bearer}` },
287
+ timeoutMs: 30000,
288
+ });
289
+ return res.json();
290
+ };
291
+
292
+ let data = null;
293
+ const order = preferUAT ? ['uat', 'bot'] : ['bot', 'uat'];
294
+ const errors = [];
295
+ for (const identity of order) {
296
+ try {
297
+ if (identity === 'uat') {
298
+ if (!this.hasUAT) { errors.push('uat: not configured'); continue; }
299
+ const uat = await this._getValidUAT();
300
+ const resp = await tryPath(uat);
301
+ if (resp.code === 0) { data = resp; break; }
302
+ errors.push(`uat: code=${resp.code} msg=${resp.msg}`);
303
+ } else {
304
+ const tat = await this._getAppToken();
305
+ const resp = await tryPath(tat);
306
+ if (resp.code === 0) { data = resp; break; }
307
+ errors.push(`bot: code=${resp.code} msg=${resp.msg}`);
308
+ }
309
+ } catch (e) {
310
+ errors.push(`${identity}: ${e.message}`);
311
+ }
312
+ }
313
+ if (!data) {
314
+ throw new Error(`readMergeForwardChildren failed: ${errors.join(' | ')}`);
315
+ }
316
+
317
+ // items[0] is the parent itself — filter it out. The rest are children.
318
+ const rawChildren = (data.data?.items || []).filter(m =>
319
+ m.message_id !== parentMessageId && m.upper_message_id);
320
+
321
+ const children = rawChildren.map(raw => {
322
+ const f = this._formatMessage(raw);
323
+ // Surface the parent id on the child so downstream tools (download_image /
324
+ // download_file) know which id to pass to Feishu's resource endpoint.
325
+ f.parentMessageId = parentMessageId;
326
+ // Mark the origin chat explicitly — child.chatId is the ORIGINAL chat the
327
+ // message came from, not the chat where the merge_forward was posted.
328
+ f.originChatId = raw.chat_id;
329
+ return f;
330
+ });
331
+ await this._populateSenderNames(children, userClient);
332
+ return children;
333
+ },
334
+
335
+ // Expand merge_forward placeholders in-place. Adds `children: [...]` or
336
+ // `expandError` on each merge_forward item. `depth` guards against nesting
337
+ // (Feishu does allow nested merge_forward, but we cap at 1 level to avoid
338
+ // exponential fan-out in agent contexts).
339
+ async _expandMergeForwardItems(items, userClient, { preferUAT = true, depth = 0, maxDepth = 1 } = {}) {
340
+ if (!items || depth >= maxDepth) return;
341
+ for (const m of items) {
342
+ if (m.msgType !== 'merge_forward') continue;
343
+ try {
344
+ const children = await this.readMergeForwardChildren(m.messageId, userClient, { preferUAT });
345
+ m.children = children;
346
+ // One extra level deep if user really wants, via recursive call.
347
+ if (depth + 1 < maxDepth) {
348
+ await this._expandMergeForwardItems(children, userClient, { preferUAT, depth: depth + 1, maxDepth });
349
+ }
350
+ } catch (e) {
351
+ m.expandError = e.message;
352
+ }
353
+ }
354
+ },
355
+
356
+ // --- Hardened Message Read (v1.3.4) ---
357
+
358
+ // Reads messages with explicit fallback routing: tries the bot path first,
359
+ // classifies any failure via error-codes.js, and escalates to UAT when
360
+ // appropriate. Returns the same shape as readMessages/readMessagesAsUser
361
+ // plus `via` ('bot' | 'user' | 'contacts') and, if fallback fired,
362
+ // `via_reason` (a short enum from classifyError).
363
+ //
364
+ // If `skipBot` is true, the bot path is never attempted (callers use this
365
+ // when the chat_id came from search_contacts — i.e. definitely external).
366
+ //
367
+ // Throws a single, wrapped error if BOTH paths fail or if UAT is absent and
368
+ // the bot failed; the message points the user at `npx feishu-user-plugin oauth`.
369
+ async readMessagesWithFallback(chatId, options, userClient, { skipBot = false, via = 'bot' } = {}) {
370
+ const tryUAT = async (viaLabel, reason) => {
371
+ if (!this.hasUAT) {
372
+ const hint = 'To read external / private groups, configure UAT via: npx feishu-user-plugin oauth';
373
+ const err = new Error(`Cannot read chat ${chatId} as bot (${reason || 'bot failed and no UAT configured'}). ${hint}`);
374
+ err.viaReason = reason;
375
+ throw err;
376
+ }
377
+ const data = await this.readMessagesAsUser(chatId, options, userClient);
378
+ data.via = viaLabel;
379
+ if (reason) data.via_reason = reason;
380
+ return data;
381
+ };
382
+
383
+ if (skipBot) {
384
+ return tryUAT(via || 'contacts', 'contacts_resolved_external');
385
+ }
386
+
387
+ // Attempt 1 — bot identity.
388
+ try {
389
+ const data = await this.readMessages(chatId, options, userClient);
390
+ data.via = 'bot';
391
+ return data;
392
+ } catch (botErr) {
393
+ const klass = classifyError(botErr);
394
+ console.error(`[feishu-user-plugin] read_messages bot failed for ${chatId}: ${botErr.message} [class=${klass.action}, reason=${klass.reason}, code=${klass.code}]`);
395
+
396
+ if (klass.action === 'retry') {
397
+ // One retry after short backoff before hopping to UAT.
398
+ await new Promise(r => setTimeout(r, 2000));
399
+ try {
400
+ const data = await this.readMessages(chatId, options, userClient);
401
+ data.via = 'bot';
402
+ data.via_reason = klass.reason + '_recovered';
403
+ return data;
404
+ } catch (retryErr) {
405
+ console.error(`[feishu-user-plugin] read_messages bot retry failed for ${chatId}: ${retryErr.message}`);
406
+ }
407
+ }
408
+
409
+ // Fall through to UAT — if UAT is missing, tryUAT throws the user-friendly
410
+ // "run npx feishu-user-plugin oauth" error instead of the raw Feishu payload.
411
+ return tryUAT('user', klass.reason);
412
+ }
413
+ },
414
+ };
@@ -0,0 +1,30 @@
1
+ // Composer for LarkOfficialClient. base.js owns the cross-cutting infrastructure
2
+ // (constructor, UAT management, _safeSDKCall, _asUserOrApp, _uatREST, sender
3
+ // name resolution, helpers); each domain file owns its own methods. We splice
4
+ // them onto the prototype here so callers get a single class with everything.
5
+ //
6
+ // Adding a new domain: create clients/official/<x>.js exporting an object of
7
+ // async methods, then push it onto DOMAINS below. Domain order is purely
8
+ // cosmetic — Object.assign onto the prototype happens before any constructor
9
+ // runs, so cross-domain `this.*` calls work regardless of mixin order.
10
+ const { LarkOfficialClient } = require('./base');
11
+
12
+ const DOMAINS = [
13
+ require('./contacts'),
14
+ require('./calendar'),
15
+ require('./tasks'),
16
+ require('./groups'),
17
+ require('./okr'),
18
+ require('./wiki'),
19
+ require('./drive'),
20
+ require('./uploads'),
21
+ require('./docs'),
22
+ require('./bitable'),
23
+ require('./im'),
24
+ ];
25
+
26
+ for (const domain of DOMAINS) {
27
+ Object.assign(LarkOfficialClient.prototype, domain);
28
+ }
29
+
30
+ module.exports = { LarkOfficialClient };
@@ -0,0 +1,127 @@
1
+ // src/clients/official/okr.js
2
+ // Mixed into LarkOfficialClient.prototype by ./index.js. UAT-first throughout
3
+ // — OKR resources belong to the calling user.
4
+
5
+ module.exports = {
6
+ // --- OKR read (v1.3.4) ---
7
+
8
+ async listUserOkrs(userId, { periodIds, offset = 0, limit = 10, lang, userIdType = 'open_id' } = {}) {
9
+ 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.');
10
+ const params = { user_id_type: userIdType, offset: String(offset), limit: String(limit) };
11
+ if (lang) params.lang = lang;
12
+ if (periodIds && periodIds.length) params.period_ids = periodIds;
13
+ const res = await this._asUserOrApp({
14
+ uatPath: `/open-apis/okr/v1/users/${encodeURIComponent(userId)}/okrs`,
15
+ query: params,
16
+ sdkFn: () => this.client.okr.userOkr.list({
17
+ path: { user_id: userId },
18
+ params: {
19
+ user_id_type: userIdType,
20
+ offset: String(offset),
21
+ limit: String(limit),
22
+ ...(lang ? { lang } : {}),
23
+ ...(periodIds && periodIds.length ? { period_ids: periodIds } : {}),
24
+ },
25
+ }),
26
+ label: 'listUserOkrs',
27
+ });
28
+ return { total: res.data.total, items: res.data.okr_list || [] };
29
+ },
30
+
31
+ async getOkrs(okrIds, { lang, userIdType = 'open_id' } = {}) {
32
+ if (!Array.isArray(okrIds) || okrIds.length === 0) {
33
+ throw new Error('getOkrs: okrIds must be a non-empty array');
34
+ }
35
+ const params = { user_id_type: userIdType, okr_ids: okrIds };
36
+ if (lang) params.lang = lang;
37
+ // UAT REST path takes repeated okr_ids= params; URLSearchParams will serialize an array properly
38
+ const res = await this._asUserOrApp({
39
+ uatPath: `/open-apis/okr/v1/okrs/batch_get`,
40
+ query: params,
41
+ sdkFn: () => this.client.okr.okr.batchGet({ params }),
42
+ label: 'getOkrs',
43
+ });
44
+ return { items: res.data.okr_list || [] };
45
+ },
46
+
47
+ async listOkrPeriods({ pageSize = 10, pageToken } = {}) {
48
+ const params = { page_size: String(pageSize) };
49
+ if (pageToken) params.page_token = pageToken;
50
+ const res = await this._asUserOrApp({
51
+ uatPath: `/open-apis/okr/v1/periods`,
52
+ query: params,
53
+ sdkFn: () => this.client.okr.period.list({ params: { page_size: pageSize, ...(pageToken ? { page_token: pageToken } : {}) } }),
54
+ label: 'listOkrPeriods',
55
+ });
56
+ return { items: res.data.items || [], pageToken: res.data.page_token, hasMore: res.data.has_more };
57
+ },
58
+
59
+ // --- OKR progress record write (v1.3.7) ---
60
+ // Requires `okr:okr.content:write` (or wider okr:okr) on the OAuth.
61
+
62
+ async createOkrProgressRecord({ targetId, targetType, content, sourceTitle, sourceUrl, sourceUrlPc, sourceUrlMobile, progressRate, userIdType = 'open_id' }) {
63
+ if (!targetId) throw new Error('createOkrProgressRecord: target_id is required (the key_result_id or objective_id)');
64
+ if (!targetType) throw new Error('createOkrProgressRecord: target_type is required (1=objective, 2=key_result)');
65
+ if (!content || typeof content !== 'object') {
66
+ throw new Error('createOkrProgressRecord: content (block-structured object) is required. Use buildOkrContent(text) helper for a simple paragraph.');
67
+ }
68
+ const data = {
69
+ source_title: sourceTitle || 'Progress update',
70
+ source_url: sourceUrl || 'https://feishu.cn/',
71
+ target_id: targetId,
72
+ target_type: targetType,
73
+ content,
74
+ };
75
+ if (sourceUrlPc) data.source_url_pc = sourceUrlPc;
76
+ if (sourceUrlMobile) data.source_url_mobile = sourceUrlMobile;
77
+ if (progressRate) data.progress_rate = progressRate;
78
+ const params = { user_id_type: userIdType };
79
+ const res = await this._asUserOrApp({
80
+ uatPath: `/open-apis/okr/v1/progress_records`,
81
+ method: 'POST',
82
+ body: data,
83
+ query: params,
84
+ sdkFn: () => this.client.okr.progressRecord.create({ data, params }),
85
+ label: 'createOkrProgressRecord',
86
+ });
87
+ const out = { progressId: res.data.progress_id, modifyTime: res.data.modify_time, content: res.data.content, progressRate: res.data.progress_rate, viaUser: !!res._viaUser };
88
+ if (res._fallbackWarning) out.fallbackWarning = res._fallbackWarning;
89
+ return out;
90
+ },
91
+
92
+ // Feishu OKR API has no native "list progress records" — the records live
93
+ // under each key_result in the OKR object. This helper extracts the IDs by
94
+ // walking getOkrs(okrId) and unwinding the progress_record_list per
95
+ // objective + key_result.
96
+ async listOkrProgressRecords(okrId, { lang, userIdType = 'open_id' } = {}) {
97
+ if (!okrId) throw new Error('listOkrProgressRecords: okr_id is required');
98
+ const { items } = await this.getOkrs([okrId], { lang, userIdType });
99
+ if (!items || items.length === 0) {
100
+ return { okrId, records: [] };
101
+ }
102
+ const okr = items[0];
103
+ const records = [];
104
+ for (const obj of okr.objective_list || []) {
105
+ for (const r of obj.progress_record_list || []) {
106
+ records.push({ progress_id: r.id, target_type: 1, target_id: obj.id });
107
+ }
108
+ for (const kr of obj.kr_list || []) {
109
+ for (const r of kr.progress_record_list || []) {
110
+ records.push({ progress_id: r.id, target_type: 2, target_id: kr.id });
111
+ }
112
+ }
113
+ }
114
+ return { okrId, records };
115
+ },
116
+
117
+ async deleteOkrProgressRecord(progressId) {
118
+ if (!progressId) throw new Error('deleteOkrProgressRecord: progress_id is required');
119
+ const res = await this._asUserOrApp({
120
+ uatPath: `/open-apis/okr/v1/progress_records/${encodeURIComponent(progressId)}`,
121
+ method: 'DELETE',
122
+ sdkFn: () => this.client.okr.progressRecord.delete({ path: { progress_id: progressId } }),
123
+ label: 'deleteOkrProgressRecord',
124
+ });
125
+ return { deleted: true, viaUser: !!res._viaUser };
126
+ },
127
+ };