feishu-user-plugin 1.3.6 → 1.3.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (71) hide show
  1. package/.claude-plugin/plugin.json +2 -2
  2. package/CHANGELOG.md +71 -0
  3. package/README.md +72 -41
  4. package/package.json +10 -3
  5. package/scripts/capture-feishu-protobuf.js +86 -0
  6. package/scripts/check-changelog.js +31 -0
  7. package/scripts/check-docs-sync.js +41 -0
  8. package/scripts/check-tool-count.js +40 -0
  9. package/scripts/check-version.js +40 -0
  10. package/scripts/decode-feishu-protobuf.js +115 -0
  11. package/scripts/smoke.js +224 -0
  12. package/scripts/sync-claude-md.sh +12 -0
  13. package/scripts/sync-server-json.js +71 -0
  14. package/scripts/sync-team-skills.sh +22 -0
  15. package/scripts/test-all-tools.js +158 -0
  16. package/scripts/test-wiki-attach-fallback.js +71 -0
  17. package/scripts/test-ws-events.js +84 -0
  18. package/skills/feishu-user-plugin/SKILL.md +5 -5
  19. package/skills/feishu-user-plugin/references/CLAUDE.md +248 -318
  20. package/skills/feishu-user-plugin/references/table.md +18 -9
  21. package/src/auth/cookie.js +30 -0
  22. package/src/auth/credentials.js +399 -0
  23. package/src/auth/profile-router.js +248 -0
  24. package/src/auth/uat.js +231 -0
  25. package/src/cli.js +45 -13
  26. package/src/clients/official/base.js +188 -0
  27. package/src/clients/official/bitable.js +269 -0
  28. package/src/clients/official/calendar.js +176 -0
  29. package/src/clients/official/contacts.js +54 -0
  30. package/src/clients/official/docs.js +301 -0
  31. package/src/clients/official/drive.js +77 -0
  32. package/src/clients/official/groups.js +68 -0
  33. package/src/clients/official/im.js +414 -0
  34. package/src/clients/official/index.js +30 -0
  35. package/src/clients/official/okr.js +127 -0
  36. package/src/clients/official/tasks.js +142 -0
  37. package/src/clients/official/uploads.js +260 -0
  38. package/src/clients/official/wiki.js +207 -0
  39. package/src/{client.js → clients/user.js} +25 -33
  40. package/src/config.js +13 -8
  41. package/src/events/event-buffer.js +100 -0
  42. package/src/events/index.js +5 -0
  43. package/src/events/ws-server.js +86 -0
  44. package/src/index.js +4 -1977
  45. package/src/logger.js +20 -0
  46. package/src/oauth.js +5 -1
  47. package/src/official.js +5 -1944
  48. package/src/prompts/_registry.js +69 -0
  49. package/src/prompts/index.js +54 -0
  50. package/src/server.js +305 -0
  51. package/src/setup.js +16 -1
  52. package/src/test-all.js +2 -2
  53. package/src/test-comprehensive.js +3 -3
  54. package/src/test-send.js +1 -1
  55. package/src/tools/_registry.js +31 -0
  56. package/src/tools/bitable.js +246 -0
  57. package/src/tools/calendar.js +207 -0
  58. package/src/tools/contacts.js +66 -0
  59. package/src/tools/diagnostics.js +172 -0
  60. package/src/tools/docs.js +158 -0
  61. package/src/tools/drive.js +111 -0
  62. package/src/tools/events.js +64 -0
  63. package/src/tools/groups.js +81 -0
  64. package/src/tools/im-read.js +259 -0
  65. package/src/tools/messaging-bot.js +151 -0
  66. package/src/tools/messaging-user.js +292 -0
  67. package/src/tools/okr.js +159 -0
  68. package/src/tools/profile.js +74 -0
  69. package/src/tools/tasks.js +168 -0
  70. package/src/tools/uploads.js +63 -0
  71. package/src/tools/wiki.js +191 -0
package/src/index.js CHANGED
@@ -1,1979 +1,6 @@
1
1
  #!/usr/bin/env node
2
- // MCP stdio protocol uses stdout for JSON-RPC. ANY accidental stdout write from
3
- // this process or its dependencies will corrupt the transport and disconnect the
4
- // client. v1.3.1 patched the Lark SDK's defaultLogger via a custom logger, but
5
- // that only covers one dependency. This global redirect is defense-in-depth:
6
- // any present or future module that calls console.log (or console.info) goes to
7
- // stderr instead, so MCP stdio stays clean no matter what.
8
- // Do this BEFORE any other require() so even early log calls are captured.
9
- console.log = (...args) => console.error(...args);
10
- console.info = (...args) => console.error(...args);
11
-
12
- const { Server } = require('@modelcontextprotocol/sdk/server/index.js');
13
- const { StdioServerTransport } = require('@modelcontextprotocol/sdk/server/stdio.js');
14
- const {
15
- CallToolRequestSchema,
16
- ListToolsRequestSchema,
17
- } = require('@modelcontextprotocol/sdk/types.js');
18
- const path = require('path');
19
- // Local dev fallback: MCP clients inject env vars from config's env block at spawn time.
20
- // This dotenv line only matters when running locally with a .env file (e.g. during development).
21
- require('dotenv').config({ path: path.join(__dirname, '..', '.env') });
22
- const { LarkUserClient } = require('./client');
23
- const { LarkOfficialClient } = require('./official');
24
- const { resolveToObj, resolveToken, parseFeishuInput } = require('./resolver');
25
-
26
- // --- Chat ID Mapper ---
27
-
28
- class ChatIdMapper {
29
- constructor() {
30
- this.nameCache = new Map(); // oc_id → chat name
31
- this.lastRefresh = 0;
32
- this.TTL = 5 * 60 * 1000; // 5 min cache
33
- }
34
-
35
- async _refresh(official) {
36
- if (Date.now() - this.lastRefresh < this.TTL) return;
37
- try {
38
- const chats = await official.listAllChats();
39
- this.nameCache.clear();
40
- for (const chat of chats) {
41
- this.nameCache.set(chat.chat_id, chat.name || '');
42
- }
43
- this.lastRefresh = Date.now();
44
- } catch (e) {
45
- console.error('[feishu-user-plugin] ChatIdMapper refresh failed:', e.message);
46
- }
47
- }
48
-
49
- // Case-insensitive name matching helper
50
- static _nameMatch(haystack, needle, exact = false) {
51
- if (!haystack || !needle) return false;
52
- const h = haystack.toLowerCase(), n = needle.toLowerCase();
53
- return exact ? h === n : h.includes(n);
54
- }
55
-
56
- async findByName(name, official) {
57
- await this._refresh(official);
58
- // Exact match first (case-insensitive)
59
- for (const [ocId, chatName] of this.nameCache) {
60
- if (ChatIdMapper._nameMatch(chatName, name, true)) return ocId;
61
- }
62
- // Partial match (case-insensitive)
63
- for (const [ocId, chatName] of this.nameCache) {
64
- if (ChatIdMapper._nameMatch(chatName, name)) return ocId;
65
- }
66
- return null;
67
- }
68
-
69
- async resolveToOcId(chatIdOrName, official) {
70
- if (!chatIdOrName) return null;
71
- if (chatIdOrName.startsWith('oc_')) return chatIdOrName;
72
- // Also accept raw numeric IDs (from search_contacts)
73
- if (/^\d+$/.test(chatIdOrName)) return chatIdOrName;
74
- // Strategy 1: Search in bot's group list cache
75
- const cached = await this.findByName(chatIdOrName, official);
76
- if (cached) return cached;
77
- // Strategy 2: Use im.v1.chat.search API (finds groups even if not in cache)
78
- try {
79
- const results = await official.chatSearch(chatIdOrName);
80
- for (const chat of results) {
81
- this.nameCache.set(chat.chat_id, chat.name || '');
82
- if (ChatIdMapper._nameMatch(chat.name, chatIdOrName, true)) return chat.chat_id;
83
- }
84
- // Partial match on search results (case-insensitive)
85
- for (const chat of results) {
86
- if (ChatIdMapper._nameMatch(chat.name, chatIdOrName)) return chat.chat_id;
87
- }
88
- } catch (e) {
89
- console.error('[feishu-user-plugin] chatSearch fallback failed:', e.message);
90
- }
91
- return null;
92
- }
93
-
94
- // Strategy 3: Use search_contacts (cookie-based) to find external groups by name
95
- // Returns numeric chat_id that works with UAT readMessagesAsUser
96
- async resolveViaContacts(chatName, userClient) {
97
- if (!userClient) return null;
98
- try {
99
- const results = await userClient.search(chatName);
100
- const groups = results.filter(r => r.type === 'group');
101
- // Exact match first (case-insensitive)
102
- for (const g of groups) {
103
- if (ChatIdMapper._nameMatch(g.title, chatName, true)) return String(g.id);
104
- }
105
- // Partial match (case-insensitive)
106
- for (const g of groups) {
107
- if (ChatIdMapper._nameMatch(g.title, chatName)) return String(g.id);
108
- }
109
- } catch (e) {
110
- console.error('[feishu-user-plugin] search_contacts fallback failed:', e.message);
111
- }
112
- return null;
113
- }
114
- }
115
-
116
- // --- Client Singletons + Profiles ---
117
-
118
- let userClient = null;
119
- let officialClient = null;
120
- const chatIdMapper = new ChatIdMapper();
121
-
122
- // Profile system (v1.3.6).
123
- // Default behaviour is identical to pre-1.3.6: LARK_COOKIE / LARK_APP_ID / etc.
124
- // from process.env act as profile "default". To register more profiles, set
125
- // LARK_PROFILES_JSON in the MCP env to a JSON object:
126
- // { "alt": { "LARK_COOKIE": "...", "LARK_APP_ID": "...", ... }, ... }
127
- // Then call switch_profile to change which credential set is active.
128
- let currentProfile = 'default';
129
-
130
- function loadProfileMap() {
131
- const raw = process.env.LARK_PROFILES_JSON;
132
- if (!raw) return {};
133
- try {
134
- const parsed = JSON.parse(raw);
135
- if (parsed && typeof parsed === 'object') return parsed;
136
- } catch (e) {
137
- console.error(`[feishu-user-plugin] LARK_PROFILES_JSON parse failed: ${e.message}`);
138
- }
139
- return {};
140
- }
141
-
142
- function profileEnv(name) {
143
- if (name === 'default') {
144
- return {
145
- LARK_COOKIE: process.env.LARK_COOKIE,
146
- LARK_APP_ID: process.env.LARK_APP_ID,
147
- LARK_APP_SECRET: process.env.LARK_APP_SECRET,
148
- LARK_USER_ACCESS_TOKEN: process.env.LARK_USER_ACCESS_TOKEN,
149
- LARK_USER_REFRESH_TOKEN: process.env.LARK_USER_REFRESH_TOKEN,
150
- };
151
- }
152
- const profiles = loadProfileMap();
153
- if (!profiles[name]) throw new Error(`Profile "${name}" not found. Available: ${['default', ...Object.keys(profiles)].join(', ')}`);
154
- return profiles[name];
155
- }
156
-
157
- async function getUserClient() {
158
- if (userClient) return userClient;
159
- const env = profileEnv(currentProfile);
160
- const cookie = env.LARK_COOKIE;
161
- if (!cookie) throw new Error(
162
- `LARK_COOKIE not set for profile "${currentProfile}". To fix:\n` +
163
- '1. Open https://www.feishu.cn/messenger/ and log in\n' +
164
- '2. DevTools → Network tab → Disable cache → Reload → Click first request → Request Headers → Cookie → Copy value\n' +
165
- ' (Do NOT use document.cookie or Application→Cookies — they miss HttpOnly cookies like session/sl_session)\n' +
166
- '3. Paste the cookie string into your .mcp.json env LARK_COOKIE field, then restart Claude Code\n' +
167
- 'If Playwright MCP is available: navigate to feishu.cn/messenger/, let user log in, then use context.cookies() to get the full cookie string including HttpOnly cookies.'
168
- );
169
- userClient = new LarkUserClient(cookie);
170
- await userClient.init();
171
- return userClient;
172
- }
173
-
174
- function getOfficialClient() {
175
- if (officialClient) return officialClient;
176
- const env = profileEnv(currentProfile);
177
- const appId = env.LARK_APP_ID;
178
- const appSecret = env.LARK_APP_SECRET;
179
- if (!appId || !appSecret) throw new Error(
180
- `LARK_APP_ID and LARK_APP_SECRET not set for profile "${currentProfile}".\n` +
181
- 'For team members: these should be pre-filled in your .mcp.json. Check that the config was copied correctly from the team-skills README.\n' +
182
- 'For external users: create a Custom App at https://open.feishu.cn/app, get the App ID and App Secret, add them to your .mcp.json env.'
183
- );
184
- // Honor profile-specific UAT env if present (LarkOfficialClient.loadUAT uses
185
- // process.env directly; we patch the env temporarily for non-default profiles)
186
- const prevUAT = process.env.LARK_USER_ACCESS_TOKEN;
187
- const prevRT = process.env.LARK_USER_REFRESH_TOKEN;
188
- if (currentProfile !== 'default') {
189
- if (env.LARK_USER_ACCESS_TOKEN) process.env.LARK_USER_ACCESS_TOKEN = env.LARK_USER_ACCESS_TOKEN;
190
- if (env.LARK_USER_REFRESH_TOKEN) process.env.LARK_USER_REFRESH_TOKEN = env.LARK_USER_REFRESH_TOKEN;
191
- }
192
- officialClient = new LarkOfficialClient(appId, appSecret);
193
- officialClient.loadUAT();
194
- if (currentProfile !== 'default') {
195
- process.env.LARK_USER_ACCESS_TOKEN = prevUAT;
196
- process.env.LARK_USER_REFRESH_TOKEN = prevRT;
197
- }
198
- return officialClient;
199
- }
200
-
201
- // --- Tool Definitions ---
202
-
203
- const TOOLS = [
204
- // ========== Profile management (v1.3.6) ==========
205
- {
206
- name: 'list_profiles',
207
- description: '[Plugin] List all available identity profiles (sets of LARK_COOKIE/APP_ID/APP_SECRET/UAT). The "default" profile uses the top-level env vars; additional profiles come from LARK_PROFILES_JSON. Marks the currently active profile.',
208
- inputSchema: { type: 'object', properties: {} },
209
- },
210
- {
211
- name: 'switch_profile',
212
- description: '[Plugin] Switch the active identity profile. Subsequent tool calls use the new profile\'s credentials. Cached client instances are reset so the next call rebuilds against the new creds.',
213
- inputSchema: {
214
- type: 'object',
215
- properties: {
216
- name: { type: 'string', description: 'Profile name. "default" for top-level env vars; any key from LARK_PROFILES_JSON otherwise.' },
217
- },
218
- required: ['name'],
219
- },
220
- },
221
-
222
- // ========== User Identity — Send Messages ==========
223
- {
224
- name: 'send_as_user',
225
- description: '[User Identity] Send a text message as the logged-in Feishu user. Supports reply threading and real @-mentions (triggers push notifications).',
226
- inputSchema: {
227
- type: 'object',
228
- properties: {
229
- chat_id: { type: 'string', description: 'Target chat ID (numeric)' },
230
- text: { type: 'string', description: 'Message text. If `ats` is provided, include the display marker for each @ in this text (default marker is `@<name>`).' },
231
- ats: {
232
- type: 'array',
233
- description: 'Optional @-mentions. Each entry: {userId: "ou_xxx", name: "DisplayName"}. The text must contain each @<name> marker in order — it gets spliced into a real AT element so the mentioned user receives a notification.',
234
- items: { type: 'object', properties: { userId: { type: 'string' }, name: { type: 'string' }, marker: { type: 'string' } } },
235
- },
236
- root_id: { type: 'string', description: 'Thread root message ID (for reply, optional)' },
237
- parent_id: { type: 'string', description: 'Parent message ID (for nested reply, optional)' },
238
- },
239
- required: ['chat_id', 'text'],
240
- },
241
- },
242
- {
243
- name: 'send_to_user',
244
- description: '[User Identity] Search user by name → create P2P chat → send text message. All in one step.',
245
- inputSchema: {
246
- type: 'object',
247
- properties: {
248
- user_name: { type: 'string', description: 'Recipient name (Chinese or English)' },
249
- text: { type: 'string', description: 'Message text' },
250
- ats: {
251
- type: 'array',
252
- description: 'Optional @-mentions. Same format as send_as_user.ats: [{userId, name}]. Text must contain the `@<name>` marker for each entry.',
253
- items: { type: 'object', properties: { userId: { type: 'string' }, name: { type: 'string' }, marker: { type: 'string' } } },
254
- },
255
- },
256
- required: ['user_name', 'text'],
257
- },
258
- },
259
- {
260
- name: 'send_to_group',
261
- description: '[User Identity] Search group by name → send text message. All in one step.',
262
- inputSchema: {
263
- type: 'object',
264
- properties: {
265
- group_name: { type: 'string', description: 'Group chat name' },
266
- text: { type: 'string', description: 'Message text' },
267
- ats: {
268
- type: 'array',
269
- description: 'Optional @-mentions that trigger real notifications. Each entry: {userId, name}. Text must contain `@<name>` marker for each entry.',
270
- items: { type: 'object', properties: { userId: { type: 'string' }, name: { type: 'string' }, marker: { type: 'string' } } },
271
- },
272
- },
273
- required: ['group_name', 'text'],
274
- },
275
- },
276
- {
277
- name: 'batch_send',
278
- description: '[User Identity / Official API] Send the same or different content to multiple targets in one call. Each target dispatches sequentially with a small delay (anti-rate-limit) and reports per-target success/error. Identity is the cookie user (user-identity sends) unless target.via=bot. Use for broadcast / fan-out scenarios.',
279
- inputSchema: {
280
- type: 'object',
281
- properties: {
282
- targets: {
283
- type: 'array',
284
- description: 'Array of targets. Each entry: { type: "user"|"group"|"chat", id: <user_name | group_name | chat_id>, content: { kind: "text"|"image"|"file"|"post", ... } }. For kind="text": { text }. For "image": { image_key }. For "file": { file_key, file_name }. For "post": { title, paragraphs }. Optional per-target: via="bot" routes through send_message_as_bot (chat_id required).',
285
- items: { type: 'object' },
286
- },
287
- delay_ms: { type: 'number', description: 'Delay between sends in milliseconds (default 200, increase for risky volumes).' },
288
- },
289
- required: ['targets'],
290
- },
291
- },
292
- {
293
- name: 'send_image_as_user',
294
- description: '[User Identity] Send an image as the logged-in user. Requires image_key (upload via Official API first).',
295
- inputSchema: {
296
- type: 'object',
297
- properties: {
298
- chat_id: { type: 'string', description: 'Target chat ID' },
299
- image_key: { type: 'string', description: 'Image key from upload (img_v2_xxx or img_v3_xxx)' },
300
- root_id: { type: 'string', description: 'Thread root message ID (optional)' },
301
- },
302
- required: ['chat_id', 'image_key'],
303
- },
304
- },
305
- {
306
- name: 'send_file_as_user',
307
- description: '[User Identity] Send a file as the logged-in user. Requires file_key (upload via Official API first).',
308
- inputSchema: {
309
- type: 'object',
310
- properties: {
311
- chat_id: { type: 'string', description: 'Target chat ID' },
312
- file_key: { type: 'string', description: 'File key from upload' },
313
- file_name: { type: 'string', description: 'Display file name' },
314
- root_id: { type: 'string', description: 'Thread root message ID (optional)' },
315
- },
316
- required: ['chat_id', 'file_key', 'file_name'],
317
- },
318
- },
319
- {
320
- name: 'send_sticker_as_user',
321
- description: '[User Identity] Send a sticker/emoji as the logged-in user.',
322
- inputSchema: {
323
- type: 'object',
324
- properties: {
325
- chat_id: { type: 'string', description: 'Target chat ID' },
326
- sticker_id: { type: 'string', description: 'Sticker ID' },
327
- sticker_set_id: { type: 'string', description: 'Sticker set ID' },
328
- },
329
- required: ['chat_id', 'sticker_id', 'sticker_set_id'],
330
- },
331
- },
332
- {
333
- name: 'send_post_as_user',
334
- description: '[User Identity] Send a rich text (POST) message with title and formatted paragraphs. Supports real @-mentions that trigger notifications.',
335
- inputSchema: {
336
- type: 'object',
337
- properties: {
338
- chat_id: { type: 'string', description: 'Target chat ID' },
339
- title: { type: 'string', description: 'Post title (optional)' },
340
- paragraphs: {
341
- type: 'array',
342
- description: 'Array of paragraphs. Each paragraph is an array of elements:\n• {tag:"text",text:"..."} — plain text\n• {tag:"a",href:"https://...",text:"display"} — hyperlink\n• {tag:"at",userId:"ou_xxx",name:"Display Name"} — real @-mention (triggers notification)',
343
- items: { type: 'array', items: { type: 'object' } },
344
- },
345
- root_id: { type: 'string', description: 'Thread root message ID (optional)' },
346
- },
347
- required: ['chat_id', 'paragraphs'],
348
- },
349
- },
350
- {
351
- name: 'send_audio_as_user',
352
- description: '[User Identity] Send an audio message as the logged-in user. Requires audio_key.',
353
- inputSchema: {
354
- type: 'object',
355
- properties: {
356
- chat_id: { type: 'string', description: 'Target chat ID' },
357
- audio_key: { type: 'string', description: 'Audio key from upload' },
358
- },
359
- required: ['chat_id', 'audio_key'],
360
- },
361
- },
362
-
363
- // ========== User Identity — Contacts & Info ==========
364
- {
365
- name: 'search_contacts',
366
- description: '[User Identity] Search Feishu users, bots, or group chats by name. Returns IDs.',
367
- inputSchema: {
368
- type: 'object',
369
- properties: { query: { type: 'string', description: 'Search keyword' } },
370
- required: ['query'],
371
- },
372
- },
373
- {
374
- name: 'create_p2p_chat',
375
- description: '[User Identity] Create or get a P2P (direct message) chat. Returns numeric chat_id.',
376
- inputSchema: {
377
- type: 'object',
378
- properties: { user_id: { type: 'string', description: 'Target user ID from search_contacts' } },
379
- required: ['user_id'],
380
- },
381
- },
382
- {
383
- name: 'get_chat_info',
384
- description: '[Official API + User Identity fallback] Get chat details: name, description, member count, owner. Supports both oc_xxx and numeric chat_id.',
385
- inputSchema: {
386
- type: 'object',
387
- properties: { chat_id: { type: 'string', description: 'Chat ID (oc_xxx or numeric)' } },
388
- required: ['chat_id'],
389
- },
390
- },
391
- {
392
- name: 'get_user_info',
393
- description: '[User Identity] Look up a user\'s display name by user ID.',
394
- inputSchema: {
395
- type: 'object',
396
- properties: {
397
- user_id: { type: 'string', description: 'User ID' },
398
- chat_id: { type: 'string', description: 'Chat context (optional)' },
399
- },
400
- required: ['user_id'],
401
- },
402
- },
403
- {
404
- name: 'get_login_status',
405
- description: 'Check cookie session validity and app credentials status. Also refreshes session.',
406
- inputSchema: { type: 'object', properties: {} },
407
- },
408
-
409
- // ========== IM — Official API (User Identity via UAT) ==========
410
- {
411
- name: 'read_p2p_messages',
412
- description: '[User UAT] Read P2P (direct message) chat history using user_access_token. Works for chats the bot cannot access. Returns newest messages first by default. Auto-expands merge_forward messages into their child messages by default — disable with expand_merge_forward=false. Requires OAuth setup.',
413
- inputSchema: {
414
- type: 'object',
415
- properties: {
416
- chat_id: { type: 'string', description: 'Chat ID (numeric from create_p2p_chat, or oc_xxx from list_user_chats). Both formats work.' },
417
- page_size: { type: 'number', description: 'Messages to fetch (default 20, max 50)' },
418
- start_time: { type: 'string', description: 'Start timestamp in seconds (optional)' },
419
- end_time: { type: 'string', description: 'End timestamp in seconds (optional)' },
420
- sort_type: { type: 'string', enum: ['ByCreateTimeDesc', 'ByCreateTimeAsc'], description: 'Sort order (default: ByCreateTimeDesc = newest first)' },
421
- expand_merge_forward: { type: 'boolean', description: 'Auto-expand merge_forward placeholders into their child messages (default true). Children carry parentMessageId; use that id (not the child id) with download_image / download_file.' },
422
- },
423
- required: ['chat_id'],
424
- },
425
- },
426
- {
427
- name: 'list_user_chats',
428
- description: '[User UAT] List group chats the user is in. Note: only returns groups, not P2P. For P2P chats, use search_contacts → create_p2p_chat → read_p2p_messages. Requires OAuth setup.',
429
- inputSchema: {
430
- type: 'object',
431
- properties: {
432
- page_size: { type: 'number', description: 'Items per page (default 20)' },
433
- page_token: { type: 'string', description: 'Pagination token' },
434
- },
435
- },
436
- },
437
-
438
- // ========== IM — Official API (Bot Identity) ==========
439
- {
440
- name: 'list_chats',
441
- description: '[Official API] List all chats the bot has joined. Returns chat_id, name, type.',
442
- inputSchema: {
443
- type: 'object',
444
- properties: {
445
- page_size: { type: 'number', description: 'Items per page (default 20, max 100)' },
446
- page_token: { type: 'string', description: 'Pagination token' },
447
- },
448
- },
449
- },
450
- {
451
- name: 'read_messages',
452
- description: '[Official API + UAT fallback] Read message history from any group. Accepts oc_xxx ID, numeric ID, or chat name (auto-searched). Auto-falls back to UAT for external groups the bot cannot access. Returns newest messages first by default, with sender names resolved. Auto-expands merge_forward messages into their child messages (with original sender / time / content preserved) by default — disable with expand_merge_forward=false. Text messages have URLs extracted into `urls`; Feishu doc links are additionally surfaced as `feishuDocs` so agents can feed them straight into read_doc / get_doc_blocks.',
453
- inputSchema: {
454
- type: 'object',
455
- properties: {
456
- chat_id: { type: 'string', description: 'Chat ID (oc_xxx), numeric ID, or chat name (auto-searched via bot groups, im.chat.search, and user contacts)' },
457
- page_size: { type: 'number', description: 'Messages to fetch (default 20, max 50)' },
458
- start_time: { type: 'string', description: 'Start timestamp in seconds (optional)' },
459
- end_time: { type: 'string', description: 'End timestamp in seconds (optional)' },
460
- sort_type: { type: 'string', enum: ['ByCreateTimeDesc', 'ByCreateTimeAsc'], description: 'Sort order (default: ByCreateTimeDesc = newest first)' },
461
- expand_merge_forward: { type: 'boolean', description: 'Auto-expand merge_forward placeholders into their child messages (default true). Children carry parentMessageId; use that id (not the child id) with download_image / download_file.' },
462
- },
463
- required: ['chat_id'],
464
- },
465
- },
466
- {
467
- name: 'reply_message',
468
- description: '[Official API] Reply to a specific message by message_id (as bot). Only works for text messages; other types return error 230054.',
469
- inputSchema: {
470
- type: 'object',
471
- properties: {
472
- message_id: { type: 'string', description: 'Message ID to reply to (om_xxx)' },
473
- text: { type: 'string', description: 'Reply text' },
474
- },
475
- required: ['message_id', 'text'],
476
- },
477
- },
478
- {
479
- name: 'forward_message',
480
- description: '[Official API] Forward a message to another chat.',
481
- inputSchema: {
482
- type: 'object',
483
- properties: {
484
- message_id: { type: 'string', description: 'Message ID to forward' },
485
- receive_id: { type: 'string', description: 'Target chat_id or open_id' },
486
- },
487
- required: ['message_id', 'receive_id'],
488
- },
489
- },
490
-
491
- // ========== Docs — Official API ==========
492
- {
493
- name: 'search_docs',
494
- description: '[Official API] Search Feishu documents by keyword.',
495
- inputSchema: {
496
- type: 'object',
497
- properties: { query: { type: 'string', description: 'Search keyword' } },
498
- required: ['query'],
499
- },
500
- },
501
- {
502
- name: 'read_doc',
503
- description: '[Official API] Read the raw text content of a Feishu document.',
504
- inputSchema: {
505
- type: 'object',
506
- properties: { document_id: { type: 'string', description: 'Document ID or token' } },
507
- required: ['document_id'],
508
- },
509
- },
510
- {
511
- name: 'get_doc_blocks',
512
- description: '[Official API] Get structured block tree of a document. Returns block types, content, and hierarchy for precise document analysis.',
513
- inputSchema: {
514
- type: 'object',
515
- properties: {
516
- document_id: { type: 'string', description: 'Document ID (from search_docs or create_doc)' },
517
- },
518
- required: ['document_id'],
519
- },
520
- },
521
- {
522
- name: 'create_doc',
523
- description: '[Official API] Create a new Feishu document. Can place directly under a Wiki space by passing wiki_space_id (optionally wiki_parent_node_token for nested placement) — the plugin creates the doc in drive then attaches it as a Wiki node.',
524
- inputSchema: {
525
- type: 'object',
526
- properties: {
527
- title: { type: 'string', description: 'Document title' },
528
- folder_id: { type: 'string', description: 'Parent folder token (optional; ignored when wiki_space_id is set)' },
529
- wiki_space_id: { type: 'string', description: 'Wiki space ID to place the doc under (optional)' },
530
- wiki_parent_node_token: { type: 'string', description: 'Parent wiki node token within the space (optional; defaults to space root)' },
531
- },
532
- required: ['title'],
533
- },
534
- },
535
-
536
- // ========== Bitable — Official API ==========
537
- {
538
- name: 'create_bitable',
539
- description: '[Official API] Create a new Bitable (multi-dimensional table) app. Can place directly under a Wiki space via wiki_space_id (and optional wiki_parent_node_token).',
540
- inputSchema: {
541
- type: 'object',
542
- properties: {
543
- name: { type: 'string', description: 'Bitable app name' },
544
- folder_id: { type: 'string', description: 'Parent folder token (optional, defaults to root; ignored when wiki_space_id is set)' },
545
- wiki_space_id: { type: 'string', description: 'Wiki space ID to place the bitable under (optional)' },
546
- wiki_parent_node_token: { type: 'string', description: 'Parent wiki node token within the space (optional)' },
547
- },
548
- },
549
- },
550
- {
551
- name: 'list_bitable_tables',
552
- description: '[Official API] List all tables in a Bitable app.',
553
- inputSchema: {
554
- type: 'object',
555
- properties: { app_token: { type: 'string', description: 'Bitable app token' } },
556
- required: ['app_token'],
557
- },
558
- },
559
- {
560
- name: 'create_bitable_table',
561
- description: '[Official API] Create a new data table in a Bitable app. Optionally define initial fields.',
562
- inputSchema: {
563
- type: 'object',
564
- properties: {
565
- app_token: { type: 'string', description: 'Bitable app token' },
566
- name: { type: 'string', description: 'Table name' },
567
- fields: {
568
- type: 'array',
569
- description: 'Initial field definitions (optional). Each item: {field_name, type} where type is 1=Text, 2=Number, 3=SingleSelect, 4=MultiSelect, 5=DateTime, 7=Checkbox, 11=User, 13=Phone, 15=URL, 17=Attachment, 18=Link, 20=Formula, 21=DuplexLink, 22=Location, 23=GroupChat, 1001=CreateTime, 1002=ModifiedTime, 1003=Creator, 1004=Modifier',
570
- items: { type: 'object' },
571
- },
572
- },
573
- required: ['app_token', 'name'],
574
- },
575
- },
576
- {
577
- name: 'list_bitable_fields',
578
- description: '[Official API] List all fields (columns) in a Bitable table.',
579
- inputSchema: {
580
- type: 'object',
581
- properties: {
582
- app_token: { type: 'string', description: 'Bitable app token' },
583
- table_id: { type: 'string', description: 'Table ID' },
584
- },
585
- required: ['app_token', 'table_id'],
586
- },
587
- },
588
- {
589
- name: 'create_bitable_field',
590
- description: '[Official API] Create a new field (column) in a Bitable table.',
591
- inputSchema: {
592
- type: 'object',
593
- properties: {
594
- app_token: { type: 'string', description: 'Bitable app token' },
595
- table_id: { type: 'string', description: 'Table ID' },
596
- field_name: { type: 'string', description: 'Field display name' },
597
- type: { type: 'number', description: 'Field type: 1=Text, 2=Number, 3=SingleSelect, 4=MultiSelect, 5=DateTime, 7=Checkbox, 11=User, 13=Phone, 15=URL, 17=Attachment, 18=Link, 20=Formula, 21=DuplexLink, 22=Location, 23=GroupChat, 1001=CreateTime, 1002=ModifiedTime, 1003=Creator, 1004=Modifier' },
598
- property: { type: 'object', description: 'Field-type-specific properties (optional). E.g. for SingleSelect: {options: [{name:"A"},{name:"B"}]}' },
599
- },
600
- required: ['app_token', 'table_id', 'field_name', 'type'],
601
- },
602
- },
603
- {
604
- name: 'update_bitable_field',
605
- description: '[Official API] Update an existing field (column) in a Bitable table.',
606
- inputSchema: {
607
- type: 'object',
608
- properties: {
609
- app_token: { type: 'string', description: 'Bitable app token' },
610
- table_id: { type: 'string', description: 'Table ID' },
611
- field_id: { type: 'string', description: 'Field ID to update' },
612
- field_name: { type: 'string', description: 'New field name (optional)' },
613
- type: { type: 'number', description: 'Field type (REQUIRED by Feishu API, see create_bitable_field for values)' },
614
- property: { type: 'object', description: 'Field-type-specific properties (optional)' },
615
- },
616
- required: ['app_token', 'table_id', 'field_id', 'type'],
617
- },
618
- },
619
- {
620
- name: 'delete_bitable_field',
621
- description: '[Official API] Delete a field (column) from a Bitable table.',
622
- inputSchema: {
623
- type: 'object',
624
- properties: {
625
- app_token: { type: 'string', description: 'Bitable app token' },
626
- table_id: { type: 'string', description: 'Table ID' },
627
- field_id: { type: 'string', description: 'Field ID to delete' },
628
- },
629
- required: ['app_token', 'table_id', 'field_id'],
630
- },
631
- },
632
- {
633
- name: 'list_bitable_views',
634
- description: '[Official API] List all views in a Bitable table.',
635
- inputSchema: {
636
- type: 'object',
637
- properties: {
638
- app_token: { type: 'string', description: 'Bitable app token' },
639
- table_id: { type: 'string', description: 'Table ID' },
640
- },
641
- required: ['app_token', 'table_id'],
642
- },
643
- },
644
- {
645
- name: 'search_bitable_records',
646
- description: '[Official API] Search/query records in a Bitable table.',
647
- inputSchema: {
648
- type: 'object',
649
- properties: {
650
- app_token: { type: 'string', description: 'Bitable app token' },
651
- table_id: { type: 'string', description: 'Table ID' },
652
- filter: { type: 'object', description: 'Filter conditions (optional)' },
653
- sort: { type: 'array', description: 'Sort conditions (optional)' },
654
- page_size: { type: 'number', description: 'Results per page (default 20)' },
655
- },
656
- required: ['app_token', 'table_id'],
657
- },
658
- },
659
- {
660
- name: 'batch_create_bitable_records',
661
- description: '[Official API] Create one or more records (rows) in a Bitable table. Pass a single record or up to 500.',
662
- inputSchema: {
663
- type: 'object',
664
- properties: {
665
- app_token: { type: 'string', description: 'Bitable app token' },
666
- table_id: { type: 'string', description: 'Table ID' },
667
- records: { type: 'array', description: 'Array of {fields: {field_name: value}} objects', items: { type: 'object' } },
668
- },
669
- required: ['app_token', 'table_id', 'records'],
670
- },
671
- },
672
- {
673
- name: 'batch_update_bitable_records',
674
- description: '[Official API] Update one or more records in a Bitable table. Pass a single record or up to 500.',
675
- inputSchema: {
676
- type: 'object',
677
- properties: {
678
- app_token: { type: 'string', description: 'Bitable app token' },
679
- table_id: { type: 'string', description: 'Table ID' },
680
- records: { type: 'array', description: 'Array of {record_id, fields: {field_name: value}} objects', items: { type: 'object' } },
681
- },
682
- required: ['app_token', 'table_id', 'records'],
683
- },
684
- },
685
- {
686
- name: 'batch_delete_bitable_records',
687
- description: '[Official API] Delete one or more records from a Bitable table. Pass a single ID or up to 500.',
688
- inputSchema: {
689
- type: 'object',
690
- properties: {
691
- app_token: { type: 'string', description: 'Bitable app token' },
692
- table_id: { type: 'string', description: 'Table ID' },
693
- record_ids: { type: 'array', description: 'Array of record IDs to delete', items: { type: 'string' } },
694
- },
695
- required: ['app_token', 'table_id', 'record_ids'],
696
- },
697
- },
698
-
699
- // ========== Wiki — Official API ==========
700
- {
701
- name: 'list_wiki_spaces',
702
- description: '[Official API] List all accessible Wiki spaces.',
703
- inputSchema: { type: 'object', properties: {} },
704
- },
705
- {
706
- name: 'search_wiki',
707
- description: '[Official API] Search Wiki nodes by keyword.',
708
- inputSchema: {
709
- type: 'object',
710
- properties: { query: { type: 'string', description: 'Search keyword' } },
711
- required: ['query'],
712
- },
713
- },
714
- {
715
- name: 'list_wiki_nodes',
716
- description: '[Official API] List nodes in a Wiki space.',
717
- inputSchema: {
718
- type: 'object',
719
- properties: {
720
- space_id: { type: 'string', description: 'Wiki space ID' },
721
- parent_node_token: { type: 'string', description: 'Parent node token (optional)' },
722
- },
723
- required: ['space_id'],
724
- },
725
- },
726
-
727
- // ========== Drive — Official API ==========
728
- {
729
- name: 'list_files',
730
- description: '[Official API] List files in a Drive folder.',
731
- inputSchema: {
732
- type: 'object',
733
- properties: { folder_token: { type: 'string', description: 'Folder token (empty for root)' } },
734
- },
735
- },
736
- {
737
- name: 'create_folder',
738
- description: '[Official API] Create a new folder in Drive.',
739
- inputSchema: {
740
- type: 'object',
741
- properties: {
742
- name: { type: 'string', description: 'Folder name' },
743
- parent_token: { type: 'string', description: 'Parent folder token (optional)' },
744
- },
745
- required: ['name'],
746
- },
747
- },
748
-
749
- // ========== Upload — Official API ==========
750
- {
751
- name: 'upload_image',
752
- description: '[Official API] Upload an image file to Feishu. Returns image_key for use with send_image_as_user.',
753
- inputSchema: {
754
- type: 'object',
755
- properties: {
756
- image_path: { type: 'string', description: 'Absolute path to the image file on disk' },
757
- image_type: { type: 'string', enum: ['message', 'avatar'], description: 'Image usage type (default: message)' },
758
- },
759
- required: ['image_path'],
760
- },
761
- },
762
- {
763
- name: 'upload_file',
764
- description: '[Official API] Upload a file to Feishu. Returns file_key for use with send_file_as_user.',
765
- inputSchema: {
766
- type: 'object',
767
- properties: {
768
- file_path: { type: 'string', description: 'Absolute path to the file on disk' },
769
- file_type: { type: 'string', enum: ['opus', 'mp4', 'pdf', 'doc', 'xls', 'ppt', 'stream'], description: 'File type (default: stream for generic files)' },
770
- file_name: { type: 'string', description: 'Display file name (optional, defaults to basename)' },
771
- },
772
- required: ['file_path'],
773
- },
774
- },
775
- {
776
- name: 'upload_drive_file',
777
- description: '[Official API] Upload a file from disk to a Feishu Drive folder (drive/v1/files/upload_all, parent_type=explorer). Returns file_token + url. If wiki_space_id is provided, the uploaded file is then attached to that Wiki space via move_docs_to_wiki (obj_type=file). UAT-first with app fallback.',
778
- inputSchema: {
779
- type: 'object',
780
- properties: {
781
- file_path: { type: 'string', description: 'Absolute path to the file on disk' },
782
- folder_token: { type: 'string', description: 'Destination folder token. Use list_files to find one, or pass the user "我的空间" root token.' },
783
- wiki_space_id: { type: 'string', description: 'Optional. If set, also attach the uploaded file to this Wiki space.' },
784
- wiki_parent_node_token: { type: 'string', description: 'Optional. Parent node under which to attach in the Wiki space.' },
785
- },
786
- required: ['file_path', 'folder_token'],
787
- },
788
- },
789
- {
790
- name: 'upload_bitable_attachment',
791
- description: '[Official API] Upload a file as a Bitable attachment (drive/v1/medias/upload_all with parent_type=bitable_image or bitable_file). Returns file_token suitable for writing into a Bitable Attachment-type field via batch_create/update_bitable_records (the field value should be [{file_token}]).',
792
- inputSchema: {
793
- type: 'object',
794
- properties: {
795
- app_token: { type: 'string', description: 'Bitable app token (the bascn... or basc... id)' },
796
- file_path: { type: 'string', description: 'Absolute path to the file on disk' },
797
- kind: { type: 'string', enum: ['image', 'file'], description: 'Whether the attachment is an image (bitable_image) or a generic file (bitable_file). Default: file.' },
798
- },
799
- required: ['app_token', 'file_path'],
800
- },
801
- },
802
-
803
- // ========== Contact — Official API ==========
804
- {
805
- name: 'find_user',
806
- description: '[Official API] Find a Feishu user by email or mobile number.',
807
- inputSchema: {
808
- type: 'object',
809
- properties: {
810
- email: { type: 'string', description: 'User email (optional)' },
811
- mobile: { type: 'string', description: 'User mobile with country code like +86xxx (optional)' },
812
- },
813
- },
814
- },
815
-
816
- // ========== IM — Bot Send / Edit / Delete ==========
817
- {
818
- name: 'send_message_as_bot',
819
- description: '[Official API] Send a message as the bot to any chat. Supports text, post, interactive, etc. This is the reliable path for @-mentions: include `<at user_id="ou_xxx">Name</at>` inline in text content and Feishu resolves it to a real @-notification.',
820
- inputSchema: {
821
- type: 'object',
822
- properties: {
823
- chat_id: { type: 'string', description: 'Target chat_id (oc_xxx) or open_id' },
824
- msg_type: { type: 'string', description: 'Message type: text, post, image, interactive, etc.', enum: ['text', 'post', 'image', 'interactive', 'share_chat', 'share_user', 'audio', 'media', 'file', 'sticker'] },
825
- content: { description: 'Message content (string or object, auto-serialized). Plain text: {"text":"hello"}. Text with @-mention: {"text":"<at user_id=\\"ou_xxx\\">Alice</at> hi"} — the inline tag becomes a real @-notification.' },
826
- },
827
- required: ['chat_id', 'msg_type', 'content'],
828
- },
829
- },
830
- {
831
- name: 'send_card_as_user',
832
- description: '[v1.3.6: bot-routed default] Send an interactive card to a chat. **As of v1.3.6, identity defaults to BOT** because user-identity card sending requires reverse-engineering the Feishu web protobuf and is deferred to v1.3.7. The tool name keeps the "as_user" suffix so callers don\'t have to migrate when v1.3.7 lands; once user-identity is implemented the default flips. Pass `card` as a JSON object (Feishu card schema). To force bot explicitly set via="bot".',
833
- inputSchema: {
834
- type: 'object',
835
- properties: {
836
- chat_id: { type: 'string', description: 'Target chat_id (oc_xxx) or open_id' },
837
- card: { description: 'Feishu card JSON. See https://open.feishu.cn/cardkit for the schema; build cards visually then paste the resulting JSON here.' },
838
- via: { type: 'string', enum: ['bot', 'user'], description: 'Identity to send as. Default "bot". "user" returns an explicit not-yet-implemented error in v1.3.6.' },
839
- },
840
- required: ['chat_id', 'card'],
841
- },
842
- },
843
- {
844
- name: 'delete_message',
845
- description: '[Official API] Recall/delete a message (bot can only delete its own messages).',
846
- inputSchema: {
847
- type: 'object',
848
- properties: { message_id: { type: 'string', description: 'Message ID (om_xxx)' } },
849
- required: ['message_id'],
850
- },
851
- },
852
- {
853
- name: 'update_message',
854
- description: '[Official API] Edit a sent message (bot can only edit its own messages). Supports text and post.',
855
- inputSchema: {
856
- type: 'object',
857
- properties: {
858
- message_id: { type: 'string', description: 'Message ID (om_xxx)' },
859
- msg_type: { type: 'string', description: 'Message type: text or post' },
860
- content: { description: 'New content. For text: {"text":"updated text"}' },
861
- },
862
- required: ['message_id', 'msg_type', 'content'],
863
- },
864
- },
865
-
866
- // ========== IM — Reactions ==========
867
- {
868
- name: 'add_reaction',
869
- description: '[Official API] Add an emoji reaction to a message.',
870
- inputSchema: {
871
- type: 'object',
872
- properties: {
873
- message_id: { type: 'string', description: 'Message ID (om_xxx)' },
874
- emoji_type: { type: 'string', description: 'Emoji type string, e.g. "THUMBSUP", "SMILE", "HEART"' },
875
- },
876
- required: ['message_id', 'emoji_type'],
877
- },
878
- },
879
- {
880
- name: 'delete_reaction',
881
- description: '[Official API] Remove an emoji reaction from a message.',
882
- inputSchema: {
883
- type: 'object',
884
- properties: {
885
- message_id: { type: 'string', description: 'Message ID' },
886
- reaction_id: { type: 'string', description: 'Reaction ID (from add_reaction response)' },
887
- },
888
- required: ['message_id', 'reaction_id'],
889
- },
890
- },
891
-
892
- // ========== IM — Pin Messages ==========
893
- {
894
- name: 'pin_message',
895
- description: '[Official API] Pin or unpin a message in a chat.',
896
- inputSchema: {
897
- type: 'object',
898
- properties: {
899
- message_id: { type: 'string', description: 'Message ID' },
900
- pinned: { type: 'boolean', description: 'true to pin, false to unpin', default: true },
901
- },
902
- required: ['message_id'],
903
- },
904
- },
905
-
906
- // ========== IM — Chat Management ==========
907
- {
908
- name: 'create_group',
909
- description: '[Official API] Create a new group chat (as bot). Can add initial members.',
910
- inputSchema: {
911
- type: 'object',
912
- properties: {
913
- name: { type: 'string', description: 'Group name' },
914
- description: { type: 'string', description: 'Group description (optional)' },
915
- user_ids: { type: 'array', items: { type: 'string' }, description: 'Initial member open_ids (optional)' },
916
- },
917
- required: ['name'],
918
- },
919
- },
920
- {
921
- name: 'update_group',
922
- description: '[Official API] Update group chat name or description.',
923
- inputSchema: {
924
- type: 'object',
925
- properties: {
926
- chat_id: { type: 'string', description: 'Chat ID (oc_xxx)' },
927
- name: { type: 'string', description: 'New group name (optional)' },
928
- description: { type: 'string', description: 'New description (optional)' },
929
- },
930
- required: ['chat_id'],
931
- },
932
- },
933
- {
934
- name: 'list_members',
935
- description: '[Official API] List all members in a group chat.',
936
- inputSchema: {
937
- type: 'object',
938
- properties: {
939
- chat_id: { type: 'string', description: 'Chat ID (oc_xxx)' },
940
- page_size: { type: 'number', description: 'Items per page (default 50)' },
941
- page_token: { type: 'string', description: 'Pagination token' },
942
- },
943
- required: ['chat_id'],
944
- },
945
- },
946
- {
947
- name: 'manage_members',
948
- description: '[Official API] Add or remove members from a group chat.',
949
- inputSchema: {
950
- type: 'object',
951
- properties: {
952
- chat_id: { type: 'string', description: 'Group chat ID (oc_xxx)' },
953
- member_ids: { type: 'array', items: { type: 'string' }, description: 'Array of user open_ids' },
954
- action: { type: 'string', enum: ['add', 'remove'], description: 'Action to perform' },
955
- },
956
- required: ['chat_id', 'member_ids', 'action'],
957
- },
958
- },
959
-
960
- // ========== Docs — Block Editing ==========
961
- {
962
- name: 'create_doc_block',
963
- description: '[Official API] Insert content blocks into a document. Five modes:\n (A) Generic — pass `children` array (e.g. [{block_type:2, text:{...}}]) for text/heading/list/etc.\n (B) Image from local file — pass `image_path` (absolute path); the plugin creates an image block, uploads the file to drive, and patches the block with the token. Returns block_id + image_token.\n (C) Image from uploaded token — pass `image_token` to reuse an already-uploaded image.\n (D) File attachment from local file — pass `file_path`; the plugin creates a file block (block_type=23), uploads via parent_type=docx_file, and patches with replace_file.\n (E) File from uploaded token — pass `file_token` to reuse an already-uploaded file.\n`document_id` accepts native document_id, wiki node token, or Feishu URL.',
964
- inputSchema: {
965
- type: 'object',
966
- properties: {
967
- document_id: { type: 'string', description: 'Document ID, wiki node token, or Feishu URL' },
968
- parent_block_id: { type: 'string', description: 'Parent block ID (use document_id for root)' },
969
- children: { type: 'array', description: 'Generic block objects — mode A. E.g. [{block_type:2, text:{elements:[{text_run:{content:"Hello"}}]}}]', items: { type: 'object' } },
970
- image_path: { type: 'string', description: 'Local image path — mode B (mutually exclusive with other modes)' },
971
- image_token: { type: 'string', description: 'Pre-uploaded docx image token — mode C (mutually exclusive with other modes)' },
972
- file_path: { type: 'string', description: 'Local file path for an attachment block — mode D (mutually exclusive with other modes)' },
973
- file_token: { type: 'string', description: 'Pre-uploaded docx file token — mode E (mutually exclusive with other modes)' },
974
- index: { type: 'number', description: 'Insert position (optional, appends to end if omitted)' },
975
- },
976
- required: ['document_id', 'parent_block_id'],
977
- },
978
- },
979
- {
980
- name: 'update_doc_block',
981
- description: '[Official API] Update a specific block in a document. Generic mode: pass update_body. Image-replace mode: pass image_token to swap the picture in an existing image block. File-replace mode: pass file_token to swap an existing file block. document_id accepts native ID, wiki node token, or Feishu URL.',
982
- inputSchema: {
983
- type: 'object',
984
- properties: {
985
- document_id: { type: 'string', description: 'Document ID, wiki node token, or Feishu URL' },
986
- block_id: { type: 'string', description: 'Block ID to update' },
987
- update_body: { type: 'object', description: 'Generic update payload. E.g. {update_text_elements:{elements:[{text_run:{content:"new text"}}]}}' },
988
- image_token: { type: 'string', description: 'Pre-uploaded image token — if provided, update_body is ignored and the block is patched with {replace_image:{token}}' },
989
- file_token: { type: 'string', description: 'Pre-uploaded file token — patches the block with {replace_file:{token}}' },
990
- },
991
- required: ['document_id', 'block_id'],
992
- },
993
- },
994
- {
995
- name: 'delete_doc_blocks',
996
- description: '[Official API] Delete a range of blocks from a document.',
997
- inputSchema: {
998
- type: 'object',
999
- properties: {
1000
- document_id: { type: 'string', description: 'Document ID' },
1001
- parent_block_id: { type: 'string', description: 'Parent block ID containing the blocks to delete' },
1002
- start_index: { type: 'number', description: 'Start index (inclusive)' },
1003
- end_index: { type: 'number', description: 'End index (exclusive)' },
1004
- },
1005
- required: ['document_id', 'parent_block_id', 'start_index', 'end_index'],
1006
- },
1007
- },
1008
-
1009
- // ========== Bitable — Additional ==========
1010
- {
1011
- name: 'get_bitable_record',
1012
- description: '[Official API] Get a single record by ID from a Bitable table.',
1013
- inputSchema: {
1014
- type: 'object',
1015
- properties: {
1016
- app_token: { type: 'string', description: 'Bitable app token' },
1017
- table_id: { type: 'string', description: 'Table ID' },
1018
- record_id: { type: 'string', description: 'Record ID' },
1019
- },
1020
- required: ['app_token', 'table_id', 'record_id'],
1021
- },
1022
- },
1023
- {
1024
- name: 'delete_bitable_table',
1025
- description: '[Official API] Delete a data table from a Bitable app.',
1026
- inputSchema: {
1027
- type: 'object',
1028
- properties: {
1029
- app_token: { type: 'string', description: 'Bitable app token' },
1030
- table_id: { type: 'string', description: 'Table ID to delete' },
1031
- },
1032
- required: ['app_token', 'table_id'],
1033
- },
1034
- },
1035
-
1036
- {
1037
- name: 'get_bitable_meta',
1038
- description: '[Official API] Get metadata of a Bitable app (name, revision, etc.).',
1039
- inputSchema: {
1040
- type: 'object',
1041
- properties: {
1042
- app_token: { type: 'string', description: 'Bitable app token' },
1043
- },
1044
- required: ['app_token'],
1045
- },
1046
- },
1047
- {
1048
- name: 'update_bitable_table',
1049
- description: '[Official API] Rename a data table in a Bitable app.',
1050
- inputSchema: {
1051
- type: 'object',
1052
- properties: {
1053
- app_token: { type: 'string', description: 'Bitable app token' },
1054
- table_id: { type: 'string', description: 'Table ID' },
1055
- name: { type: 'string', description: 'New table name' },
1056
- },
1057
- required: ['app_token', 'table_id', 'name'],
1058
- },
1059
- },
1060
- {
1061
- name: 'create_bitable_view',
1062
- description: '[Official API] Create a new view in a Bitable table.',
1063
- inputSchema: {
1064
- type: 'object',
1065
- properties: {
1066
- app_token: { type: 'string', description: 'Bitable app token' },
1067
- table_id: { type: 'string', description: 'Table ID' },
1068
- view_name: { type: 'string', description: 'View name' },
1069
- view_type: { type: 'string', description: 'View type: grid (default), kanban, gallery, form, gantt, calendar', default: 'grid' },
1070
- },
1071
- required: ['app_token', 'table_id', 'view_name'],
1072
- },
1073
- },
1074
- {
1075
- name: 'delete_bitable_view',
1076
- description: '[Official API] Delete a view from a Bitable table.',
1077
- inputSchema: {
1078
- type: 'object',
1079
- properties: {
1080
- app_token: { type: 'string', description: 'Bitable app token' },
1081
- table_id: { type: 'string', description: 'Table ID' },
1082
- view_id: { type: 'string', description: 'View ID to delete' },
1083
- },
1084
- required: ['app_token', 'table_id', 'view_id'],
1085
- },
1086
- },
1087
- {
1088
- name: 'copy_bitable',
1089
- description: '[Official API] Copy a Bitable app to create a new one.',
1090
- inputSchema: {
1091
- type: 'object',
1092
- properties: {
1093
- app_token: { type: 'string', description: 'Bitable app token to copy' },
1094
- name: { type: 'string', description: 'New Bitable name' },
1095
- folder_id: { type: 'string', description: 'Destination folder token (optional)' },
1096
- },
1097
- required: ['app_token', 'name'],
1098
- },
1099
- },
1100
-
1101
- // ========== Drive — File Operations ==========
1102
- {
1103
- name: 'copy_file',
1104
- description: '[Official API] Copy a file/doc in Drive.',
1105
- inputSchema: {
1106
- type: 'object',
1107
- properties: {
1108
- file_token: { type: 'string', description: 'File token to copy' },
1109
- name: { type: 'string', description: 'New file name' },
1110
- folder_token: { type: 'string', description: 'Destination folder token (optional)' },
1111
- type: { type: 'string', description: 'File type: file, doc, sheet, bitable, docx, mindnote, slides (optional)' },
1112
- },
1113
- required: ['file_token', 'name'],
1114
- },
1115
- },
1116
- {
1117
- name: 'move_file',
1118
- description: '[Official API] Move a file to another folder in Drive.',
1119
- inputSchema: {
1120
- type: 'object',
1121
- properties: {
1122
- file_token: { type: 'string', description: 'File token to move' },
1123
- folder_token: { type: 'string', description: 'Destination folder token' },
1124
- },
1125
- required: ['file_token', 'folder_token'],
1126
- },
1127
- },
1128
- {
1129
- name: 'delete_file',
1130
- description: '[Official API] Delete a file/folder from Drive.',
1131
- inputSchema: {
1132
- type: 'object',
1133
- properties: {
1134
- file_token: { type: 'string', description: 'File token to delete' },
1135
- type: { type: 'string', description: 'Type: file, folder, doc, sheet, bitable, docx, mindnote, slides' },
1136
- },
1137
- required: ['file_token'],
1138
- },
1139
- },
1140
-
1141
- // ========== Message Resources (Image/File Download) ==========
1142
- {
1143
- name: 'download_image',
1144
- description: '[User Identity / Official API] Download an image so the model can actually see it. Two modes: (1) message image — pass message_id + image_key from read_messages / read_p2p_messages. (2) docx image — pass doc_token + image_token (the block.image.token from get_doc_blocks). doc_token accepts native document_id, wiki node token, or Feishu URL. Tries user identity first, falls back to app. NOTE: for merge_forward children, pass the child\'s `parentMessageId` (NOT the child message id) — Feishu keys media by the parent merge_forward id.',
1145
- inputSchema: {
1146
- type: 'object',
1147
- properties: {
1148
- message_id: { type: 'string', description: 'Message ID (om_xxx) — for mode 1 only. For merge_forward children use the parent merge_forward message id.' },
1149
- image_key: { type: 'string', description: 'Image key (img_xxx) from message content — for mode 1 only' },
1150
- doc_token: { type: 'string', description: 'Document ID, wiki node token, or Feishu URL — for mode 2 only' },
1151
- image_token: { type: 'string', description: 'Image token from a docx image block (block.image.token via get_doc_blocks) — for mode 2 only' },
1152
- },
1153
- },
1154
- },
1155
- {
1156
- name: 'download_file',
1157
- description: '[User Identity / Official API] Download a file attached to a message (msg_type=file). Returns base64 bytes + mimeType + filename. Tries user identity first, falls back to app. For merge_forward children, pass the child\'s `parentMessageId` (NOT the child message id) — Feishu keys media by the parent merge_forward id.',
1158
- inputSchema: {
1159
- type: 'object',
1160
- properties: {
1161
- message_id: { type: 'string', description: 'Message ID (om_xxx). For merge_forward children use the parent merge_forward message id.' },
1162
- file_key: { type: 'string', description: 'File key from message content (content.file_key for msg_type=file)' },
1163
- save_path: { type: 'string', description: 'Optional absolute local path to save the file to. If omitted, file is only returned as inline base64 in the response.' },
1164
- },
1165
- required: ['message_id', 'file_key'],
1166
- },
1167
- },
1168
-
1169
- // ========== Wiki Node — Object Resolution (v1.3.4) ==========
1170
- {
1171
- name: 'get_wiki_node',
1172
- description: '[Official API] Resolve a Wiki node token to its underlying object (docx / bitable / sheet / mindnote / file). Returns obj_type + obj_token + space_id so you can read/write the real resource via the usual docx / bitable tools. Accepts bare wiki node token (wikcnXXX) or a full Feishu /wiki/ URL.',
1173
- inputSchema: {
1174
- type: 'object',
1175
- properties: {
1176
- node_token: { type: 'string', description: 'Wiki node token (wikcnXXX / wikmXXX / wiknXXX) or full Feishu /wiki/<token> URL' },
1177
- },
1178
- required: ['node_token'],
1179
- },
1180
- },
1181
-
1182
- // ========== OKR — Official API (v1.3.4) ==========
1183
- {
1184
- name: 'list_user_okrs',
1185
- description: '[Official API + UAT] List a user\'s OKRs. Requires the user\'s open_id (get yours via get_login_status or search_contacts). Filter by period_ids to narrow to a specific quarter.',
1186
- inputSchema: {
1187
- type: 'object',
1188
- properties: {
1189
- user_id: { type: 'string', description: 'Target user\'s open_id (or the matching user_id_type)' },
1190
- user_id_type: { type: 'string', enum: ['user_id', 'union_id', 'open_id', 'people_admin_id'], description: 'Type of user_id (default: open_id)' },
1191
- period_ids: { type: 'array', items: { type: 'string' }, description: 'Filter by OKR period IDs (optional). Get period IDs via list_okr_periods.' },
1192
- offset: { type: 'number', description: 'Pagination offset (default 0)' },
1193
- limit: { type: 'number', description: 'Items per page (default 10, max 10)' },
1194
- lang: { type: 'string', description: 'Response language (optional, e.g. "zh_cn", "en_us")' },
1195
- },
1196
- required: ['user_id'],
1197
- },
1198
- },
1199
- {
1200
- name: 'get_okrs',
1201
- description: '[Official API + UAT] Batch-fetch full OKR details (objectives, key results, progress, alignments) by OKR IDs.',
1202
- inputSchema: {
1203
- type: 'object',
1204
- properties: {
1205
- okr_ids: { type: 'array', items: { type: 'string' }, description: 'OKR IDs (max 10 per call). From list_user_okrs.' },
1206
- user_id_type: { type: 'string', enum: ['user_id', 'union_id', 'open_id', 'people_admin_id'], description: 'Type of user_ids in response (default: open_id)' },
1207
- lang: { type: 'string', description: 'Response language (optional)' },
1208
- },
1209
- required: ['okr_ids'],
1210
- },
1211
- },
1212
- {
1213
- name: 'list_okr_periods',
1214
- description: '[Official API + UAT] List OKR periods (quarters / years) defined in the tenant. Use period_ids from this to filter list_user_okrs.',
1215
- inputSchema: {
1216
- type: 'object',
1217
- properties: {
1218
- page_size: { type: 'number', description: 'Items per page (default 10)' },
1219
- page_token: { type: 'string', description: 'Pagination token' },
1220
- },
1221
- },
1222
- },
1223
-
1224
- // ========== Calendar — Official API (v1.3.4) ==========
1225
- {
1226
- name: 'list_calendars',
1227
- description: '[Official API + UAT] List the current user\'s calendars (primary + shared + subscribed). Requires UAT — app identity only sees calendars it was explicitly invited to. Requires `calendar:calendar:readonly` scope on the OAuth.',
1228
- inputSchema: {
1229
- type: 'object',
1230
- properties: {
1231
- page_size: { type: 'number', description: 'Items per page (min 50, default 50). Feishu\'s calendar endpoint rejects page_size < 50.' },
1232
- page_token: { type: 'string', description: 'Pagination token' },
1233
- sync_token: { type: 'string', description: 'Incremental sync token (optional)' },
1234
- },
1235
- },
1236
- },
1237
- {
1238
- name: 'list_calendar_events',
1239
- description: '[Official API + UAT] List events in a calendar within an optional time range. Typical usage: first list_calendars to find calendar_id (primary calendar has type="primary"), then list events in e.g. [now, now+7d] (Unix seconds).',
1240
- inputSchema: {
1241
- type: 'object',
1242
- properties: {
1243
- calendar_id: { type: 'string', description: 'Calendar ID from list_calendars' },
1244
- start_time: { type: 'string', description: 'Range start (Unix seconds, optional)' },
1245
- end_time: { type: 'string', description: 'Range end (Unix seconds, optional)' },
1246
- page_size: { type: 'number', description: 'Items per page (default 50)' },
1247
- page_token: { type: 'string', description: 'Pagination token' },
1248
- sync_token: { type: 'string', description: 'Incremental sync token (optional)' },
1249
- },
1250
- required: ['calendar_id'],
1251
- },
1252
- },
1253
- {
1254
- name: 'get_calendar_event',
1255
- description: '[Official API + UAT] Get full details of a single calendar event (summary, description, start/end, attendees, location, attachments, meeting link).',
1256
- inputSchema: {
1257
- type: 'object',
1258
- properties: {
1259
- calendar_id: { type: 'string', description: 'Calendar ID' },
1260
- event_id: { type: 'string', description: 'Event ID from list_calendar_events' },
1261
- },
1262
- required: ['calendar_id', 'event_id'],
1263
- },
1264
- },
1265
-
1266
- ];
1267
-
1268
- // --- Server ---
1269
-
1270
- const server = new Server(
1271
- { name: 'feishu-user-plugin', version: require('../package.json').version },
1272
- { capabilities: { tools: {} } }
1273
- );
1274
-
1275
- server.setRequestHandler(ListToolsRequestSchema, async () => ({ tools: TOOLS }));
1276
-
1277
- server.setRequestHandler(CallToolRequestSchema, async (request) => {
1278
- const { name, arguments: args } = request.params;
1279
- try {
1280
- return await handleTool(name, args || {});
1281
- } catch (err) {
1282
- return { content: [{ type: 'text', text: `Error: ${err.message}` }], isError: true };
1283
- }
2
+ require('./logger'); // installs global stdout guard MUST be first
3
+ require('./server').main().catch((err) => {
4
+ console.error('Fatal:', err);
5
+ process.exit(1);
1284
6
  });
1285
-
1286
- const text = (s) => ({ content: [{ type: 'text', text: s }] });
1287
- const json = (o) => {
1288
- // If the underlying method surfaced a fallback warning (UAT unavailable,
1289
- // resource owned by bot), lift it to the top of the response so the human /
1290
- // agent sees it *before* the structured body. Keeps the JSON payload intact.
1291
- const warn = o && typeof o === 'object' && o.fallbackWarning ? `${o.fallbackWarning}\n\n` : '';
1292
- return text(warn + JSON.stringify(o, null, 2));
1293
- };
1294
- const sendResult = (r, desc) => text(r.success ? desc : `Send failed (status: ${r.status})`);
1295
-
1296
- // Resolver helper: turn document_id / app_token / wiki node / Feishu URL into
1297
- // a native token. No-op for already-native inputs. See src/resolver.js.
1298
- async function resolveDocId(input) {
1299
- if (!input) return input;
1300
- return resolveToken(input, getOfficialClient());
1301
- }
1302
-
1303
- async function handleTool(name, args) {
1304
-
1305
- switch (name) {
1306
- // --- Profile management (v1.3.6) ---
1307
-
1308
- case 'list_profiles': {
1309
- const profiles = loadProfileMap();
1310
- const all = ['default', ...Object.keys(profiles)];
1311
- return json({ active: currentProfile, profiles: all });
1312
- }
1313
- case 'switch_profile': {
1314
- const target = args.name;
1315
- const profiles = loadProfileMap();
1316
- const all = ['default', ...Object.keys(profiles)];
1317
- if (!all.includes(target)) return text(`Profile "${target}" not found. Available: ${all.join(', ')}. To add more, set LARK_PROFILES_JSON in your MCP env.`);
1318
- currentProfile = target;
1319
- // Invalidate cached client instances so the next call uses the new creds
1320
- userClient = null;
1321
- officialClient = null;
1322
- return text(`Switched to profile: ${target}`);
1323
- }
1324
-
1325
- // --- User Identity: Text Messaging ---
1326
-
1327
- case 'send_as_user': {
1328
- const c = await getUserClient();
1329
- const r = await c.sendMessage(args.chat_id, args.text, { rootId: args.root_id, parentId: args.parent_id, ats: args.ats });
1330
- return sendResult(r, `Text sent as user to ${args.chat_id}`);
1331
- }
1332
- case 'send_to_user': {
1333
- const c = await getUserClient();
1334
- const results = await c.search(args.user_name);
1335
- const users = results.filter(r => r.type === 'user');
1336
- if (users.length === 0) return text(`User "${args.user_name}" not found. Results: ${JSON.stringify(results)}`);
1337
- if (users.length > 1) {
1338
- const candidates = users.slice(0, 5).map(u => ` - ${u.title} (ID: ${u.id})`).join('\n');
1339
- return text(`Multiple users match "${args.user_name}":\n${candidates}\nUse search_contacts to find the exact user, then create_p2p_chat + send_as_user.`);
1340
- }
1341
- const user = users[0];
1342
- const chatId = await c.createChat(user.id);
1343
- if (!chatId) return text(`Failed to create chat with ${user.title}`);
1344
- const r = await c.sendMessage(chatId, args.text, { ats: args.ats });
1345
- return sendResult(r, `Text sent to ${user.title} (chat: ${chatId})`);
1346
- }
1347
- case 'send_to_group': {
1348
- const c = await getUserClient();
1349
- const results = await c.search(args.group_name);
1350
- const groups = results.filter(r => r.type === 'group');
1351
- if (groups.length === 0) return text(`Group "${args.group_name}" not found. Results: ${JSON.stringify(results)}`);
1352
- if (groups.length > 1) {
1353
- const candidates = groups.slice(0, 5).map(g => ` - ${g.title} (ID: ${g.id})`).join('\n');
1354
- return text(`Multiple groups match "${args.group_name}":\n${candidates}\nUse search_contacts to find the exact group, then send_as_user with the ID.`);
1355
- }
1356
- const group = groups[0];
1357
- const r = await c.sendMessage(group.id, args.text, { ats: args.ats });
1358
- return sendResult(r, `Text sent to group "${group.title}" (${group.id})`);
1359
- }
1360
- case 'batch_send': {
1361
- if (!Array.isArray(args.targets) || args.targets.length === 0) return text('batch_send: targets must be a non-empty array');
1362
- const delay = typeof args.delay_ms === 'number' ? args.delay_ms : 200;
1363
- const userClient = await getUserClient();
1364
- const officialClient = getOfficialClient();
1365
- const results = [];
1366
- for (let i = 0; i < args.targets.length; i++) {
1367
- const t = args.targets[i];
1368
- try {
1369
- if (!t.content || !t.content.kind) throw new Error('content.kind is required');
1370
- // Resolve chat id from name when applicable
1371
- let chatId = t.id;
1372
- if (t.type === 'user' || t.type === 'group') {
1373
- const matches = await userClient.search(t.id);
1374
- const want = matches.filter(m => m.type === t.type);
1375
- if (want.length === 0) throw new Error(`No ${t.type} matches "${t.id}"`);
1376
- if (want.length > 1) throw new Error(`Ambiguous ${t.type} "${t.id}" (${want.length} matches). Use type="chat" with explicit chat_id.`);
1377
- const picked = want[0];
1378
- chatId = t.type === 'user' ? await userClient.createChat(picked.id) : picked.id;
1379
- if (!chatId) throw new Error(`Could not resolve chat for ${t.type} ${picked.title}`);
1380
- }
1381
- let r;
1382
- if (t.via === 'bot') {
1383
- const c = t.content;
1384
- const payload = c.kind === 'text' ? { text: c.text }
1385
- : c.kind === 'post' ? { post: { zh_cn: { title: c.title || '', content: c.paragraphs || [] } } }
1386
- : c.kind === 'image' ? { image_key: c.image_key }
1387
- : c.kind === 'interactive' ? c.card
1388
- : null;
1389
- if (!payload) throw new Error(`bot path does not support content.kind=${c.kind}`);
1390
- const msgType = c.kind === 'interactive' ? 'interactive' : c.kind;
1391
- r = await officialClient.sendMessageAsBot(chatId, msgType, payload);
1392
- results.push({ ok: true, target: t, messageId: r.messageId, via: 'bot' });
1393
- } else {
1394
- const c = t.content;
1395
- if (c.kind === 'text') r = await userClient.sendMessage(chatId, c.text, { ats: c.ats });
1396
- else if (c.kind === 'image') r = await userClient.sendImage(chatId, c.image_key);
1397
- else if (c.kind === 'file') r = await userClient.sendFile(chatId, c.file_key, c.file_name);
1398
- else if (c.kind === 'post') r = await userClient.sendPost(chatId, c.title, c.paragraphs);
1399
- else throw new Error(`unknown content.kind=${c.kind}`);
1400
- results.push({ ok: true, target: t, messageId: r.messageId, via: 'user' });
1401
- }
1402
- } catch (e) {
1403
- results.push({ ok: false, target: t, error: e.message });
1404
- }
1405
- if (i < args.targets.length - 1 && delay > 0) await new Promise(r => setTimeout(r, delay));
1406
- }
1407
- const okCount = results.filter(r => r.ok).length;
1408
- return json({ summary: `${okCount}/${results.length} sent`, results });
1409
- }
1410
-
1411
- // --- User Identity: Rich Message Types ---
1412
-
1413
- case 'send_image_as_user': {
1414
- const c = await getUserClient();
1415
- const r = await c.sendImage(args.chat_id, args.image_key, { rootId: args.root_id });
1416
- return sendResult(r, `Image sent to ${args.chat_id}`);
1417
- }
1418
- case 'send_file_as_user': {
1419
- const c = await getUserClient();
1420
- const r = await c.sendFile(args.chat_id, args.file_key, args.file_name, { rootId: args.root_id });
1421
- return sendResult(r, `File "${args.file_name}" sent to ${args.chat_id}`);
1422
- }
1423
- case 'send_sticker_as_user': {
1424
- const c = await getUserClient();
1425
- const r = await c.sendSticker(args.chat_id, args.sticker_id, args.sticker_set_id);
1426
- return sendResult(r, `Sticker sent to ${args.chat_id}`);
1427
- }
1428
- case 'send_post_as_user': {
1429
- const c = await getUserClient();
1430
- const r = await c.sendPost(args.chat_id, args.title || '', args.paragraphs, { rootId: args.root_id });
1431
- return sendResult(r, `Post sent to ${args.chat_id}`);
1432
- }
1433
- case 'send_audio_as_user': {
1434
- const c = await getUserClient();
1435
- const r = await c.sendAudio(args.chat_id, args.audio_key);
1436
- return sendResult(r, `Audio sent to ${args.chat_id}`);
1437
- }
1438
-
1439
- // --- User Identity: Contacts & Info ---
1440
-
1441
- case 'search_contacts': {
1442
- const c = await getUserClient();
1443
- return json(await c.search(args.query));
1444
- }
1445
- case 'create_p2p_chat': {
1446
- const c = await getUserClient();
1447
- const chatId = await c.createChat(args.user_id);
1448
- return text(chatId ? `P2P chat: ${chatId}` : 'Failed to create P2P chat');
1449
- }
1450
- case 'get_chat_info': {
1451
- // Strategy 1: Official API im.chat.get (supports oc_xxx format)
1452
- if (args.chat_id.startsWith('oc_')) {
1453
- try {
1454
- const info = await getOfficialClient().getChatInfo(args.chat_id);
1455
- return info ? json(info) : text(`No info for chat ${args.chat_id}`);
1456
- } catch (e) {
1457
- console.error(`[feishu-user-plugin] Official getChatInfo failed: ${e.message}`);
1458
- }
1459
- }
1460
- // Strategy 2: Protobuf gateway (supports numeric chat_id)
1461
- try {
1462
- const c = await getUserClient();
1463
- const info = await c.getGroupInfo(args.chat_id);
1464
- if (info) return json(info);
1465
- } catch (e) {
1466
- console.error(`[feishu-user-plugin] Protobuf getChatInfo failed: ${e.message}`);
1467
- }
1468
- return text(`No info for chat ${args.chat_id}`);
1469
- }
1470
- case 'get_user_info': {
1471
- let n = null;
1472
- // Strategy 1: Official API contact lookup (works for same-tenant users by open_id)
1473
- try {
1474
- const official = getOfficialClient();
1475
- n = await official.getUserById(args.user_id, 'open_id');
1476
- } catch {}
1477
- // Strategy 2: User identity client cache (populated by previous search/init calls)
1478
- if (!n) {
1479
- try {
1480
- const c = await getUserClient();
1481
- n = await c.getUserName(args.user_id);
1482
- } catch {}
1483
- }
1484
- return text(n ? `User ${args.user_id}: ${n}` : `Could not resolve user ${args.user_id}. This user may be from an external tenant. Try search_contacts with the user's display name instead.`);
1485
- }
1486
- case 'get_login_status': {
1487
- const parts = [];
1488
- try {
1489
- const c = await getUserClient();
1490
- const status = await c.checkSession();
1491
- parts.push(`Cookie: ${status.valid ? 'Active' : 'Expired'} (${status.userName || status.userId || 'unknown'})`);
1492
- parts.push(` ${status.message}`);
1493
- } catch (e) { parts.push(`Cookie: ${e.message}`); }
1494
- const hasApp = !!(process.env.LARK_APP_ID && process.env.LARK_APP_SECRET);
1495
- if (!hasApp) {
1496
- parts.push(`App credentials: Not set`);
1497
- } else {
1498
- const official = getOfficialClient();
1499
- const probe = await official.verifyApp();
1500
- if (probe.valid) {
1501
- const nameBit = probe.appName ? ` "${probe.appName}"` : '';
1502
- parts.push(`App credentials: Valid — app_id=${probe.appId}${nameBit}`);
1503
- } else {
1504
- parts.push(`App credentials: INVALID — app_id=${probe.appId} rejected by Feishu (${probe.error})`);
1505
- parts.push(` → Likely wrong/stale APP_ID. Re-run the install prompt from team-skills/plugins/feishu-user-plugin/README.md to get the correct credentials.`);
1506
- }
1507
- if (official.hasUAT) {
1508
- try {
1509
- await official.listChatsAsUser({ pageSize: 1 });
1510
- parts.push('User access token: Valid (P2P/group UAT reading enabled)');
1511
- } catch (e) {
1512
- parts.push(`User access token: INVALID — ${e.message}`);
1513
- parts.push(' → Re-run OAuth: npx feishu-user-plugin oauth, then restart Claude Code / Codex so running MCP servers load the new token.');
1514
- }
1515
- } else {
1516
- parts.push('User access token: Not set (optional — needed for P2P chat reading. Run OAuth flow to obtain, see README for details)');
1517
- }
1518
- }
1519
- return text(parts.join('\n'));
1520
- }
1521
-
1522
- // --- User UAT: IM ---
1523
-
1524
- case 'read_p2p_messages': {
1525
- const official = getOfficialClient();
1526
- let chatId = args.chat_id;
1527
- let uc = null;
1528
- let ucError = null;
1529
- try { uc = await getUserClient(); } catch (e) { ucError = e; }
1530
- // If chat_id is not numeric or oc_, try to resolve as user name → P2P chat
1531
- if (!/^\d+$/.test(chatId) && !chatId.startsWith('oc_')) {
1532
- if (uc) {
1533
- const results = await uc.search(chatId);
1534
- const user = results.find(r => r.type === 'user');
1535
- if (user) {
1536
- const pChatId = await uc.createChat(String(user.id));
1537
- if (pChatId) chatId = String(pChatId);
1538
- else return text(`Found user "${user.title}" but failed to create P2P chat.`);
1539
- } else {
1540
- // Maybe it's a group name
1541
- const group = results.find(r => r.type === 'group');
1542
- if (group) chatId = String(group.id);
1543
- else return text(`Cannot resolve "${args.chat_id}" to a chat. Use search_contacts to find the ID first.`);
1544
- }
1545
- } else {
1546
- const hint = ucError ? `Cookie auth failed: ${ucError.message}. Fix LARK_COOKIE first, or p` : 'P';
1547
- return text(`"${args.chat_id}" is not a valid chat ID. ${hint}rovide a numeric ID or oc_xxx format. Use search_contacts + create_p2p_chat to get the ID.`);
1548
- }
1549
- }
1550
- return json(await official.readMessagesAsUser(chatId, {
1551
- pageSize: args.page_size, startTime: args.start_time, endTime: args.end_time,
1552
- sortType: args.sort_type,
1553
- expandMergeForward: args.expand_merge_forward !== false,
1554
- }, uc));
1555
- }
1556
- case 'list_user_chats':
1557
- return json(await getOfficialClient().listChatsAsUser({ pageSize: args.page_size, pageToken: args.page_token }));
1558
-
1559
- // --- Official API: IM ---
1560
-
1561
- case 'list_chats':
1562
- return json(await getOfficialClient().listChats({ pageSize: args.page_size, pageToken: args.page_token }));
1563
- case 'read_messages': {
1564
- const official = getOfficialClient();
1565
- const msgOpts = {
1566
- pageSize: args.page_size, startTime: args.start_time, endTime: args.end_time,
1567
- sortType: args.sort_type,
1568
- expandMergeForward: args.expand_merge_forward !== false,
1569
- };
1570
- // Get userClient for name resolution fallback (best-effort)
1571
- let uc = null;
1572
- try { uc = await getUserClient(); } catch (_) {}
1573
-
1574
- // Path A — chat_id that resolves inside bot's / official search scope.
1575
- const resolvedChatId = await chatIdMapper.resolveToOcId(args.chat_id, official);
1576
- if (resolvedChatId) {
1577
- return json(await official.readMessagesWithFallback(resolvedChatId, msgOpts, uc));
1578
- }
1579
-
1580
- // Path B — external group discovered only via cookie search_contacts.
1581
- // When we got here the bot definitely can't see it, so skip bot entirely
1582
- // and go straight to UAT with a `contacts` via label.
1583
- if (official.hasUAT) {
1584
- if (!uc) try { uc = await getUserClient(); } catch (_) {}
1585
- const contactChatId = await chatIdMapper.resolveViaContacts(args.chat_id, uc);
1586
- if (contactChatId) {
1587
- return json(await official.readMessagesWithFallback(contactChatId, msgOpts, uc, { skipBot: true, via: 'contacts' }));
1588
- }
1589
- }
1590
-
1591
- return text(`Cannot resolve "${args.chat_id}" to a chat ID.\nSearched: bot's group list, im.chat.search API, and user contacts (search_contacts).\nTry: provide the oc_xxx or numeric chat ID directly.`);
1592
- }
1593
- case 'reply_message':
1594
- return text(`Reply sent: ${(await getOfficialClient().replyMessage(args.message_id, args.text)).messageId}`);
1595
- case 'forward_message':
1596
- return text(`Forwarded: ${(await getOfficialClient().forwardMessage(args.message_id, args.receive_id)).messageId}`);
1597
-
1598
- // --- Official API: Docs ---
1599
-
1600
- case 'search_docs':
1601
- return json(await getOfficialClient().searchDocs(args.query));
1602
- case 'read_doc':
1603
- return json(await getOfficialClient().readDoc(await resolveDocId(args.document_id)));
1604
- case 'get_doc_blocks':
1605
- return json(await getOfficialClient().getDocBlocks(await resolveDocId(args.document_id)));
1606
- case 'create_doc': {
1607
- const r = await getOfficialClient().createDoc(args.title, args.folder_id, {
1608
- wikiSpaceId: args.wiki_space_id,
1609
- wikiParentNodeToken: args.wiki_parent_node_token,
1610
- });
1611
- const ownership = r.viaUser ? ' (as user)' : ' (as app — UAT unavailable or failed; document owned by the app, not you)';
1612
- const wikiNote = r.wikiNodeToken ? ` [wiki node: ${r.wikiNodeToken}]`
1613
- : r.wikiAttachTaskId ? ` [wiki attach queued — task_id: ${r.wikiAttachTaskId}]`
1614
- : r.wikiAttachError ? ` [WARNING: wiki attach failed — ${r.wikiAttachError}. Doc exists in drive root/folder.]`
1615
- : '';
1616
- const warn = r.fallbackWarning ? `\n\n${r.fallbackWarning}` : '';
1617
- return text(`Document created${ownership}: ${r.documentId}${wikiNote}${warn}`);
1618
- }
1619
-
1620
- // --- Official API: Bitable ---
1621
-
1622
- case 'create_bitable': {
1623
- const r = await getOfficialClient().createBitable(args.name, args.folder_id, {
1624
- wikiSpaceId: args.wiki_space_id,
1625
- wikiParentNodeToken: args.wiki_parent_node_token,
1626
- });
1627
- const ownership = r.viaUser ? ' (as user)' : ' (as app — UAT unavailable or failed; bitable owned by the app, not you)';
1628
- const wikiNote = r.wikiNodeToken ? `\nWiki node: ${r.wikiNodeToken}`
1629
- : r.wikiAttachTaskId ? `\nWiki attach queued — task_id: ${r.wikiAttachTaskId}`
1630
- : r.wikiAttachError ? `\nWARNING: wiki attach failed — ${r.wikiAttachError}. Bitable exists in drive root/folder.`
1631
- : '';
1632
- const warn = r.fallbackWarning ? `\n\n${r.fallbackWarning}` : '';
1633
- return text(`Bitable created${ownership}: ${r.appToken}\nURL: ${r.url || ''}${wikiNote}${warn}`);
1634
- }
1635
- case 'list_bitable_tables':
1636
- return json(await getOfficialClient().listBitableTables(await resolveDocId(args.app_token)));
1637
- case 'create_bitable_table': {
1638
- const r = await getOfficialClient().createBitableTable(await resolveDocId(args.app_token), args.name, args.fields);
1639
- const warn = r.fallbackWarning ? `\n\n${r.fallbackWarning}` : '';
1640
- return text(`Table created: ${r.tableId}${warn}`);
1641
- }
1642
- case 'list_bitable_fields':
1643
- return json(await getOfficialClient().listBitableFields(await resolveDocId(args.app_token), args.table_id));
1644
- case 'create_bitable_field': {
1645
- const config = { field_name: args.field_name, type: args.type };
1646
- if (args.property) config.property = args.property;
1647
- return json(await getOfficialClient().createBitableField(await resolveDocId(args.app_token), args.table_id, config));
1648
- }
1649
- case 'update_bitable_field': {
1650
- const config = {};
1651
- if (args.field_name) config.field_name = args.field_name;
1652
- if (args.type) config.type = args.type;
1653
- if (args.property) config.property = args.property;
1654
- return json(await getOfficialClient().updateBitableField(await resolveDocId(args.app_token), args.table_id, args.field_id, config));
1655
- }
1656
- case 'delete_bitable_field': {
1657
- const r = await getOfficialClient().deleteBitableField(await resolveDocId(args.app_token), args.table_id, args.field_id);
1658
- return text(r.deleted ? `Field ${r.fieldId} deleted` : `Field deletion returned deleted=${r.deleted}`);
1659
- }
1660
- case 'list_bitable_views':
1661
- return json(await getOfficialClient().listBitableViews(await resolveDocId(args.app_token), args.table_id));
1662
- case 'search_bitable_records':
1663
- return json(await getOfficialClient().searchBitableRecords(await resolveDocId(args.app_token), args.table_id, {
1664
- filter: args.filter, sort: args.sort, pageSize: args.page_size,
1665
- }));
1666
- case 'batch_create_bitable_records':
1667
- return json(await getOfficialClient().batchCreateBitableRecords(await resolveDocId(args.app_token), args.table_id, args.records));
1668
- case 'batch_update_bitable_records':
1669
- return json(await getOfficialClient().batchUpdateBitableRecords(await resolveDocId(args.app_token), args.table_id, args.records));
1670
- case 'batch_delete_bitable_records':
1671
- return json(await getOfficialClient().batchDeleteBitableRecords(await resolveDocId(args.app_token), args.table_id, args.record_ids));
1672
-
1673
- // --- Official API: Wiki ---
1674
-
1675
- case 'list_wiki_spaces':
1676
- return json(await getOfficialClient().listWikiSpaces());
1677
- case 'search_wiki':
1678
- return json(await getOfficialClient().searchWiki(args.query));
1679
- case 'list_wiki_nodes':
1680
- return json(await getOfficialClient().listWikiNodes(args.space_id, { parentNodeToken: args.parent_node_token }));
1681
-
1682
- // --- Official API: Drive ---
1683
-
1684
- case 'list_files':
1685
- return json(await getOfficialClient().listFiles(args.folder_token));
1686
- case 'create_folder': {
1687
- const r = await getOfficialClient().createFolder(args.name, args.parent_token);
1688
- const ownership = r.viaUser ? ' (as user)' : ' (as app — UAT unavailable or failed; folder owned by the app, not you)';
1689
- const warn = r.fallbackWarning ? `\n\n${r.fallbackWarning}` : '';
1690
- return text(`Folder created${ownership}: ${r.token}${warn}`);
1691
- }
1692
-
1693
- // --- Official API: Contact ---
1694
-
1695
- case 'find_user':
1696
- return json(await getOfficialClient().findUserByIdentity({ emails: args.email, mobiles: args.mobile }));
1697
-
1698
- // --- Upload ---
1699
-
1700
- case 'upload_image': {
1701
- const r = await getOfficialClient().uploadImage(args.image_path, args.image_type);
1702
- return text(`Image uploaded: ${r.imageKey}\nUse this image_key with send_image_as_user to send it.`);
1703
- }
1704
- case 'upload_file': {
1705
- const r = await getOfficialClient().uploadFile(args.file_path, args.file_type, args.file_name);
1706
- return text(`File uploaded: ${r.fileKey}\nUse this file_key with send_file_as_user to send it.`);
1707
- }
1708
- case 'upload_drive_file': {
1709
- const official = getOfficialClient();
1710
- const up = await official.uploadDriveFile(args.file_path, args.folder_token);
1711
- const out = { fileToken: up.fileToken, viaUser: up.viaUser, url: `https://feishu.cn/file/${up.fileToken}` };
1712
- if (args.wiki_space_id) {
1713
- try {
1714
- const node = await official.attachToWiki(args.wiki_space_id, 'file', up.fileToken, args.wiki_parent_node_token);
1715
- out.wikiNodeToken = node.node_token || null;
1716
- out.wikiAttachTaskId = node.task_id || null;
1717
- } catch (e) {
1718
- out.wikiAttachError = e.message;
1719
- }
1720
- }
1721
- return json(out);
1722
- }
1723
- case 'upload_bitable_attachment': {
1724
- const kind = args.kind === 'image' ? 'bitable_image' : 'bitable_file';
1725
- const appToken = await resolveDocId(args.app_token);
1726
- const up = await getOfficialClient().uploadMedia(args.file_path, appToken, kind);
1727
- return json({ fileToken: up.fileToken, viaUser: up.viaUser, parentType: kind, hint: `Pass [{ file_token: "${up.fileToken}" }] as the value of an Attachment-type Bitable field.` });
1728
- }
1729
-
1730
- // --- Official API: Bot Send / Edit / Delete ---
1731
-
1732
- case 'send_card_as_user': {
1733
- const via = args.via || 'bot';
1734
- if (via === 'user') {
1735
- return text('send_card_as_user via="user" is not implemented in v1.3.6 — user-identity card sending requires reverse-engineering the Feishu web protobuf and is scheduled for v1.3.7. Use via="bot" (default) for now.');
1736
- }
1737
- const r = await getOfficialClient().sendMessageAsBot(args.chat_id, 'interactive', args.card);
1738
- return text(`Card sent (${via}): ${r.messageId}`);
1739
- }
1740
- case 'send_message_as_bot': {
1741
- const r = await getOfficialClient().sendMessageAsBot(args.chat_id, args.msg_type, args.content);
1742
- return text(`Message sent (bot): ${r.messageId}`);
1743
- }
1744
- case 'delete_message':
1745
- return text(`Message deleted: ${(await getOfficialClient().deleteMessage(args.message_id)).deleted}`);
1746
- case 'update_message':
1747
- return text(`Message updated: ${(await getOfficialClient().updateMessage(args.message_id, args.msg_type, args.content)).messageId}`);
1748
-
1749
- // --- Official API: Reactions ---
1750
-
1751
- case 'add_reaction':
1752
- return text(`Reaction added: ${(await getOfficialClient().addReaction(args.message_id, args.emoji_type)).reactionId}`);
1753
- case 'delete_reaction':
1754
- return text(`Reaction removed: ${(await getOfficialClient().deleteReaction(args.message_id, args.reaction_id)).deleted}`);
1755
-
1756
- // --- Official API: Pins ---
1757
-
1758
- case 'pin_message':
1759
- return json(await getOfficialClient().pinMessage(args.message_id, args.pinned !== false));
1760
-
1761
- // --- Official API: Chat Management ---
1762
-
1763
- case 'create_group':
1764
- return text(`Group created: ${(await getOfficialClient().createChat({ name: args.name, description: args.description, userIds: args.user_ids })).chatId}`);
1765
- case 'update_group':
1766
- return text(`Group updated: ${(await getOfficialClient().updateChat(args.chat_id, { name: args.name, description: args.description })).updated}`);
1767
- case 'list_members':
1768
- return json(await getOfficialClient().listChatMembers(args.chat_id, { pageSize: args.page_size, pageToken: args.page_token }));
1769
- case 'manage_members': {
1770
- const official = getOfficialClient();
1771
- if (args.action === 'remove') {
1772
- return json(await official.removeChatMembers(args.chat_id, args.member_ids));
1773
- }
1774
- return json(await official.addChatMembers(args.chat_id, args.member_ids));
1775
- }
1776
-
1777
- // --- Official API: Doc Block Editing ---
1778
-
1779
- case 'create_doc_block': {
1780
- const official = getOfficialClient();
1781
- const docId = await resolveDocId(args.document_id);
1782
- const modes = [args.children, args.image_path, args.image_token, args.file_path, args.file_token].filter(Boolean);
1783
- if (modes.length > 1) return text('create_doc_block: pass exactly ONE of children / image_path / image_token / file_path / file_token.');
1784
- if (args.image_path || args.image_token) {
1785
- const r = await official.createDocBlockWithImage(docId, args.parent_block_id, {
1786
- imagePath: args.image_path,
1787
- imageToken: args.image_token,
1788
- index: args.index,
1789
- });
1790
- return json(r);
1791
- }
1792
- if (args.file_path || args.file_token) {
1793
- const r = await official.createDocBlockWithFile(docId, args.parent_block_id, {
1794
- filePath: args.file_path,
1795
- fileToken: args.file_token,
1796
- index: args.index,
1797
- });
1798
- return json(r);
1799
- }
1800
- if (!args.children) return text('create_doc_block: children, image_path, image_token, file_path, or file_token is required.');
1801
- return json(await official.createDocBlock(docId, args.parent_block_id, args.children, args.index));
1802
- }
1803
- case 'update_doc_block': {
1804
- const official = getOfficialClient();
1805
- const docId = await resolveDocId(args.document_id);
1806
- const modes = [args.update_body, args.image_token, args.file_token].filter(Boolean);
1807
- if (modes.length > 1) return text('update_doc_block: pass exactly ONE of update_body / image_token / file_token.');
1808
- if (args.image_token) {
1809
- return json(await official.updateDocBlockImage(docId, args.block_id, args.image_token));
1810
- }
1811
- if (args.file_token) {
1812
- return json(await official.updateDocBlockFile(docId, args.block_id, args.file_token));
1813
- }
1814
- if (!args.update_body) return text('update_doc_block: update_body, image_token, or file_token is required.');
1815
- return json(await official.updateDocBlock(docId, args.block_id, args.update_body));
1816
- }
1817
- case 'delete_doc_blocks':
1818
- return text(`Blocks deleted: ${(await getOfficialClient().deleteDocBlocks(await resolveDocId(args.document_id), args.parent_block_id, args.start_index, args.end_index)).deleted}`);
1819
-
1820
- // --- Official API: Bitable Additional ---
1821
-
1822
- case 'get_bitable_record':
1823
- return json(await getOfficialClient().getBitableRecord(await resolveDocId(args.app_token), args.table_id, args.record_id));
1824
- case 'delete_bitable_table':
1825
- return text(`Table deleted: ${(await getOfficialClient().deleteBitableTable(await resolveDocId(args.app_token), args.table_id)).deleted}`);
1826
- case 'get_bitable_meta':
1827
- return json(await getOfficialClient().getBitableMeta(await resolveDocId(args.app_token)));
1828
- case 'update_bitable_table':
1829
- return text(`Table renamed: ${(await getOfficialClient().updateBitableTable(await resolveDocId(args.app_token), args.table_id, args.name)).name}`);
1830
- case 'create_bitable_view':
1831
- return json(await getOfficialClient().createBitableView(await resolveDocId(args.app_token), args.table_id, args.view_name, args.view_type));
1832
- case 'delete_bitable_view':
1833
- return text(`View deleted: ${(await getOfficialClient().deleteBitableView(await resolveDocId(args.app_token), args.table_id, args.view_id)).deleted}`);
1834
- case 'copy_bitable':
1835
- return json(await getOfficialClient().copyBitable(await resolveDocId(args.app_token), args.name, args.folder_id));
1836
-
1837
- // --- Official API: Drive File Operations ---
1838
-
1839
- case 'copy_file':
1840
- return json(await getOfficialClient().copyFile(args.file_token, args.name, args.folder_token, args.type));
1841
- case 'move_file':
1842
- return text(`File moved: task=${(await getOfficialClient().moveFile(args.file_token, args.folder_token)).taskId}`);
1843
- case 'delete_file':
1844
- return text(`File deleted: task=${(await getOfficialClient().deleteFile(args.file_token, args.type)).taskId}`);
1845
-
1846
- case 'download_image': {
1847
- const official = getOfficialClient();
1848
- let r;
1849
- let source;
1850
- if (args.image_token) {
1851
- // Docx image mode — doc_token may be a URL / wiki node; resolve it.
1852
- const docToken = args.doc_token ? await resolveDocId(args.doc_token) : undefined;
1853
- r = await official.downloadDocImage(args.image_token, docToken);
1854
- source = docToken ? `docx ${docToken}` : 'drive media';
1855
- } else if (args.message_id && args.image_key) {
1856
- r = await official.downloadMessageResource(args.message_id, args.image_key, 'image');
1857
- source = `message ${args.message_id}`;
1858
- } else {
1859
- return text('download_image requires either (message_id + image_key) for chat images, or (image_token, optionally with doc_token) for docx images.');
1860
- }
1861
- // Return as MCP image content so the model sees the pixels directly.
1862
- return {
1863
- content: [
1864
- { type: 'text', text: `Image downloaded from ${source} (${r.viaUser ? 'as user' : 'as app'}, ${r.bytes} bytes, ${r.mimeType}):` },
1865
- { type: 'image', data: r.base64, mimeType: r.mimeType },
1866
- ],
1867
- };
1868
- }
1869
-
1870
- case 'download_file': {
1871
- if (!args.message_id || !args.file_key) {
1872
- return text('download_file requires message_id + file_key. For merge_forward children pass the PARENT merge_forward message id, not the child id.');
1873
- }
1874
- const r = await getOfficialClient().downloadMessageResource(args.message_id, args.file_key, 'file');
1875
- let saveNote = '';
1876
- if (args.save_path) {
1877
- try {
1878
- const fs = require('fs');
1879
- fs.writeFileSync(args.save_path, Buffer.from(r.base64, 'base64'));
1880
- saveNote = `\nSaved to: ${args.save_path}`;
1881
- } catch (e) {
1882
- saveNote = `\nSave to ${args.save_path} failed: ${e.message}`;
1883
- }
1884
- }
1885
- // Files are returned as a text summary plus a resource link so agents can
1886
- // either read the saved copy or decode the base64 themselves. We do not
1887
- // embed binary file content as MCP image blobs (wrong content-type).
1888
- const summary = `File downloaded from message ${args.message_id} (${r.viaUser ? 'as user' : 'as app'}, ${r.bytes} bytes, ${r.mimeType})${saveNote}`;
1889
- return {
1890
- content: [
1891
- { type: 'text', text: summary },
1892
- { type: 'text', text: `base64 (${r.bytes} bytes, truncated display):\n${r.base64.slice(0, 400)}${r.base64.length > 400 ? '…' : ''}` },
1893
- ],
1894
- };
1895
- }
1896
-
1897
- // --- Wiki Node Resolution (v1.3.4) ---
1898
- case 'get_wiki_node': {
1899
- // Accept either a bare wiki node token or a full /wiki/ URL — parse first.
1900
- const parsed = parseFeishuInput(args.node_token);
1901
- const token = (parsed.kind === 'wiki' || parsed.kind === 'raw') ? parsed.token : args.node_token;
1902
- return json(await getOfficialClient().getWikiNode(token));
1903
- }
1904
-
1905
- // --- OKR (v1.3.4) ---
1906
- case 'list_user_okrs':
1907
- return json(await getOfficialClient().listUserOkrs(args.user_id, {
1908
- periodIds: args.period_ids, offset: args.offset, limit: args.limit, lang: args.lang,
1909
- userIdType: args.user_id_type,
1910
- }));
1911
- case 'get_okrs':
1912
- return json(await getOfficialClient().getOkrs(args.okr_ids, { lang: args.lang, userIdType: args.user_id_type }));
1913
- case 'list_okr_periods':
1914
- return json(await getOfficialClient().listOkrPeriods({ pageSize: args.page_size, pageToken: args.page_token }));
1915
-
1916
- // --- Calendar (v1.3.4) ---
1917
- case 'list_calendars':
1918
- return json(await getOfficialClient().listCalendars({ pageSize: args.page_size, pageToken: args.page_token, syncToken: args.sync_token }));
1919
- case 'list_calendar_events':
1920
- return json(await getOfficialClient().listCalendarEvents(args.calendar_id, {
1921
- startTime: args.start_time, endTime: args.end_time,
1922
- pageSize: args.page_size, pageToken: args.page_token, syncToken: args.sync_token,
1923
- }));
1924
- case 'get_calendar_event':
1925
- return json(await getOfficialClient().getCalendarEvent(args.calendar_id, args.event_id));
1926
-
1927
- default:
1928
- return text(`Unknown tool: ${name}`);
1929
- }
1930
- }
1931
-
1932
- // --- Process-level error handlers ---
1933
- // Prevent stray promise rejections or uncaught exceptions from killing the MCP server.
1934
- process.on('uncaughtException', (err) => {
1935
- console.error('[feishu-user-plugin] Uncaught exception:', err.message);
1936
- console.error(err.stack);
1937
- });
1938
-
1939
- process.on('unhandledRejection', (reason) => {
1940
- console.error('[feishu-user-plugin] Unhandled rejection:', reason);
1941
- });
1942
-
1943
- async function main() {
1944
- const transport = new StdioServerTransport();
1945
- await server.connect(transport);
1946
-
1947
- // Startup diagnostics
1948
- const hasCookie = !!process.env.LARK_COOKIE;
1949
- const hasApp = !!(process.env.LARK_APP_ID && process.env.LARK_APP_SECRET);
1950
- const hasUAT = !!process.env.LARK_USER_ACCESS_TOKEN;
1951
- console.error(`[feishu-user-plugin] MCP Server v${require('../package.json').version} — ${TOOLS.length} tools`);
1952
- console.error(`[feishu-user-plugin] Auth: Cookie=${hasCookie ? 'YES' : 'NO'} App=${hasApp ? 'YES' : 'NO'} UAT=${hasUAT ? 'YES' : 'NO'}`);
1953
- if (!hasCookie) console.error('[feishu-user-plugin] WARNING: LARK_COOKIE not set — user identity tools (send_to_user, etc.) will fail');
1954
- if (!hasApp) console.error('[feishu-user-plugin] WARNING: LARK_APP_ID/SECRET not set — official API tools (read_messages, docs, etc.) will fail');
1955
- if (!hasUAT) console.error('[feishu-user-plugin] WARNING: LARK_USER_ACCESS_TOKEN not set — P2P chat reading (read_p2p_messages) will fail');
1956
-
1957
- // Validate APP_ID/SECRET against Feishu before serving any tool calls.
1958
- // Catches the "Claude filled in a wrong/stale APP_ID during install" failure mode
1959
- // that otherwise surfaces as cryptic 401s on every Official API call (looks like
1960
- // "MCP 掉线" to the user). Non-blocking — we warn but still serve, because the
1961
- // user may only need user-identity (cookie) tools.
1962
- if (hasApp) {
1963
- try {
1964
- const probe = await getOfficialClient().verifyApp();
1965
- if (probe.valid) {
1966
- const nameBit = probe.appName ? ` "${probe.appName}"` : '';
1967
- console.error(`[feishu-user-plugin] App verified: ${probe.appId}${nameBit}`);
1968
- } else {
1969
- console.error(`[feishu-user-plugin] ERROR: LARK_APP_ID=${probe.appId} was REJECTED by Feishu (${probe.error}).`);
1970
- console.error('[feishu-user-plugin] → Every Official API tool call will fail. Likely wrong/stale APP_ID.');
1971
- console.error('[feishu-user-plugin] → Re-run the install prompt from team-skills/plugins/feishu-user-plugin/README.md to get the correct credentials.');
1972
- }
1973
- } catch (e) {
1974
- console.error(`[feishu-user-plugin] WARNING: Could not verify APP_ID (${e.message}); network issue or cold start. Proceeding anyway.`);
1975
- }
1976
- }
1977
- }
1978
-
1979
- main().catch(console.error);