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,176 @@
1
+ // src/clients/official/calendar.js
2
+ // Mixed into LarkOfficialClient.prototype by ./index.js. UAT-first for all
3
+ // methods (calendar resources are user-owned by default).
4
+
5
+ module.exports = {
6
+ // --- Calendar read (v1.3.4) ---
7
+
8
+ async listCalendars({ pageSize = 50, pageToken, syncToken } = {}) {
9
+ // Feishu's calendar/v4/calendars endpoint rejects page_size < 50 with
10
+ // `99992402 field validation failed` ("the min value is 50"). The docs don't
11
+ // flag this — smoke-tested against the real API. Clamp to be safe.
12
+ const ps = Math.max(50, Number(pageSize) || 50);
13
+ const params = { page_size: String(ps) };
14
+ if (pageToken) params.page_token = pageToken;
15
+ if (syncToken) params.sync_token = syncToken;
16
+ const res = await this._asUserOrApp({
17
+ uatPath: `/open-apis/calendar/v4/calendars`,
18
+ query: params,
19
+ sdkFn: () => this.client.calendar.calendar.list({ params: { page_size: ps, ...(pageToken ? { page_token: pageToken } : {}), ...(syncToken ? { sync_token: syncToken } : {}) } }),
20
+ label: 'listCalendars',
21
+ });
22
+ return {
23
+ items: res.data.calendar_list || [],
24
+ pageToken: res.data.page_token,
25
+ syncToken: res.data.sync_token,
26
+ hasMore: res.data.has_more,
27
+ };
28
+ },
29
+
30
+ async listCalendarEvents(calendarId, { startTime, endTime, pageSize = 50, pageToken, syncToken } = {}) {
31
+ if (!calendarId) throw new Error('listCalendarEvents: calendarId is required');
32
+ const params = { page_size: String(pageSize) };
33
+ if (startTime) params.start_time = String(startTime);
34
+ if (endTime) params.end_time = String(endTime);
35
+ if (pageToken) params.page_token = pageToken;
36
+ if (syncToken) params.sync_token = syncToken;
37
+ const res = await this._asUserOrApp({
38
+ uatPath: `/open-apis/calendar/v4/calendars/${encodeURIComponent(calendarId)}/events`,
39
+ query: params,
40
+ sdkFn: () => this.client.calendar.calendarEvent.list({
41
+ path: { calendar_id: calendarId },
42
+ params: {
43
+ page_size: pageSize,
44
+ ...(startTime ? { start_time: String(startTime) } : {}),
45
+ ...(endTime ? { end_time: String(endTime) } : {}),
46
+ ...(pageToken ? { page_token: pageToken } : {}),
47
+ ...(syncToken ? { sync_token: syncToken } : {}),
48
+ },
49
+ }),
50
+ label: 'listCalendarEvents',
51
+ });
52
+ return {
53
+ items: res.data.items || [],
54
+ pageToken: res.data.page_token,
55
+ syncToken: res.data.sync_token,
56
+ hasMore: res.data.has_more,
57
+ };
58
+ },
59
+
60
+ async getCalendarEvent(calendarId, eventId) {
61
+ if (!calendarId || !eventId) throw new Error('getCalendarEvent: calendarId and eventId are required');
62
+ const res = await this._asUserOrApp({
63
+ uatPath: `/open-apis/calendar/v4/calendars/${encodeURIComponent(calendarId)}/events/${encodeURIComponent(eventId)}`,
64
+ sdkFn: () => this.client.calendar.calendarEvent.get({ path: { calendar_id: calendarId, event_id: eventId } }),
65
+ label: 'getCalendarEvent',
66
+ });
67
+ return { event: res.data.event };
68
+ },
69
+
70
+ // --- Calendar write (v1.3.7) ---
71
+ // Requires `calendar:calendar.event:write` scope on app + UAT.
72
+
73
+ async createCalendarEvent(calendarId, eventData) {
74
+ if (!calendarId) throw new Error('createCalendarEvent: calendarId is required');
75
+ if (!eventData?.start_time || !eventData?.end_time) {
76
+ throw new Error('createCalendarEvent: start_time and end_time are required (each: {timestamp: "<unix-seconds>", timezone?: "Asia/Shanghai"} or {date: "YYYY-MM-DD"})');
77
+ }
78
+ const res = await this._asUserOrApp({
79
+ uatPath: `/open-apis/calendar/v4/calendars/${encodeURIComponent(calendarId)}/events`,
80
+ method: 'POST',
81
+ body: eventData,
82
+ sdkFn: () => this.client.calendar.calendarEvent.create({
83
+ path: { calendar_id: calendarId },
84
+ data: eventData,
85
+ }),
86
+ label: 'createCalendarEvent',
87
+ });
88
+ const out = { event: res.data.event, viaUser: !!res._viaUser };
89
+ if (res._fallbackWarning) out.fallbackWarning = res._fallbackWarning;
90
+ return out;
91
+ },
92
+
93
+ async updateCalendarEvent(calendarId, eventId, updates) {
94
+ if (!calendarId || !eventId) throw new Error('updateCalendarEvent: calendarId and eventId are required');
95
+ if (!updates || typeof updates !== 'object' || Object.keys(updates).length === 0) {
96
+ throw new Error('updateCalendarEvent: updates object is required (e.g. {summary, description, start_time, end_time, attendee_ability, ...})');
97
+ }
98
+ const res = await this._asUserOrApp({
99
+ uatPath: `/open-apis/calendar/v4/calendars/${encodeURIComponent(calendarId)}/events/${encodeURIComponent(eventId)}`,
100
+ method: 'PATCH',
101
+ body: updates,
102
+ sdkFn: () => this.client.calendar.calendarEvent.patch({
103
+ path: { calendar_id: calendarId, event_id: eventId },
104
+ data: updates,
105
+ }),
106
+ label: 'updateCalendarEvent',
107
+ });
108
+ const out = { event: res.data.event, viaUser: !!res._viaUser };
109
+ if (res._fallbackWarning) out.fallbackWarning = res._fallbackWarning;
110
+ return out;
111
+ },
112
+
113
+ async deleteCalendarEvent(calendarId, eventId, { needNotification, meetingChatId } = {}) {
114
+ if (!calendarId || !eventId) throw new Error('deleteCalendarEvent: calendarId and eventId are required');
115
+ const query = {};
116
+ if (needNotification !== undefined) query.need_notification = String(needNotification);
117
+ if (meetingChatId) query.meeting_chat_id = meetingChatId;
118
+ const res = await this._asUserOrApp({
119
+ uatPath: `/open-apis/calendar/v4/calendars/${encodeURIComponent(calendarId)}/events/${encodeURIComponent(eventId)}`,
120
+ method: 'DELETE',
121
+ query,
122
+ sdkFn: () => this.client.calendar.calendarEvent.delete({
123
+ path: { calendar_id: calendarId, event_id: eventId },
124
+ params: meetingChatId ? { meeting_chat_id: meetingChatId } : {},
125
+ }),
126
+ label: 'deleteCalendarEvent',
127
+ });
128
+ const out = { deleted: true, viaUser: !!res._viaUser };
129
+ if (res._fallbackWarning) out.fallbackWarning = res._fallbackWarning;
130
+ return out;
131
+ },
132
+
133
+ async respondCalendarEvent(calendarId, eventId, rsvpStatus) {
134
+ if (!calendarId || !eventId) throw new Error('respondCalendarEvent: calendarId and eventId are required');
135
+ if (!['accept', 'decline', 'tentative'].includes(rsvpStatus)) {
136
+ throw new Error('respondCalendarEvent: rsvp_status must be one of accept|decline|tentative');
137
+ }
138
+ const body = { rsvp_status: rsvpStatus };
139
+ const res = await this._asUserOrApp({
140
+ uatPath: `/open-apis/calendar/v4/calendars/${encodeURIComponent(calendarId)}/events/${encodeURIComponent(eventId)}/reply`,
141
+ method: 'PATCH',
142
+ body,
143
+ sdkFn: () => this.client.calendar.calendarEvent.reply({
144
+ path: { calendar_id: calendarId, event_id: eventId },
145
+ data: body,
146
+ }),
147
+ label: 'respondCalendarEvent',
148
+ });
149
+ const out = { rsvp: rsvpStatus, viaUser: !!res._viaUser };
150
+ if (res._fallbackWarning) out.fallbackWarning = res._fallbackWarning;
151
+ return out;
152
+ },
153
+
154
+ async getFreebusy({ timeMin, timeMax, userIds = [], roomIds = [], includeExternalCalendar, onlyBusy } = {}) {
155
+ if (!timeMin || !timeMax) throw new Error('getFreebusy: time_min and time_max (RFC3339 strings) are required');
156
+ if (!Array.isArray(userIds) || userIds.length === 0) {
157
+ throw new Error('getFreebusy: user_ids array is required (use list_profiles / get_login_status to get your own open_id)');
158
+ }
159
+ const body = {
160
+ time_min: timeMin,
161
+ time_max: timeMax,
162
+ user_ids: userIds,
163
+ };
164
+ if (roomIds && roomIds.length) body.room_ids = roomIds;
165
+ if (includeExternalCalendar !== undefined) body.include_external_calendar = !!includeExternalCalendar;
166
+ if (onlyBusy !== undefined) body.only_busy = !!onlyBusy;
167
+ const res = await this._asUserOrApp({
168
+ uatPath: `/open-apis/calendar/v4/freebusy/batch_get`,
169
+ method: 'POST',
170
+ body,
171
+ sdkFn: () => this.client.calendar.freebusy.batch({ data: body }),
172
+ label: 'getFreebusy',
173
+ });
174
+ return { freebusyLists: res.data.freebusy_lists || [], viaUser: !!res._viaUser };
175
+ },
176
+ };
@@ -0,0 +1,54 @@
1
+ // src/clients/official/contacts.js
2
+ // Mixed into LarkOfficialClient.prototype by ./index.js. Methods receive `this`
3
+ // bound to the LarkOfficialClient instance, so they can use this.client,
4
+ // this._safeSDKCall, this._asUserOrApp, this._uatREST, etc.
5
+
6
+ module.exports = {
7
+ // --- User Name Resolution ---
8
+ //
9
+ // UAT-first (v1.3.7 C1.15 fix) so that calling get_user_info with the
10
+ // current user's own open_id resolves correctly. The bot path goes via the
11
+ // app-level contact API which can read tenant employees but does not have a
12
+ // notion of "the calling user" — so when the bot couldn't see a user (because
13
+ // contact scope wasn't granted, or the user happened to be the bot's owner)
14
+ // the previous code fell into a `null` and we surfaced "may be from external
15
+ // tenant", which was misleading. UAT can always see the current user.
16
+ async getUserById(userId, userIdType = 'open_id') {
17
+ if (this._userNameCache.has(userId)) return this._userNameCache.get(userId);
18
+
19
+ // 1. UAT path — works for the current user (self) and any colleague the
20
+ // UAT owner has access to.
21
+ if (this.hasUAT) {
22
+ try {
23
+ const data = await this._uatREST(
24
+ 'GET',
25
+ `/open-apis/contact/v3/users/${encodeURIComponent(userId)}`,
26
+ { query: { user_id_type: userIdType } },
27
+ );
28
+ if (data && data.code === 0 && data.data?.user?.name) {
29
+ this._userNameCache.set(userId, data.data.user.name);
30
+ return data.data.user.name;
31
+ }
32
+ } catch (e) {
33
+ console.error(`[feishu-user-plugin] getUserById(${userId}) as user failed: ${e.message}`);
34
+ }
35
+ }
36
+
37
+ // 2. Bot fallback — needs `contact:user.base:readonly` scope on the app.
38
+ try {
39
+ const res = await this.client.contact.user.get({
40
+ path: { user_id: userId },
41
+ params: { user_id_type: userIdType },
42
+ });
43
+ if (res.code === 0 && res.data?.user?.name) {
44
+ this._userNameCache.set(userId, res.data.user.name);
45
+ return res.data.user.name;
46
+ }
47
+ } catch (e) {
48
+ // Surface to the diagnostics log so users can see whether the failure
49
+ // was a missing contact scope vs an actual external user.
50
+ console.error(`[feishu-user-plugin] getUserById(${userId}) as bot failed: ${e.message}`);
51
+ }
52
+ return null;
53
+ },
54
+ };
@@ -0,0 +1,301 @@
1
+ // src/clients/official/docs.js
2
+ // Mixed into LarkOfficialClient.prototype by ./index.js (or temporarily by
3
+ // ./base.js during phase A.4–A.11). Methods receive `this` bound to the
4
+ // LarkOfficialClient instance, so they can use this.client, this._safeSDKCall,
5
+ // this._asUserOrApp, this._uatREST, this.uploadMedia, etc. — all defined in
6
+ // base.js or mixed in via other domain modules.
7
+
8
+ const { buildEmptyImageBlock, buildReplaceImagePayload, buildEmptyFileBlock, buildReplaceFilePayload } = require('../../doc-blocks');
9
+
10
+ module.exports = {
11
+ // --- Docs ---
12
+
13
+ async searchDocs(query, { pageSize = 10, pageToken } = {}) {
14
+ const res = await this._safeSDKCall(
15
+ () => this.client.request({
16
+ method: 'POST', url: '/open-apis/suite/docs-api/search/object',
17
+ data: { search_key: query, count: pageSize, offset: pageToken ? parseInt(pageToken) : 0, owner_ids: [], chat_ids: [], docs_types: [] },
18
+ }),
19
+ 'searchDocs'
20
+ );
21
+ return { items: res.data.docs_entities || [], hasMore: res.data.has_more };
22
+ },
23
+
24
+ async readDoc(documentId) {
25
+ const res = await this._asUserOrApp({
26
+ uatPath: `/open-apis/docx/v1/documents/${documentId}/raw_content`,
27
+ query: { lang: '0' },
28
+ sdkFn: () => this.client.docx.document.rawContent({ path: { document_id: documentId }, params: { lang: 0 } }),
29
+ label: 'readDoc',
30
+ });
31
+ return { content: res.data.content };
32
+ },
33
+
34
+ async createDoc(title, folderId, { wikiSpaceId, wikiParentNodeToken } = {}) {
35
+ const res = await this._asUserOrApp({
36
+ uatPath: `/open-apis/docx/v1/documents`,
37
+ method: 'POST',
38
+ body: { title, folder_token: folderId || '' },
39
+ sdkFn: () => this.client.docx.document.create({ data: { title, folder_token: folderId || '' } }),
40
+ label: 'createDoc',
41
+ });
42
+ const documentId = res.data.document?.document_id;
43
+ const out = { documentId, viaUser: !!res._viaUser, fallbackWarning: res._fallbackWarning || null };
44
+ if (documentId && wikiSpaceId) {
45
+ try {
46
+ const node = await this.attachToWiki(wikiSpaceId, 'docx', documentId, wikiParentNodeToken);
47
+ if (node?.node_token) out.wikiNodeToken = node.node_token;
48
+ else if (node?.task_id) out.wikiAttachTaskId = node.task_id;
49
+ } catch (e) {
50
+ out.wikiAttachError = e.message;
51
+ }
52
+ }
53
+ return out;
54
+ },
55
+
56
+ async getDocBlocks(documentId) {
57
+ const res = await this._asUserOrApp({
58
+ uatPath: `/open-apis/docx/v1/documents/${documentId}/blocks`,
59
+ query: { page_size: '500' },
60
+ sdkFn: () => this.client.docx.documentBlock.list({ path: { document_id: documentId }, params: { page_size: 500 } }),
61
+ label: 'getDocBlocks',
62
+ });
63
+ return { items: res.data.items || [] };
64
+ },
65
+
66
+ async createDocBlock(documentId, parentBlockId, children, index) {
67
+ const data = { children };
68
+ if (index !== undefined) data.index = index;
69
+ const res = await this._asUserOrApp({
70
+ uatPath: `/open-apis/docx/v1/documents/${documentId}/blocks/${parentBlockId}/children`,
71
+ method: 'POST',
72
+ body: data,
73
+ sdkFn: () => this.client.docx.documentBlockChildren.create({
74
+ path: { document_id: documentId, block_id: parentBlockId },
75
+ data,
76
+ }),
77
+ label: 'createDocBlock',
78
+ });
79
+ return { blocks: res.data.children || [], fallbackWarning: res._fallbackWarning || null };
80
+ },
81
+
82
+ async updateDocBlock(documentId, blockId, updateBody) {
83
+ const res = await this._asUserOrApp({
84
+ uatPath: `/open-apis/docx/v1/documents/${documentId}/blocks/${blockId}`,
85
+ method: 'PATCH',
86
+ body: updateBody,
87
+ sdkFn: () => this.client.docx.documentBlock.patch({
88
+ path: { document_id: documentId, block_id: blockId },
89
+ data: updateBody,
90
+ }),
91
+ label: 'updateDocBlock',
92
+ });
93
+ return { block: res.data.block };
94
+ },
95
+
96
+ async deleteDocBlocks(documentId, parentBlockId, startIndex, endIndex) {
97
+ await this._asUserOrApp({
98
+ uatPath: `/open-apis/docx/v1/documents/${documentId}/blocks/${parentBlockId}/children/batch_delete`,
99
+ method: 'DELETE',
100
+ body: { start_index: startIndex, end_index: endIndex },
101
+ sdkFn: () => this.client.docx.documentBlockChildren.batchDelete({
102
+ path: { document_id: documentId, block_id: parentBlockId },
103
+ data: { start_index: startIndex, end_index: endIndex },
104
+ }),
105
+ label: 'deleteDocBlocks',
106
+ });
107
+ return { deleted: true };
108
+ },
109
+
110
+ // Create a new image block and populate it from either a local file path or
111
+ // an already-uploaded media token. Orchestrates the three-step Feishu flow:
112
+ // 1) create empty image placeholder block
113
+ // 2) upload pixels (skipped if caller passes a ready-made imageToken)
114
+ // 3) patch the placeholder with the uploaded token
115
+ // Returns { blockId, imageToken, viaUser }.
116
+ async createDocBlockWithImage(documentId, parentBlockId, { imagePath, imageToken, index } = {}) {
117
+ if (!imagePath && !imageToken) {
118
+ throw new Error('createDocBlockWithImage: either imagePath or imageToken is required');
119
+ }
120
+
121
+ // Step 1 — empty placeholder.
122
+ const placeholder = buildEmptyImageBlock();
123
+ const createBody = { children: [placeholder] };
124
+ if (index !== undefined) createBody.index = index;
125
+ const created = await this._asUserOrApp({
126
+ uatPath: `/open-apis/docx/v1/documents/${documentId}/blocks/${parentBlockId}/children`,
127
+ method: 'POST',
128
+ body: createBody,
129
+ sdkFn: () => this.client.docx.documentBlockChildren.create({
130
+ path: { document_id: documentId, block_id: parentBlockId },
131
+ data: createBody,
132
+ }),
133
+ label: 'createDocBlockWithImage.placeholder',
134
+ });
135
+ const newBlock = (created.data.children || [])[0];
136
+ const blockId = newBlock?.block_id;
137
+ if (!blockId) throw new Error(`createDocBlockWithImage: placeholder creation returned no block_id: ${JSON.stringify(created.data).slice(0, 400)}`);
138
+
139
+ // Step 2 — upload (if needed).
140
+ let finalToken = imageToken;
141
+ let viaUser = !!created._viaUser;
142
+ let fallbackWarning = created._fallbackWarning || null;
143
+ if (!finalToken) {
144
+ const uploaded = await this.uploadMedia(imagePath, blockId, 'docx_image');
145
+ finalToken = uploaded.fileToken;
146
+ viaUser = viaUser && uploaded.viaUser; // true iff both steps went via user
147
+ }
148
+
149
+ // Step 3 — attach token to the placeholder via PATCH replace_image.
150
+ const patch = buildReplaceImagePayload(finalToken);
151
+ await this._asUserOrApp({
152
+ uatPath: `/open-apis/docx/v1/documents/${documentId}/blocks/${blockId}`,
153
+ method: 'PATCH',
154
+ body: patch,
155
+ sdkFn: () => this.client.docx.documentBlock.patch({
156
+ path: { document_id: documentId, block_id: blockId },
157
+ data: patch,
158
+ }),
159
+ label: 'createDocBlockWithImage.replaceImage',
160
+ });
161
+
162
+ return { blockId, imageToken: finalToken, viaUser, fallbackWarning };
163
+ },
164
+
165
+ // Replace an existing image block's media token (e.g. swap the picture in an
166
+ // already-created image block). Expects an uploaded media token — use
167
+ // uploadMedia or create_doc_block's image_path shortcut to obtain one.
168
+ async updateDocBlockImage(documentId, blockId, imageToken) {
169
+ const patch = buildReplaceImagePayload(imageToken);
170
+ await this._asUserOrApp({
171
+ uatPath: `/open-apis/docx/v1/documents/${documentId}/blocks/${blockId}`,
172
+ method: 'PATCH',
173
+ body: patch,
174
+ sdkFn: () => this.client.docx.documentBlock.patch({
175
+ path: { document_id: documentId, block_id: blockId },
176
+ data: patch,
177
+ }),
178
+ label: 'updateDocBlockImage',
179
+ });
180
+ return { blockId, imageToken };
181
+ },
182
+
183
+ // Create a file-attachment block in a docx, mirroring createDocBlockWithImage:
184
+ // 1) create empty file placeholder block
185
+ // 2) upload the binary via uploadMedia(parent_type=docx_file)
186
+ // 3) PATCH with replace_file.token to attach
187
+ // Returns { blockId, fileToken, viaUser, fallbackWarning }.
188
+ async createDocBlockWithFile(documentId, parentBlockId, { filePath, fileToken, index } = {}) {
189
+ if (!filePath && !fileToken) {
190
+ throw new Error('createDocBlockWithFile: either filePath or fileToken is required');
191
+ }
192
+ const placeholder = buildEmptyFileBlock();
193
+ const createBody = { children: [placeholder] };
194
+ if (index !== undefined) createBody.index = index;
195
+ const created = await this._asUserOrApp({
196
+ uatPath: `/open-apis/docx/v1/documents/${documentId}/blocks/${parentBlockId}/children`,
197
+ method: 'POST',
198
+ body: createBody,
199
+ sdkFn: () => this.client.docx.documentBlockChildren.create({
200
+ path: { document_id: documentId, block_id: parentBlockId },
201
+ data: createBody,
202
+ }),
203
+ label: 'createDocBlockWithFile.placeholder',
204
+ });
205
+ // Feishu auto-wraps a FILE block (block_type=23) in a VIEW block
206
+ // (block_type=33) — the create response returns the OUTER view block.
207
+ // We need the inner file block's id for both the media upload (parent_node)
208
+ // and the replace_file PATCH. Walk children to find it; fall back to a
209
+ // get_doc_blocks lookup if the response didn't materialize the descendant.
210
+ const newBlock = (created.data.children || [])[0];
211
+ const outerBlockId = newBlock?.block_id;
212
+ if (!outerBlockId) throw new Error(`createDocBlockWithFile: placeholder creation returned no block_id: ${JSON.stringify(created.data).slice(0, 400)}`);
213
+ // Feishu auto-wraps a FILE block (23) in a VIEW block (33). The create
214
+ // response's outer block is the view; we need to find the inner file
215
+ // block for both the media upload (parent_node) and the replace_file PATCH.
216
+ let blockId = outerBlockId;
217
+ if (newBlock.block_type !== 23) {
218
+ const inner = await this._findFileChildOf(documentId, outerBlockId, newBlock.children);
219
+ if (!inner) throw new Error(`createDocBlockWithFile: could not locate inner FILE block under view ${outerBlockId}`);
220
+ blockId = inner;
221
+ }
222
+
223
+ let finalToken = fileToken;
224
+ let viaUser = !!created._viaUser;
225
+ let fallbackWarning = created._fallbackWarning || null;
226
+ if (!finalToken) {
227
+ const uploaded = await this.uploadMedia(filePath, blockId, 'docx_file');
228
+ finalToken = uploaded.fileToken;
229
+ viaUser = viaUser && uploaded.viaUser;
230
+ }
231
+
232
+ const patch = buildReplaceFilePayload(finalToken);
233
+ await this._asUserOrApp({
234
+ uatPath: `/open-apis/docx/v1/documents/${documentId}/blocks/${blockId}`,
235
+ method: 'PATCH',
236
+ body: patch,
237
+ sdkFn: () => this.client.docx.documentBlock.patch({
238
+ path: { document_id: documentId, block_id: blockId },
239
+ data: patch,
240
+ }),
241
+ label: 'createDocBlockWithFile.replaceFile',
242
+ });
243
+
244
+ return { blockId, viewBlockId: outerBlockId !== blockId ? outerBlockId : undefined, fileToken: finalToken, viaUser, fallbackWarning };
245
+ },
246
+
247
+ // Helper for createDocBlockWithFile — given a view block id and the children
248
+ // array surfaced by the create response (just IDs in docx v1), find the
249
+ // FILE child (block_type=23). If no children list was returned, fall back
250
+ // to listing the doc and walking by parent_id.
251
+ async _findFileChildOf(documentId, viewBlockId, childIds) {
252
+ if (Array.isArray(childIds) && childIds.length > 0) {
253
+ // childIds[0] is most likely the file block — verify with a get
254
+ for (const childId of childIds) {
255
+ try {
256
+ const res = await this._asUserOrApp({
257
+ uatPath: `/open-apis/docx/v1/documents/${documentId}/blocks/${childId}`,
258
+ method: 'GET',
259
+ sdkFn: () => this.client.docx.documentBlock.get({ path: { document_id: documentId, block_id: childId } }),
260
+ label: '_findFileChildOf.get',
261
+ });
262
+ if (res?.data?.block?.block_type === 23) return childId;
263
+ } catch (_) { /* fall through */ }
264
+ }
265
+ // None matched directly; return the first as best-effort
266
+ return childIds[0];
267
+ }
268
+ // Fallback: list all blocks and find a 23 whose parent_id is the view block
269
+ try {
270
+ const res = await this._asUserOrApp({
271
+ uatPath: `/open-apis/docx/v1/documents/${documentId}/blocks`,
272
+ method: 'GET',
273
+ sdkFn: () => this.client.docx.documentBlock.list({ path: { document_id: documentId } }),
274
+ label: '_findFileChildOf.list',
275
+ });
276
+ const items = res?.data?.items || [];
277
+ const match = items.find(b => b.block_type === 23 && b.parent_id === viewBlockId);
278
+ return match?.block_id || null;
279
+ } catch (_) {
280
+ return null;
281
+ }
282
+ },
283
+
284
+ // Replace an existing file block's media token. Expects an already-uploaded
285
+ // file token (use uploadMedia with parent_type=docx_file, or
286
+ // create_doc_block's file_path shortcut).
287
+ async updateDocBlockFile(documentId, blockId, fileToken) {
288
+ const patch = buildReplaceFilePayload(fileToken);
289
+ await this._asUserOrApp({
290
+ uatPath: `/open-apis/docx/v1/documents/${documentId}/blocks/${blockId}`,
291
+ method: 'PATCH',
292
+ body: patch,
293
+ sdkFn: () => this.client.docx.documentBlock.patch({
294
+ path: { document_id: documentId, block_id: blockId },
295
+ data: patch,
296
+ }),
297
+ label: 'updateDocBlockFile',
298
+ });
299
+ return { blockId, fileToken };
300
+ },
301
+ };
@@ -0,0 +1,77 @@
1
+ // src/clients/official/drive.js
2
+ // Mixed into LarkOfficialClient.prototype by ./index.js (or temporarily by
3
+ // ./base.js during phase A.4–A.11). Methods receive `this` bound to the
4
+ // LarkOfficialClient instance, so they can use this.client, this._safeSDKCall,
5
+ // this._asUserOrApp, this._uatREST, etc. — all defined in base.js.
6
+
7
+ module.exports = {
8
+ // --- Drive ---
9
+
10
+ async listFiles(folderToken, { pageSize = 50, pageToken } = {}) {
11
+ const params = { page_size: pageSize, folder_token: folderToken || '' };
12
+ if (pageToken) params.page_token = pageToken;
13
+ const res = await this._safeSDKCall(() => this.client.drive.file.list({ params }), 'listFiles');
14
+ return { items: res.data.files || [], hasMore: res.data.has_more };
15
+ },
16
+
17
+ async createFolder(name, parentToken) {
18
+ const body = { name, folder_token: parentToken || '' };
19
+ const res = await this._asUserOrApp({
20
+ uatPath: `/open-apis/drive/v1/files/create_folder`,
21
+ method: 'POST',
22
+ body,
23
+ sdkFn: () => this.client.drive.file.createFolder({ data: body }),
24
+ label: 'createFolder',
25
+ });
26
+ return { token: res.data.token, viaUser: !!res._viaUser, fallbackWarning: res._fallbackWarning || null };
27
+ },
28
+
29
+ // --- Drive: File Operations ---
30
+
31
+ async copyFile(fileToken, name, folderToken, type) {
32
+ const data = { name, folder_token: folderToken || '' };
33
+ if (type) data.type = type;
34
+ // _asUserOrApp so UAT-owned files (created by the user) can be copied
35
+ // without the bot needing edit permission. Bot-only path returned 1062501.
36
+ const res = await this._asUserOrApp({
37
+ uatPath: `/open-apis/drive/v1/files/${fileToken}/copy`,
38
+ method: 'POST',
39
+ body: data,
40
+ sdkFn: () => this.client.drive.file.copy({ path: { file_token: fileToken }, data }),
41
+ label: 'copyFile',
42
+ });
43
+ return { file: res.data.file, viaUser: !!res._viaUser, fallbackWarning: res._fallbackWarning || null };
44
+ },
45
+
46
+ async moveFile(fileToken, folderToken, type) {
47
+ // Feishu drive move requires `type` in the request body — without it Feishu
48
+ // returns 1061002 ("invalid params"). type values: file, folder, doc,
49
+ // sheet, bitable, docx, mindnote, slides. _asUserOrApp so user-owned
50
+ // resources can be moved without bot edit permission.
51
+ const data = { folder_token: folderToken || '' };
52
+ if (type) data.type = type;
53
+ const res = await this._asUserOrApp({
54
+ uatPath: `/open-apis/drive/v1/files/${fileToken}/move`,
55
+ method: 'POST',
56
+ body: data,
57
+ sdkFn: () => this.client.drive.file.move({ path: { file_token: fileToken }, data }),
58
+ label: 'moveFile',
59
+ });
60
+ return { taskId: res.data.task_id, viaUser: !!res._viaUser, fallbackWarning: res._fallbackWarning || null };
61
+ },
62
+
63
+ async deleteFile(fileToken, type) {
64
+ // _asUserOrApp so UAT-owned files can be deleted by the user. Bot-only
65
+ // path returned 1062501 because the bot lacks edit permission on
66
+ // user-created resources. Feishu also requires `type` as a query param.
67
+ const params = { type: type || 'file' };
68
+ const res = await this._asUserOrApp({
69
+ uatPath: `/open-apis/drive/v1/files/${fileToken}`,
70
+ method: 'DELETE',
71
+ query: params,
72
+ sdkFn: () => this.client.drive.file.delete({ path: { file_token: fileToken }, params }),
73
+ label: 'deleteFile',
74
+ });
75
+ return { taskId: res.data.task_id, viaUser: !!res._viaUser, fallbackWarning: res._fallbackWarning || null };
76
+ },
77
+ };
@@ -0,0 +1,68 @@
1
+ // src/clients/official/groups.js
2
+ // Mixed into LarkOfficialClient.prototype by ./index.js (or temporarily by
3
+ // ./base.js during phase A.4–A.11). Methods receive `this` bound to the
4
+ // LarkOfficialClient instance, so they can use this.client, this._safeSDKCall,
5
+ // this._asUserOrApp, this._uatREST, etc. — all defined in base.js.
6
+
7
+ module.exports = {
8
+ // --- IM: Chat Management ---
9
+
10
+ async createChat({ name, description, userIds, botIds } = {}) {
11
+ const data = {};
12
+ if (name) data.name = name;
13
+ if (description) data.description = description;
14
+ if (userIds) data.user_id_list = userIds;
15
+ if (botIds) data.bot_id_list = botIds;
16
+ const res = await this._safeSDKCall(
17
+ () => this.client.im.chat.create({ params: { user_id_type: 'open_id' }, data }),
18
+ 'createChat'
19
+ );
20
+ return { chatId: res.data.chat_id };
21
+ },
22
+
23
+ async updateChat(chatId, { name, description } = {}) {
24
+ const data = {};
25
+ if (name) data.name = name;
26
+ if (description) data.description = description;
27
+ const res = await this._safeSDKCall(
28
+ () => this.client.im.chat.update({ path: { chat_id: chatId }, data }),
29
+ 'updateChat'
30
+ );
31
+ return { updated: true };
32
+ },
33
+
34
+ async listChatMembers(chatId, { pageSize = 50, pageToken } = {}) {
35
+ const res = await this._safeSDKCall(
36
+ () => this.client.im.chatMembers.get({
37
+ path: { chat_id: chatId },
38
+ params: { member_id_type: 'open_id', page_size: pageSize, page_token: pageToken },
39
+ }),
40
+ 'listChatMembers'
41
+ );
42
+ return { items: res.data.items || [], hasMore: res.data.has_more, pageToken: res.data.page_token };
43
+ },
44
+
45
+ async addChatMembers(chatId, userIds, memberIdType = 'open_id') {
46
+ const res = await this._safeSDKCall(
47
+ () => this.client.im.chatMembers.create({
48
+ path: { chat_id: chatId },
49
+ params: { member_id_type: memberIdType },
50
+ data: { id_list: userIds },
51
+ }),
52
+ 'addChatMembers'
53
+ );
54
+ return { invalidIds: res.data.invalid_id_list || [] };
55
+ },
56
+
57
+ async removeChatMembers(chatId, userIds, memberIdType = 'open_id') {
58
+ const res = await this._safeSDKCall(
59
+ () => this.client.im.chatMembers.delete({
60
+ path: { chat_id: chatId },
61
+ params: { member_id_type: memberIdType },
62
+ data: { id_list: userIds },
63
+ }),
64
+ 'removeChatMembers'
65
+ );
66
+ return { invalidIds: res.data.invalid_id_list || [] };
67
+ },
68
+ };