feishu-user-plugin 1.3.6 → 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.
- package/.claude-plugin/plugin.json +2 -2
- package/CHANGELOG.md +22 -0
- package/README.md +55 -40
- package/package.json +10 -3
- package/scripts/check-tool-count.js +15 -0
- package/scripts/check-version.js +40 -0
- package/scripts/smoke.js +224 -0
- package/scripts/sync-claude-md.sh +12 -0
- package/scripts/sync-team-skills.sh +22 -0
- package/scripts/test-all-tools.js +158 -0
- package/skills/feishu-user-plugin/SKILL.md +5 -5
- package/skills/feishu-user-plugin/references/CLAUDE.md +138 -99
- package/skills/feishu-user-plugin/references/table.md +18 -9
- package/src/auth/credentials.js +350 -0
- package/src/cli.js +42 -13
- package/src/clients/official/base.js +424 -0
- package/src/clients/official/bitable.js +269 -0
- package/src/clients/official/calendar.js +176 -0
- package/src/clients/official/contacts.js +54 -0
- package/src/clients/official/docs.js +301 -0
- package/src/clients/official/drive.js +77 -0
- package/src/clients/official/groups.js +68 -0
- package/src/clients/official/im.js +414 -0
- package/src/clients/official/index.js +30 -0
- package/src/clients/official/okr.js +127 -0
- package/src/clients/official/tasks.js +142 -0
- package/src/clients/official/uploads.js +260 -0
- package/src/clients/official/wiki.js +207 -0
- package/src/{client.js → clients/user.js} +23 -17
- package/src/index.js +4 -1977
- package/src/logger.js +20 -0
- package/src/oauth.js +5 -1
- package/src/official.js +5 -1944
- package/src/prompts/_registry.js +69 -0
- package/src/prompts/index.js +54 -0
- package/src/server.js +242 -0
- package/src/test-all.js +2 -2
- package/src/test-comprehensive.js +3 -3
- package/src/test-send.js +1 -1
- package/src/tools/_registry.js +30 -0
- package/src/tools/bitable.js +246 -0
- package/src/tools/calendar.js +207 -0
- package/src/tools/contacts.js +66 -0
- package/src/tools/diagnostics.js +172 -0
- package/src/tools/docs.js +158 -0
- package/src/tools/drive.js +111 -0
- package/src/tools/groups.js +81 -0
- package/src/tools/im-read.js +259 -0
- package/src/tools/messaging-bot.js +151 -0
- package/src/tools/messaging-user.js +292 -0
- package/src/tools/okr.js +159 -0
- package/src/tools/profile.js +43 -0
- package/src/tools/tasks.js +168 -0
- package/src/tools/uploads.js +63 -0
- 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
|
+
};
|