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.
@@ -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 };
@@ -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);