feishu-user-plugin 1.0.0
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 +9 -0
- package/.env.example +18 -0
- package/.mcp.json.example +13 -0
- package/CHANGELOG.md +62 -0
- package/LICENSE +21 -0
- package/README.md +473 -0
- package/package.json +57 -0
- package/proto/lark.proto +317 -0
- package/skills/feishu-user-plugin/SKILL.md +103 -0
- package/skills/feishu-user-plugin/references/CLAUDE.md +94 -0
- package/skills/feishu-user-plugin/references/digest.md +26 -0
- package/skills/feishu-user-plugin/references/doc.md +27 -0
- package/skills/feishu-user-plugin/references/drive.md +24 -0
- package/skills/feishu-user-plugin/references/reply.md +23 -0
- package/skills/feishu-user-plugin/references/search.md +22 -0
- package/skills/feishu-user-plugin/references/send.md +28 -0
- package/skills/feishu-user-plugin/references/status.md +22 -0
- package/skills/feishu-user-plugin/references/table.md +32 -0
- package/skills/feishu-user-plugin/references/wiki.md +26 -0
- package/src/client.js +364 -0
- package/src/index.js +697 -0
- package/src/oauth-auto.js +196 -0
- package/src/oauth.js +215 -0
- package/src/official.js +365 -0
- package/src/test-all.js +324 -0
- package/src/test-comprehensive.js +301 -0
- package/src/test-send.js +67 -0
- package/src/utils.js +39 -0
package/src/official.js
ADDED
|
@@ -0,0 +1,365 @@
|
|
|
1
|
+
const lark = require('@larksuiteoapi/node-sdk');
|
|
2
|
+
|
|
3
|
+
class LarkOfficialClient {
|
|
4
|
+
constructor(appId, appSecret) {
|
|
5
|
+
this.appId = appId;
|
|
6
|
+
this.appSecret = appSecret;
|
|
7
|
+
this.client = new lark.Client({ appId, appSecret, disableTokenCache: false });
|
|
8
|
+
this._uat = null;
|
|
9
|
+
this._uatRefresh = null;
|
|
10
|
+
this._uatExpires = 0;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
// --- UAT (User Access Token) Management ---
|
|
14
|
+
|
|
15
|
+
loadUAT() {
|
|
16
|
+
const token = process.env.LARK_USER_ACCESS_TOKEN;
|
|
17
|
+
const refresh = process.env.LARK_USER_REFRESH_TOKEN;
|
|
18
|
+
const expires = parseInt(process.env.LARK_UAT_EXPIRES || '0');
|
|
19
|
+
if (token) {
|
|
20
|
+
this._uat = token;
|
|
21
|
+
this._uatRefresh = refresh || null;
|
|
22
|
+
this._uatExpires = expires;
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
get hasUAT() {
|
|
27
|
+
return !!this._uat;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
async _getValidUAT() {
|
|
31
|
+
if (!this._uat) throw new Error('No user_access_token. Run: npx feishu-user-plugin oauth');
|
|
32
|
+
|
|
33
|
+
const now = Math.floor(Date.now() / 1000);
|
|
34
|
+
// Proactively refresh if we know it's expiring within 5 min
|
|
35
|
+
if (this._uatExpires > 0 && this._uatExpires <= now + 300) {
|
|
36
|
+
return this._refreshUAT();
|
|
37
|
+
}
|
|
38
|
+
return this._uat;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
async _refreshUAT() {
|
|
42
|
+
if (!this._uatRefresh) throw new Error('UAT expired and no refresh token. Run: npx feishu-user-plugin oauth');
|
|
43
|
+
|
|
44
|
+
const res = await fetch('https://open.feishu.cn/open-apis/authen/v2/oauth/token', {
|
|
45
|
+
method: 'POST',
|
|
46
|
+
headers: { 'content-type': 'application/json' },
|
|
47
|
+
body: JSON.stringify({
|
|
48
|
+
grant_type: 'refresh_token',
|
|
49
|
+
client_id: this.appId,
|
|
50
|
+
client_secret: this.appSecret,
|
|
51
|
+
refresh_token: this._uatRefresh,
|
|
52
|
+
}),
|
|
53
|
+
});
|
|
54
|
+
const data = await res.json();
|
|
55
|
+
const tokenData = data.access_token ? data : data.data;
|
|
56
|
+
if (!tokenData?.access_token) throw new Error(`UAT refresh failed: ${JSON.stringify(data)}. Run: npx feishu-user-plugin oauth`);
|
|
57
|
+
|
|
58
|
+
this._uat = tokenData.access_token;
|
|
59
|
+
this._uatRefresh = tokenData.refresh_token || this._uatRefresh;
|
|
60
|
+
this._uatExpires = Math.floor(Date.now() / 1000) + tokenData.expires_in;
|
|
61
|
+
this._persistUAT();
|
|
62
|
+
console.error('[feishu-user-plugin] UAT refreshed successfully');
|
|
63
|
+
return this._uat;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
_persistUAT() {
|
|
67
|
+
const fs = require('fs');
|
|
68
|
+
const path = require('path');
|
|
69
|
+
const envPath = path.join(__dirname, '..', '.env');
|
|
70
|
+
try {
|
|
71
|
+
let env = fs.readFileSync(envPath, 'utf8');
|
|
72
|
+
for (const [key, val] of Object.entries({
|
|
73
|
+
LARK_USER_ACCESS_TOKEN: this._uat,
|
|
74
|
+
LARK_USER_REFRESH_TOKEN: this._uatRefresh,
|
|
75
|
+
LARK_UAT_EXPIRES: String(this._uatExpires),
|
|
76
|
+
})) {
|
|
77
|
+
const regex = new RegExp(`^${key}=.*$`, 'm');
|
|
78
|
+
if (regex.test(env)) env = env.replace(regex, `${key}=${val}`);
|
|
79
|
+
else env += `\n${key}=${val}`;
|
|
80
|
+
}
|
|
81
|
+
fs.writeFileSync(envPath, env.trim() + '\n');
|
|
82
|
+
} catch {}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// --- UAT-based IM operations (for P2P chats) ---
|
|
86
|
+
|
|
87
|
+
// Wrapper: call fn with UAT, retry once after refresh if auth fails (code 99991668/99991663)
|
|
88
|
+
async _withUAT(fn) {
|
|
89
|
+
let uat = await this._getValidUAT();
|
|
90
|
+
const data = await fn(uat);
|
|
91
|
+
if (data.code === 99991668 || data.code === 99991663) {
|
|
92
|
+
// Token invalid/expired — try refresh once
|
|
93
|
+
uat = await this._refreshUAT();
|
|
94
|
+
return fn(uat);
|
|
95
|
+
}
|
|
96
|
+
return data;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
async listChatsAsUser({ pageSize = 20, pageToken } = {}) {
|
|
100
|
+
const params = new URLSearchParams({ page_size: String(pageSize) });
|
|
101
|
+
if (pageToken) params.set('page_token', pageToken);
|
|
102
|
+
const data = await this._withUAT(async (uat) => {
|
|
103
|
+
const res = await fetch(`https://open.feishu.cn/open-apis/im/v1/chats?${params}`, {
|
|
104
|
+
headers: { 'Authorization': `Bearer ${uat}` },
|
|
105
|
+
});
|
|
106
|
+
return res.json();
|
|
107
|
+
});
|
|
108
|
+
if (data.code !== 0) throw new Error(`listChatsAsUser failed (${data.code}): ${data.msg}`);
|
|
109
|
+
return { items: data.data.items || [], pageToken: data.data.page_token, hasMore: data.data.has_more };
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
async readMessagesAsUser(chatId, { pageSize = 20, startTime, endTime, pageToken } = {}) {
|
|
113
|
+
const params = new URLSearchParams({
|
|
114
|
+
container_id_type: 'chat', container_id: chatId, page_size: String(pageSize),
|
|
115
|
+
});
|
|
116
|
+
if (startTime) params.set('start_time', startTime);
|
|
117
|
+
if (endTime) params.set('end_time', endTime);
|
|
118
|
+
if (pageToken) params.set('page_token', pageToken);
|
|
119
|
+
const data = await this._withUAT(async (uat) => {
|
|
120
|
+
const res = await fetch(`https://open.feishu.cn/open-apis/im/v1/messages?${params}`, {
|
|
121
|
+
headers: { 'Authorization': `Bearer ${uat}` },
|
|
122
|
+
});
|
|
123
|
+
return res.json();
|
|
124
|
+
});
|
|
125
|
+
if (data.code !== 0) throw new Error(`readMessagesAsUser failed (${data.code}): ${data.msg}`);
|
|
126
|
+
return {
|
|
127
|
+
items: (data.data.items || []).map(m => this._formatMessage(m)),
|
|
128
|
+
hasMore: data.data.has_more,
|
|
129
|
+
pageToken: data.data.page_token,
|
|
130
|
+
};
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// --- IM ---
|
|
134
|
+
|
|
135
|
+
async listChats({ pageSize = 20, pageToken } = {}) {
|
|
136
|
+
const res = await this.client.im.chat.list({ params: { page_size: pageSize, page_token: pageToken } });
|
|
137
|
+
if (res.code !== 0) throw new Error(`listChats failed (${res.code}): ${res.msg}`);
|
|
138
|
+
return { items: res.data.items || [], pageToken: res.data.page_token, hasMore: res.data.has_more };
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
async readMessages(chatId, { pageSize = 20, startTime, endTime, pageToken } = {}) {
|
|
142
|
+
const params = { container_id_type: 'chat', container_id: chatId, page_size: pageSize };
|
|
143
|
+
if (startTime) params.start_time = startTime;
|
|
144
|
+
if (endTime) params.end_time = endTime;
|
|
145
|
+
if (pageToken) params.page_token = pageToken;
|
|
146
|
+
const res = await this.client.im.message.list({ params });
|
|
147
|
+
if (res.code !== 0) throw new Error(`readMessages failed (${res.code}): ${res.msg}`);
|
|
148
|
+
return { items: (res.data.items || []).map(m => this._formatMessage(m)), hasMore: res.data.has_more, pageToken: res.data.page_token };
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
async getMessage(messageId) {
|
|
152
|
+
const res = await this.client.im.message.get({ path: { message_id: messageId } });
|
|
153
|
+
if (res.code !== 0) throw new Error(`getMessage failed (${res.code}): ${res.msg}`);
|
|
154
|
+
return this._formatMessage(res.data);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
async replyMessage(messageId, text, msgType = 'text') {
|
|
158
|
+
const content = msgType === 'text' ? JSON.stringify({ text }) : text;
|
|
159
|
+
const res = await this.client.im.message.reply({
|
|
160
|
+
path: { message_id: messageId },
|
|
161
|
+
data: { content, msg_type: msgType },
|
|
162
|
+
});
|
|
163
|
+
if (res.code !== 0) throw new Error(`replyMessage failed (${res.code}): ${res.msg}`);
|
|
164
|
+
return { messageId: res.data.message_id };
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
async forwardMessage(messageId, receiverId, receiveIdType = 'chat_id') {
|
|
168
|
+
const res = await this.client.im.message.forward({
|
|
169
|
+
path: { message_id: messageId },
|
|
170
|
+
data: { receive_id: receiverId },
|
|
171
|
+
params: { receive_id_type: receiveIdType },
|
|
172
|
+
});
|
|
173
|
+
if (res.code !== 0) throw new Error(`forwardMessage failed (${res.code}): ${res.msg}`);
|
|
174
|
+
return { messageId: res.data.message_id };
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// --- Docs ---
|
|
178
|
+
|
|
179
|
+
async searchDocs(query, { pageSize = 10, pageToken } = {}) {
|
|
180
|
+
const res = await this.client.request({
|
|
181
|
+
method: 'POST',
|
|
182
|
+
url: '/open-apis/suite/docs-api/search/object',
|
|
183
|
+
data: { search_key: query, count: pageSize, offset: pageToken ? parseInt(pageToken) : 0, owner_ids: [], chat_ids: [], docs_types: [] },
|
|
184
|
+
});
|
|
185
|
+
if (res.code !== 0) throw new Error(`searchDocs failed (${res.code}): ${res.msg}`);
|
|
186
|
+
return { items: res.data.docs_entities || [], hasMore: res.data.has_more };
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
async readDoc(documentId) {
|
|
190
|
+
const res = await this.client.docx.document.rawContent({ path: { document_id: documentId }, params: { lang: 0 } });
|
|
191
|
+
if (res.code !== 0) throw new Error(`readDoc failed (${res.code}): ${res.msg}`);
|
|
192
|
+
return { content: res.data.content };
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
async createDoc(title, folderId) {
|
|
196
|
+
const res = await this.client.docx.document.create({ data: { title, folder_token: folderId || '' } });
|
|
197
|
+
if (res.code !== 0) throw new Error(`createDoc failed (${res.code}): ${res.msg}`);
|
|
198
|
+
return { documentId: res.data.document?.document_id };
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
async getDocBlocks(documentId) {
|
|
202
|
+
const res = await this.client.docx.documentBlock.list({ path: { document_id: documentId }, params: { page_size: 500 } });
|
|
203
|
+
if (res.code !== 0) throw new Error(`getDocBlocks failed (${res.code}): ${res.msg}`);
|
|
204
|
+
return { items: res.data.items || [] };
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
// --- Bitable ---
|
|
208
|
+
|
|
209
|
+
async listBitableTables(appToken) {
|
|
210
|
+
const res = await this.client.bitable.appTable.list({ path: { app_token: appToken } });
|
|
211
|
+
if (res.code !== 0) throw new Error(`listTables failed (${res.code}): ${res.msg}`);
|
|
212
|
+
return { items: res.data.items || [] };
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
async listBitableFields(appToken, tableId) {
|
|
216
|
+
const res = await this.client.bitable.appTableField.list({ path: { app_token: appToken, table_id: tableId } });
|
|
217
|
+
if (res.code !== 0) throw new Error(`listFields failed (${res.code}): ${res.msg}`);
|
|
218
|
+
return { items: res.data.items || [] };
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
async searchBitableRecords(appToken, tableId, { filter, sort, pageSize = 20, pageToken } = {}) {
|
|
222
|
+
const data = {};
|
|
223
|
+
if (filter) data.filter = filter;
|
|
224
|
+
if (sort) data.sort = sort;
|
|
225
|
+
if (pageSize) data.page_size = pageSize;
|
|
226
|
+
if (pageToken) data.page_token = pageToken;
|
|
227
|
+
const res = await this.client.bitable.appTableRecord.search({
|
|
228
|
+
path: { app_token: appToken, table_id: tableId },
|
|
229
|
+
data,
|
|
230
|
+
});
|
|
231
|
+
if (res.code !== 0) throw new Error(`searchRecords failed (${res.code}): ${res.msg}`);
|
|
232
|
+
return { items: res.data.items || [], total: res.data.total, hasMore: res.data.has_more };
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
async createBitableRecord(appToken, tableId, fields) {
|
|
236
|
+
const res = await this.client.bitable.appTableRecord.create({
|
|
237
|
+
path: { app_token: appToken, table_id: tableId },
|
|
238
|
+
data: { fields },
|
|
239
|
+
});
|
|
240
|
+
if (res.code !== 0) throw new Error(`createRecord failed (${res.code}): ${res.msg}`);
|
|
241
|
+
return { recordId: res.data.record?.record_id };
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
async updateBitableRecord(appToken, tableId, recordId, fields) {
|
|
245
|
+
const res = await this.client.bitable.appTableRecord.update({
|
|
246
|
+
path: { app_token: appToken, table_id: tableId, record_id: recordId },
|
|
247
|
+
data: { fields },
|
|
248
|
+
});
|
|
249
|
+
if (res.code !== 0) throw new Error(`updateRecord failed (${res.code}): ${res.msg}`);
|
|
250
|
+
return { recordId: res.data.record?.record_id };
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
// --- Wiki ---
|
|
254
|
+
|
|
255
|
+
async listWikiSpaces() {
|
|
256
|
+
const res = await this.client.wiki.space.list({ params: { page_size: 50 } });
|
|
257
|
+
if (res.code !== 0) throw new Error(`listSpaces failed (${res.code}): ${res.msg}`);
|
|
258
|
+
return { items: res.data.items || [] };
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
async searchWiki(query) {
|
|
262
|
+
const res = await this.client.request({
|
|
263
|
+
method: 'POST',
|
|
264
|
+
url: '/open-apis/suite/docs-api/search/object',
|
|
265
|
+
data: { search_key: query, count: 20, offset: 0, owner_ids: [], chat_ids: [], docs_types: ['wiki'] },
|
|
266
|
+
});
|
|
267
|
+
if (res.code !== 0) throw new Error(`searchWiki failed (${res.code}): ${res.msg}`);
|
|
268
|
+
return { items: res.data.docs_entities || [] };
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
async getWikiNode(spaceId, nodeToken) {
|
|
272
|
+
const res = await this.client.wiki.space.getNode({
|
|
273
|
+
params: { token: nodeToken },
|
|
274
|
+
});
|
|
275
|
+
if (res.code !== 0) throw new Error(`getNode failed (${res.code}): ${res.msg}`);
|
|
276
|
+
return res.data.node;
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
async listWikiNodes(spaceId, { parentNodeToken, pageToken } = {}) {
|
|
280
|
+
const params = { page_size: 50 };
|
|
281
|
+
if (parentNodeToken) params.parent_node_token = parentNodeToken;
|
|
282
|
+
if (pageToken) params.page_token = pageToken;
|
|
283
|
+
const res = await this.client.wiki.spaceNode.list({
|
|
284
|
+
path: { space_id: spaceId },
|
|
285
|
+
params,
|
|
286
|
+
});
|
|
287
|
+
if (res.code !== 0) throw new Error(`listNodes failed (${res.code}): ${res.msg}`);
|
|
288
|
+
return { items: res.data.items || [], hasMore: res.data.has_more };
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
// --- Drive ---
|
|
292
|
+
|
|
293
|
+
async listFiles(folderToken, { pageSize = 50, pageToken } = {}) {
|
|
294
|
+
const params = { page_size: pageSize, folder_token: folderToken || '' };
|
|
295
|
+
if (pageToken) params.page_token = pageToken;
|
|
296
|
+
const res = await this.client.drive.file.list({ params });
|
|
297
|
+
if (res.code !== 0) throw new Error(`listFiles failed (${res.code}): ${res.msg}`);
|
|
298
|
+
return { items: res.data.files || [], hasMore: res.data.has_more };
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
async createFolder(name, parentToken) {
|
|
302
|
+
const res = await this.client.drive.file.createFolder({
|
|
303
|
+
data: { name, folder_token: parentToken || '' },
|
|
304
|
+
});
|
|
305
|
+
if (res.code !== 0) throw new Error(`createFolder failed (${res.code}): ${res.msg}`);
|
|
306
|
+
return { token: res.data.token };
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
// --- Contact ---
|
|
310
|
+
|
|
311
|
+
async findUserByIdentity({ emails, mobiles } = {}) {
|
|
312
|
+
const data = {};
|
|
313
|
+
if (emails) data.emails = Array.isArray(emails) ? emails : [emails];
|
|
314
|
+
if (mobiles) data.mobiles = Array.isArray(mobiles) ? mobiles : [mobiles];
|
|
315
|
+
const res = await this.client.contact.user.batchGetId({
|
|
316
|
+
data,
|
|
317
|
+
params: { user_id_type: 'open_id' },
|
|
318
|
+
});
|
|
319
|
+
if (res.code !== 0) throw new Error(`findUser failed (${res.code}): ${res.msg}`);
|
|
320
|
+
return { userList: res.data.user_list || [] };
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
// --- Chat ID Resolution ---
|
|
324
|
+
|
|
325
|
+
async listAllChats() {
|
|
326
|
+
const allChats = [];
|
|
327
|
+
let pageToken;
|
|
328
|
+
let hasMore = true;
|
|
329
|
+
while (hasMore) {
|
|
330
|
+
const res = await this.client.im.chat.list({ params: { page_size: 100, page_token: pageToken } });
|
|
331
|
+
if (res.code !== 0) throw new Error(`listAllChats failed (${res.code}): ${res.msg}`);
|
|
332
|
+
allChats.push(...(res.data.items || []));
|
|
333
|
+
pageToken = res.data.page_token;
|
|
334
|
+
hasMore = res.data.has_more && !!pageToken;
|
|
335
|
+
}
|
|
336
|
+
return allChats;
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
// --- Helpers ---
|
|
340
|
+
|
|
341
|
+
_formatMessage(m) {
|
|
342
|
+
if (!m) return null;
|
|
343
|
+
let body = m.body?.content || '';
|
|
344
|
+
try { body = JSON.parse(body); } catch {}
|
|
345
|
+
return {
|
|
346
|
+
messageId: m.message_id,
|
|
347
|
+
chatId: m.chat_id,
|
|
348
|
+
senderId: m.sender?.id,
|
|
349
|
+
senderType: m.sender?.sender_type,
|
|
350
|
+
msgType: m.msg_type,
|
|
351
|
+
content: body,
|
|
352
|
+
createTime: this._normalizeTimestamp(m.create_time),
|
|
353
|
+
updateTime: this._normalizeTimestamp(m.update_time),
|
|
354
|
+
};
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
_normalizeTimestamp(ts) {
|
|
358
|
+
if (!ts) return null;
|
|
359
|
+
const n = parseInt(ts);
|
|
360
|
+
// Feishu returns millisecond strings; normalize to seconds
|
|
361
|
+
return String(n > 1e12 ? Math.floor(n / 1000) : n);
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
module.exports = { LarkOfficialClient };
|
package/src/test-all.js
ADDED
|
@@ -0,0 +1,324 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Comprehensive test for all feishu-user-plugin tools.
|
|
4
|
+
* Sends test messages to "飞书plugin测试群".
|
|
5
|
+
*/
|
|
6
|
+
require('dotenv').config({ path: require('path').join(__dirname, '..', '.env') });
|
|
7
|
+
const { LarkUserClient } = require('./client');
|
|
8
|
+
const { LarkOfficialClient } = require('./official');
|
|
9
|
+
|
|
10
|
+
const TEST_GROUP = '飞书plugin测试群';
|
|
11
|
+
const results = [];
|
|
12
|
+
|
|
13
|
+
function log(tool, status, detail = '') {
|
|
14
|
+
const icon = status === 'PASS' ? '✅' : status === 'SKIP' ? '⏭️' : '❌';
|
|
15
|
+
results.push({ tool, status, detail });
|
|
16
|
+
console.log(`${icon} ${tool}: ${status} ${detail}`);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
async function main() {
|
|
20
|
+
// --- Init clients ---
|
|
21
|
+
let userClient, officialClient;
|
|
22
|
+
|
|
23
|
+
// 1. get_login_status — Cookie auth
|
|
24
|
+
try {
|
|
25
|
+
userClient = new LarkUserClient(process.env.LARK_COOKIE);
|
|
26
|
+
await userClient.init();
|
|
27
|
+
log('get_login_status', 'PASS', `user=${userClient.userName || userClient.userId}`);
|
|
28
|
+
} catch (e) {
|
|
29
|
+
log('get_login_status', 'FAIL', e.message);
|
|
30
|
+
console.error('Cookie auth failed, cannot continue user identity tests.');
|
|
31
|
+
return;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// 2. Official client init
|
|
35
|
+
try {
|
|
36
|
+
officialClient = new LarkOfficialClient(process.env.LARK_APP_ID, process.env.LARK_APP_SECRET);
|
|
37
|
+
officialClient.loadUAT();
|
|
38
|
+
log('official_client_init', 'PASS', `hasUAT=${officialClient.hasUAT}`);
|
|
39
|
+
} catch (e) {
|
|
40
|
+
log('official_client_init', 'FAIL', e.message);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// ========== User Identity Tests ==========
|
|
44
|
+
|
|
45
|
+
// 3. search_contacts — search group
|
|
46
|
+
let groupId = null;
|
|
47
|
+
try {
|
|
48
|
+
const res = await userClient.search(TEST_GROUP);
|
|
49
|
+
const group = res.find(r => r.type === 'group');
|
|
50
|
+
if (group) {
|
|
51
|
+
groupId = group.id;
|
|
52
|
+
log('search_contacts (group)', 'PASS', `found "${group.title}" id=${group.id}`);
|
|
53
|
+
} else {
|
|
54
|
+
log('search_contacts (group)', 'FAIL', `group "${TEST_GROUP}" not found. results: ${JSON.stringify(res)}`);
|
|
55
|
+
}
|
|
56
|
+
} catch (e) {
|
|
57
|
+
log('search_contacts (group)', 'FAIL', e.message);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// 4. search_contacts — search user
|
|
61
|
+
let testUserId = null;
|
|
62
|
+
try {
|
|
63
|
+
const res = await userClient.search(userClient.userName || '吴坤儒');
|
|
64
|
+
const user = res.find(r => r.type === 'user');
|
|
65
|
+
if (user) {
|
|
66
|
+
testUserId = user.id;
|
|
67
|
+
log('search_contacts (user)', 'PASS', `found "${user.title}" id=${user.id}`);
|
|
68
|
+
} else {
|
|
69
|
+
log('search_contacts (user)', 'FAIL', 'no user found');
|
|
70
|
+
}
|
|
71
|
+
} catch (e) {
|
|
72
|
+
log('search_contacts (user)', 'FAIL', e.message);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// 5. get_chat_info
|
|
76
|
+
if (groupId) {
|
|
77
|
+
try {
|
|
78
|
+
const info = await userClient.getGroupInfo(groupId);
|
|
79
|
+
if (info && info.name) {
|
|
80
|
+
log('get_chat_info', 'PASS', `name="${info.name}" members=${info.memberCount}`);
|
|
81
|
+
} else {
|
|
82
|
+
log('get_chat_info', 'FAIL', 'no info returned');
|
|
83
|
+
}
|
|
84
|
+
} catch (e) {
|
|
85
|
+
log('get_chat_info', 'FAIL', e.message);
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// 6. get_user_info
|
|
90
|
+
if (testUserId) {
|
|
91
|
+
try {
|
|
92
|
+
const name = await userClient.getUserName(testUserId, '0');
|
|
93
|
+
log('get_user_info', 'PASS', `name="${name}"`);
|
|
94
|
+
} catch (e) {
|
|
95
|
+
log('get_user_info', 'FAIL', e.message);
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// 7. send_as_user (text)
|
|
100
|
+
if (groupId) {
|
|
101
|
+
try {
|
|
102
|
+
const r = await userClient.sendMessage(groupId, '[自动化测试] send_as_user: 文本消息测试');
|
|
103
|
+
log('send_as_user (text)', r.success ? 'PASS' : 'FAIL', `status=${r.status}`);
|
|
104
|
+
} catch (e) {
|
|
105
|
+
log('send_as_user (text)', 'FAIL', e.message);
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// 8. send_to_group
|
|
110
|
+
try {
|
|
111
|
+
const searchRes = await userClient.search(TEST_GROUP);
|
|
112
|
+
const group = searchRes.find(r => r.type === 'group');
|
|
113
|
+
if (group) {
|
|
114
|
+
const r = await userClient.sendMessage(group.id, '[自动化测试] send_to_group: 群消息测试');
|
|
115
|
+
log('send_to_group', r.success ? 'PASS' : 'FAIL', `status=${r.status}`);
|
|
116
|
+
} else {
|
|
117
|
+
log('send_to_group', 'FAIL', 'group not found');
|
|
118
|
+
}
|
|
119
|
+
} catch (e) {
|
|
120
|
+
log('send_to_group', 'FAIL', e.message);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// 9. send_post_as_user (rich text)
|
|
124
|
+
if (groupId) {
|
|
125
|
+
try {
|
|
126
|
+
const paragraphs = [
|
|
127
|
+
[{ tag: 'text', text: '[自动化测试] send_post_as_user: ' }, { tag: 'text', text: '富文本消息测试' }],
|
|
128
|
+
[{ tag: 'text', text: '第二段落 - ' }, { tag: 'a', href: 'https://example.com', text: '链接测试' }],
|
|
129
|
+
];
|
|
130
|
+
const r = await userClient.sendPost(groupId, '自动化测试 - 富文本', paragraphs);
|
|
131
|
+
log('send_post_as_user', r.success ? 'PASS' : 'FAIL', `status=${r.status}`);
|
|
132
|
+
} catch (e) {
|
|
133
|
+
log('send_post_as_user', 'FAIL', e.message);
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// 10. send_image_as_user (skip — needs image_key)
|
|
138
|
+
log('send_image_as_user', 'SKIP', 'needs image_key from upload');
|
|
139
|
+
|
|
140
|
+
// 11. send_file_as_user (skip — needs file_key)
|
|
141
|
+
log('send_file_as_user', 'SKIP', 'needs file_key from upload');
|
|
142
|
+
|
|
143
|
+
// 12. send_sticker_as_user (skip — needs sticker IDs)
|
|
144
|
+
log('send_sticker_as_user', 'SKIP', 'needs sticker_id/sticker_set_id');
|
|
145
|
+
|
|
146
|
+
// 13. send_audio_as_user (skip — needs audio_key)
|
|
147
|
+
log('send_audio_as_user', 'SKIP', 'needs audio_key from upload');
|
|
148
|
+
|
|
149
|
+
// 14. create_p2p_chat
|
|
150
|
+
if (testUserId) {
|
|
151
|
+
try {
|
|
152
|
+
const chatId = await userClient.createChat(testUserId);
|
|
153
|
+
log('create_p2p_chat', chatId ? 'PASS' : 'FAIL', `chatId=${chatId}`);
|
|
154
|
+
} catch (e) {
|
|
155
|
+
log('create_p2p_chat', 'FAIL', e.message);
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// ========== Official API Tests ==========
|
|
160
|
+
|
|
161
|
+
if (!officialClient) {
|
|
162
|
+
log('official_api_tests', 'SKIP', 'no official client');
|
|
163
|
+
} else {
|
|
164
|
+
|
|
165
|
+
// 15. list_chats
|
|
166
|
+
let ocChatId = null;
|
|
167
|
+
try {
|
|
168
|
+
const res = await officialClient.listChats({ pageSize: 5 });
|
|
169
|
+
if (res.items && res.items.length > 0) {
|
|
170
|
+
// find test group
|
|
171
|
+
const testChat = res.items.find(c => c.name && c.name.includes('plugin测试'));
|
|
172
|
+
ocChatId = testChat ? testChat.chat_id : res.items[0].chat_id;
|
|
173
|
+
log('list_chats', 'PASS', `found ${res.items.length} chats, using ${ocChatId}`);
|
|
174
|
+
} else {
|
|
175
|
+
log('list_chats', 'FAIL', 'no chats found');
|
|
176
|
+
}
|
|
177
|
+
} catch (e) {
|
|
178
|
+
log('list_chats', 'FAIL', e.message);
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
// 16. read_messages
|
|
182
|
+
let testMessageId = null;
|
|
183
|
+
if (ocChatId) {
|
|
184
|
+
try {
|
|
185
|
+
const res = await officialClient.readMessages(ocChatId, { pageSize: 10 });
|
|
186
|
+
// Find a text message to reply to
|
|
187
|
+
const textMsg = res.items.find(m => m.msgType === 'text');
|
|
188
|
+
if (textMsg) testMessageId = textMsg.messageId;
|
|
189
|
+
log('read_messages', 'PASS', `got ${res.items.length} messages, text msg=${testMessageId || 'none'}`);
|
|
190
|
+
} catch (e) {
|
|
191
|
+
log('read_messages', 'FAIL', e.message);
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
// 17. reply_message
|
|
196
|
+
if (testMessageId) {
|
|
197
|
+
try {
|
|
198
|
+
const res = await officialClient.replyMessage(testMessageId, '[自动化测试] reply_message: bot回复测试');
|
|
199
|
+
log('reply_message', res.messageId ? 'PASS' : 'FAIL', `messageId=${res.messageId}`);
|
|
200
|
+
} catch (e) {
|
|
201
|
+
log('reply_message', 'FAIL', e.message);
|
|
202
|
+
}
|
|
203
|
+
} else {
|
|
204
|
+
log('reply_message', 'SKIP', 'no text message to reply to');
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
// 18. forward_message (skip — would duplicate messages)
|
|
208
|
+
log('forward_message', 'SKIP', 'skipped to avoid duplicate messages');
|
|
209
|
+
|
|
210
|
+
// 19. search_docs
|
|
211
|
+
try {
|
|
212
|
+
const res = await officialClient.searchDocs('测试');
|
|
213
|
+
log('search_docs', 'PASS', `found ${(res.items || []).length} docs`);
|
|
214
|
+
} catch (e) {
|
|
215
|
+
log('search_docs', 'FAIL', e.message);
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
// 20. read_doc (skip — needs doc ID)
|
|
219
|
+
log('read_doc', 'SKIP', 'needs document_id from search_docs');
|
|
220
|
+
|
|
221
|
+
// 21. create_doc (skip — would create real doc)
|
|
222
|
+
log('create_doc', 'SKIP', 'skipped to avoid creating unnecessary docs');
|
|
223
|
+
|
|
224
|
+
// 22. list_bitable_tables (skip — needs app_token)
|
|
225
|
+
log('list_bitable_tables', 'SKIP', 'needs bitable app_token');
|
|
226
|
+
|
|
227
|
+
// 23. list_bitable_fields (skip)
|
|
228
|
+
log('list_bitable_fields', 'SKIP', 'needs app_token + table_id');
|
|
229
|
+
|
|
230
|
+
// 24. search_bitable_records (skip)
|
|
231
|
+
log('search_bitable_records', 'SKIP', 'needs app_token + table_id');
|
|
232
|
+
|
|
233
|
+
// 25. create_bitable_record (skip)
|
|
234
|
+
log('create_bitable_record', 'SKIP', 'needs app_token + table_id + fields');
|
|
235
|
+
|
|
236
|
+
// 26. update_bitable_record (skip)
|
|
237
|
+
log('update_bitable_record', 'SKIP', 'needs app_token + table_id + record_id');
|
|
238
|
+
|
|
239
|
+
// 27. list_wiki_spaces
|
|
240
|
+
try {
|
|
241
|
+
const res = await officialClient.listWikiSpaces();
|
|
242
|
+
log('list_wiki_spaces', 'PASS', `found ${(res.items || []).length} spaces`);
|
|
243
|
+
} catch (e) {
|
|
244
|
+
log('list_wiki_spaces', 'FAIL', e.message);
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
// 28. search_wiki
|
|
248
|
+
try {
|
|
249
|
+
const res = await officialClient.searchWiki('测试');
|
|
250
|
+
log('search_wiki', 'PASS', `found ${(res.items || []).length} nodes`);
|
|
251
|
+
} catch (e) {
|
|
252
|
+
log('search_wiki', 'FAIL', e.message);
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
// 29. list_wiki_nodes (skip — needs space_id)
|
|
256
|
+
log('list_wiki_nodes', 'SKIP', 'needs space_id from list_wiki_spaces');
|
|
257
|
+
|
|
258
|
+
// 30. list_files
|
|
259
|
+
try {
|
|
260
|
+
const res = await officialClient.listFiles();
|
|
261
|
+
log('list_files', 'PASS', `found ${(res.items || []).length} files`);
|
|
262
|
+
} catch (e) {
|
|
263
|
+
log('list_files', 'FAIL', e.message);
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
// 31. create_folder (skip)
|
|
267
|
+
log('create_folder', 'SKIP', 'skipped to avoid creating unnecessary folders');
|
|
268
|
+
|
|
269
|
+
// 32. find_user
|
|
270
|
+
try {
|
|
271
|
+
const res = await officialClient.findUserByIdentity({ emails: 'test@test.com' });
|
|
272
|
+
log('find_user', 'PASS', `returned ${(res.userList || []).length} users (expected 0 for test email)`);
|
|
273
|
+
} catch (e) {
|
|
274
|
+
log('find_user', 'FAIL', e.message);
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
// ========== UAT Tests ==========
|
|
278
|
+
|
|
279
|
+
if (officialClient.hasUAT) {
|
|
280
|
+
// 33. list_user_chats
|
|
281
|
+
let p2pChatId = null;
|
|
282
|
+
try {
|
|
283
|
+
const res = await officialClient.listChatsAsUser({ pageSize: 20 });
|
|
284
|
+
const items = res.items || [];
|
|
285
|
+
// find a p2p chat
|
|
286
|
+
const p2p = items.find(c => c.chat_mode === 'p2p');
|
|
287
|
+
if (p2p) p2pChatId = p2p.chat_id;
|
|
288
|
+
log('list_user_chats', 'PASS', `found ${items.length} chats, p2p=${p2pChatId || 'none'}`);
|
|
289
|
+
} catch (e) {
|
|
290
|
+
log('list_user_chats', 'FAIL', e.message);
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
// 34. read_p2p_messages
|
|
294
|
+
if (p2pChatId) {
|
|
295
|
+
try {
|
|
296
|
+
const res = await officialClient.readMessagesAsUser(p2pChatId, { pageSize: 3 });
|
|
297
|
+
log('read_p2p_messages', 'PASS', `got ${(res.items || []).length} messages`);
|
|
298
|
+
} catch (e) {
|
|
299
|
+
log('read_p2p_messages', 'FAIL', e.message);
|
|
300
|
+
}
|
|
301
|
+
} else {
|
|
302
|
+
log('read_p2p_messages', 'SKIP', 'no P2P chat found');
|
|
303
|
+
}
|
|
304
|
+
} else {
|
|
305
|
+
log('list_user_chats', 'SKIP', 'no UAT configured');
|
|
306
|
+
log('read_p2p_messages', 'SKIP', 'no UAT configured');
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
// ========== Summary ==========
|
|
311
|
+
console.log('\n========== TEST SUMMARY ==========');
|
|
312
|
+
const pass = results.filter(r => r.status === 'PASS').length;
|
|
313
|
+
const fail = results.filter(r => r.status === 'FAIL').length;
|
|
314
|
+
const skip = results.filter(r => r.status === 'SKIP').length;
|
|
315
|
+
console.log(`PASS: ${pass} FAIL: ${fail} SKIP: ${skip} TOTAL: ${results.length}`);
|
|
316
|
+
if (fail > 0) {
|
|
317
|
+
console.log('\nFailed tests:');
|
|
318
|
+
for (const r of results.filter(r => r.status === 'FAIL')) {
|
|
319
|
+
console.log(` ❌ ${r.tool}: ${r.detail}`);
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
main().catch(console.error);
|