feishu-user-plugin 1.3.3 → 1.3.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/index.js CHANGED
@@ -21,6 +21,7 @@ const path = require('path');
21
21
  require('dotenv').config({ path: path.join(__dirname, '..', '.env') });
22
22
  const { LarkUserClient } = require('./client');
23
23
  const { LarkOfficialClient } = require('./official');
24
+ const { resolveToObj, resolveToken, parseFeishuInput } = require('./resolver');
24
25
 
25
26
  // --- Chat ID Mapper ---
26
27
 
@@ -325,7 +326,7 @@ const TOOLS = [
325
326
  // ========== IM — Official API (User Identity via UAT) ==========
326
327
  {
327
328
  name: 'read_p2p_messages',
328
- 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. Requires OAuth setup.',
329
+ 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.',
329
330
  inputSchema: {
330
331
  type: 'object',
331
332
  properties: {
@@ -334,6 +335,7 @@ const TOOLS = [
334
335
  start_time: { type: 'string', description: 'Start timestamp in seconds (optional)' },
335
336
  end_time: { type: 'string', description: 'End timestamp in seconds (optional)' },
336
337
  sort_type: { type: 'string', enum: ['ByCreateTimeDesc', 'ByCreateTimeAsc'], description: 'Sort order (default: ByCreateTimeDesc = newest first)' },
338
+ 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.' },
337
339
  },
338
340
  required: ['chat_id'],
339
341
  },
@@ -364,7 +366,7 @@ const TOOLS = [
364
366
  },
365
367
  {
366
368
  name: 'read_messages',
367
- 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.',
369
+ 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.',
368
370
  inputSchema: {
369
371
  type: 'object',
370
372
  properties: {
@@ -373,6 +375,7 @@ const TOOLS = [
373
375
  start_time: { type: 'string', description: 'Start timestamp in seconds (optional)' },
374
376
  end_time: { type: 'string', description: 'End timestamp in seconds (optional)' },
375
377
  sort_type: { type: 'string', enum: ['ByCreateTimeDesc', 'ByCreateTimeAsc'], description: 'Sort order (default: ByCreateTimeDesc = newest first)' },
378
+ 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.' },
376
379
  },
377
380
  required: ['chat_id'],
378
381
  },
@@ -434,12 +437,14 @@ const TOOLS = [
434
437
  },
435
438
  {
436
439
  name: 'create_doc',
437
- description: '[Official API] Create a new Feishu document.',
440
+ 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.',
438
441
  inputSchema: {
439
442
  type: 'object',
440
443
  properties: {
441
444
  title: { type: 'string', description: 'Document title' },
442
- folder_id: { type: 'string', description: 'Parent folder token (optional)' },
445
+ folder_id: { type: 'string', description: 'Parent folder token (optional; ignored when wiki_space_id is set)' },
446
+ wiki_space_id: { type: 'string', description: 'Wiki space ID to place the doc under (optional)' },
447
+ wiki_parent_node_token: { type: 'string', description: 'Parent wiki node token within the space (optional; defaults to space root)' },
443
448
  },
444
449
  required: ['title'],
445
450
  },
@@ -448,12 +453,14 @@ const TOOLS = [
448
453
  // ========== Bitable — Official API ==========
449
454
  {
450
455
  name: 'create_bitable',
451
- description: '[Official API] Create a new Bitable (multi-dimensional table) app.',
456
+ 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).',
452
457
  inputSchema: {
453
458
  type: 'object',
454
459
  properties: {
455
460
  name: { type: 'string', description: 'Bitable app name' },
456
- folder_id: { type: 'string', description: 'Parent folder token (optional, defaults to root)' },
461
+ folder_id: { type: 'string', description: 'Parent folder token (optional, defaults to root; ignored when wiki_space_id is set)' },
462
+ wiki_space_id: { type: 'string', description: 'Wiki space ID to place the bitable under (optional)' },
463
+ wiki_parent_node_token: { type: 'string', description: 'Parent wiki node token within the space (optional)' },
457
464
  },
458
465
  },
459
466
  },
@@ -830,29 +837,32 @@ const TOOLS = [
830
837
  // ========== Docs — Block Editing ==========
831
838
  {
832
839
  name: 'create_doc_block',
833
- description: '[Official API] Insert content blocks into a document. Add text, headings, lists, etc. after create_doc.',
840
+ description: '[Official API] Insert content blocks into a document. Three 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` (from a previous uploadDocMedia or docx image block) to reuse an already-uploaded image.\n`document_id` accepts native document_id, wiki node token, or Feishu URL.',
834
841
  inputSchema: {
835
842
  type: 'object',
836
843
  properties: {
837
- document_id: { type: 'string', description: 'Document ID' },
844
+ document_id: { type: 'string', description: 'Document ID, wiki node token, or Feishu URL' },
838
845
  parent_block_id: { type: 'string', description: 'Parent block ID (use document_id for root)' },
839
- children: { type: 'array', description: 'Array of block objects to insert. E.g. [{block_type:2, text:{elements:[{text_run:{content:"Hello"}}]}}]', items: { type: 'object' } },
846
+ children: { type: 'array', description: 'Generic block objects mode A. E.g. [{block_type:2, text:{elements:[{text_run:{content:"Hello"}}]}}]', items: { type: 'object' } },
847
+ image_path: { type: 'string', description: 'Local image path — mode B (mutually exclusive with children / image_token)' },
848
+ image_token: { type: 'string', description: 'Pre-uploaded docx image token — mode C (mutually exclusive with children / image_path)' },
840
849
  index: { type: 'number', description: 'Insert position (optional, appends to end if omitted)' },
841
850
  },
842
- required: ['document_id', 'parent_block_id', 'children'],
851
+ required: ['document_id', 'parent_block_id'],
843
852
  },
844
853
  },
845
854
  {
846
855
  name: 'update_doc_block',
847
- description: '[Official API] Update a specific block in a document (change text content, style, etc.).',
856
+ 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. document_id accepts native ID, wiki node token, or Feishu URL.',
848
857
  inputSchema: {
849
858
  type: 'object',
850
859
  properties: {
851
- document_id: { type: 'string', description: 'Document ID' },
860
+ document_id: { type: 'string', description: 'Document ID, wiki node token, or Feishu URL' },
852
861
  block_id: { type: 'string', description: 'Block ID to update' },
853
- update_body: { type: 'object', description: 'Update payload. E.g. {update_text_elements:{elements:[{text_run:{content:"new text"}}]}}' },
862
+ update_body: { type: 'object', description: 'Generic update payload. E.g. {update_text_elements:{elements:[{text_run:{content:"new text"}}]}}' },
863
+ image_token: { type: 'string', description: 'Pre-uploaded image token — if provided, update_body is ignored and the block is patched with {replace_image:{token}}' },
854
864
  },
855
- required: ['document_id', 'block_id', 'update_body'],
865
+ required: ['document_id', 'block_id'],
856
866
  },
857
867
  },
858
868
  {
@@ -1005,14 +1015,125 @@ const TOOLS = [
1005
1015
  // ========== Message Resources (Image/File Download) ==========
1006
1016
  {
1007
1017
  name: 'download_image',
1008
- description: '[User Identity / Official API] Download an image embedded in a message so the model can actually see it. Pass the message_id and image_key returned by read_messages / read_p2p_messages. Tries user identity first (works for any chat the user sees), falls back to app identity (requires the bot to be in the same chat).',
1018
+ 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.',
1019
+ inputSchema: {
1020
+ type: 'object',
1021
+ properties: {
1022
+ message_id: { type: 'string', description: 'Message ID (om_xxx) — for mode 1 only. For merge_forward children use the parent merge_forward message id.' },
1023
+ image_key: { type: 'string', description: 'Image key (img_xxx) from message content — for mode 1 only' },
1024
+ doc_token: { type: 'string', description: 'Document ID, wiki node token, or Feishu URL — for mode 2 only' },
1025
+ image_token: { type: 'string', description: 'Image token from a docx image block (block.image.token via get_doc_blocks) — for mode 2 only' },
1026
+ },
1027
+ },
1028
+ },
1029
+ {
1030
+ name: 'download_file',
1031
+ 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.',
1032
+ inputSchema: {
1033
+ type: 'object',
1034
+ properties: {
1035
+ message_id: { type: 'string', description: 'Message ID (om_xxx). For merge_forward children use the parent merge_forward message id.' },
1036
+ file_key: { type: 'string', description: 'File key from message content (content.file_key for msg_type=file)' },
1037
+ 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.' },
1038
+ },
1039
+ required: ['message_id', 'file_key'],
1040
+ },
1041
+ },
1042
+
1043
+ // ========== Wiki Node — Object Resolution (v1.3.4) ==========
1044
+ {
1045
+ name: 'get_wiki_node',
1046
+ 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.',
1047
+ inputSchema: {
1048
+ type: 'object',
1049
+ properties: {
1050
+ node_token: { type: 'string', description: 'Wiki node token (wikcnXXX / wikmXXX / wiknXXX) or full Feishu /wiki/<token> URL' },
1051
+ },
1052
+ required: ['node_token'],
1053
+ },
1054
+ },
1055
+
1056
+ // ========== OKR — Official API (v1.3.4) ==========
1057
+ {
1058
+ name: 'list_user_okrs',
1059
+ 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.',
1060
+ inputSchema: {
1061
+ type: 'object',
1062
+ properties: {
1063
+ user_id: { type: 'string', description: 'Target user\'s open_id (or the matching user_id_type)' },
1064
+ user_id_type: { type: 'string', enum: ['user_id', 'union_id', 'open_id', 'people_admin_id'], description: 'Type of user_id (default: open_id)' },
1065
+ period_ids: { type: 'array', items: { type: 'string' }, description: 'Filter by OKR period IDs (optional). Get period IDs via list_okr_periods.' },
1066
+ offset: { type: 'number', description: 'Pagination offset (default 0)' },
1067
+ limit: { type: 'number', description: 'Items per page (default 10, max 10)' },
1068
+ lang: { type: 'string', description: 'Response language (optional, e.g. "zh_cn", "en_us")' },
1069
+ },
1070
+ required: ['user_id'],
1071
+ },
1072
+ },
1073
+ {
1074
+ name: 'get_okrs',
1075
+ description: '[Official API + UAT] Batch-fetch full OKR details (objectives, key results, progress, alignments) by OKR IDs.',
1076
+ inputSchema: {
1077
+ type: 'object',
1078
+ properties: {
1079
+ okr_ids: { type: 'array', items: { type: 'string' }, description: 'OKR IDs (max 10 per call). From list_user_okrs.' },
1080
+ 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)' },
1081
+ lang: { type: 'string', description: 'Response language (optional)' },
1082
+ },
1083
+ required: ['okr_ids'],
1084
+ },
1085
+ },
1086
+ {
1087
+ name: 'list_okr_periods',
1088
+ description: '[Official API + UAT] List OKR periods (quarters / years) defined in the tenant. Use period_ids from this to filter list_user_okrs.',
1089
+ inputSchema: {
1090
+ type: 'object',
1091
+ properties: {
1092
+ page_size: { type: 'number', description: 'Items per page (default 10)' },
1093
+ page_token: { type: 'string', description: 'Pagination token' },
1094
+ },
1095
+ },
1096
+ },
1097
+
1098
+ // ========== Calendar — Official API (v1.3.4) ==========
1099
+ {
1100
+ name: 'list_calendars',
1101
+ 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.',
1102
+ inputSchema: {
1103
+ type: 'object',
1104
+ properties: {
1105
+ page_size: { type: 'number', description: 'Items per page (min 50, default 50). Feishu\'s calendar endpoint rejects page_size < 50.' },
1106
+ page_token: { type: 'string', description: 'Pagination token' },
1107
+ sync_token: { type: 'string', description: 'Incremental sync token (optional)' },
1108
+ },
1109
+ },
1110
+ },
1111
+ {
1112
+ name: 'list_calendar_events',
1113
+ 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).',
1114
+ inputSchema: {
1115
+ type: 'object',
1116
+ properties: {
1117
+ calendar_id: { type: 'string', description: 'Calendar ID from list_calendars' },
1118
+ start_time: { type: 'string', description: 'Range start (Unix seconds, optional)' },
1119
+ end_time: { type: 'string', description: 'Range end (Unix seconds, optional)' },
1120
+ page_size: { type: 'number', description: 'Items per page (default 50)' },
1121
+ page_token: { type: 'string', description: 'Pagination token' },
1122
+ sync_token: { type: 'string', description: 'Incremental sync token (optional)' },
1123
+ },
1124
+ required: ['calendar_id'],
1125
+ },
1126
+ },
1127
+ {
1128
+ name: 'get_calendar_event',
1129
+ description: '[Official API + UAT] Get full details of a single calendar event (summary, description, start/end, attendees, location, attachments, meeting link).',
1009
1130
  inputSchema: {
1010
1131
  type: 'object',
1011
1132
  properties: {
1012
- message_id: { type: 'string', description: 'The message_id (om_xxx) that contains the image — from read_messages / read_p2p_messages' },
1013
- image_key: { type: 'string', description: 'The image_key from the message content (img_xxx)' },
1133
+ calendar_id: { type: 'string', description: 'Calendar ID' },
1134
+ event_id: { type: 'string', description: 'Event ID from list_calendar_events' },
1014
1135
  },
1015
- required: ['message_id', 'image_key'],
1136
+ required: ['calendar_id', 'event_id'],
1016
1137
  },
1017
1138
  },
1018
1139
 
@@ -1037,9 +1158,22 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
1037
1158
  });
1038
1159
 
1039
1160
  const text = (s) => ({ content: [{ type: 'text', text: s }] });
1040
- const json = (o) => text(JSON.stringify(o, null, 2));
1161
+ const json = (o) => {
1162
+ // If the underlying method surfaced a fallback warning (UAT unavailable,
1163
+ // resource owned by bot), lift it to the top of the response so the human /
1164
+ // agent sees it *before* the structured body. Keeps the JSON payload intact.
1165
+ const warn = o && typeof o === 'object' && o.fallbackWarning ? `${o.fallbackWarning}\n\n` : '';
1166
+ return text(warn + JSON.stringify(o, null, 2));
1167
+ };
1041
1168
  const sendResult = (r, desc) => text(r.success ? desc : `Send failed (status: ${r.status})`);
1042
1169
 
1170
+ // Resolver helper: turn document_id / app_token / wiki node / Feishu URL into
1171
+ // a native token. No-op for already-native inputs. See src/resolver.js.
1172
+ async function resolveDocId(input) {
1173
+ if (!input) return input;
1174
+ return resolveToken(input, getOfficialClient());
1175
+ }
1176
+
1043
1177
  async function handleTool(name, args) {
1044
1178
 
1045
1179
  switch (name) {
@@ -1175,7 +1309,17 @@ async function handleTool(name, args) {
1175
1309
  parts.push(`App credentials: INVALID — app_id=${probe.appId} rejected by Feishu (${probe.error})`);
1176
1310
  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.`);
1177
1311
  }
1178
- parts.push(`User access token: ${official.hasUAT ? 'Configured (P2P reading enabled)' : 'Not set (optional — needed for P2P chat reading. Run OAuth flow to obtain, see README for details)'}`);
1312
+ if (official.hasUAT) {
1313
+ try {
1314
+ await official.listChatsAsUser({ pageSize: 1 });
1315
+ parts.push('User access token: Valid (P2P/group UAT reading enabled)');
1316
+ } catch (e) {
1317
+ parts.push(`User access token: INVALID — ${e.message}`);
1318
+ parts.push(' → Re-run OAuth: npx feishu-user-plugin oauth, then restart Claude Code / Codex so running MCP servers load the new token.');
1319
+ }
1320
+ } else {
1321
+ parts.push('User access token: Not set (optional — needed for P2P chat reading. Run OAuth flow to obtain, see README for details)');
1322
+ }
1179
1323
  }
1180
1324
  return text(parts.join('\n'));
1181
1325
  }
@@ -1211,6 +1355,7 @@ async function handleTool(name, args) {
1211
1355
  return json(await official.readMessagesAsUser(chatId, {
1212
1356
  pageSize: args.page_size, startTime: args.start_time, endTime: args.end_time,
1213
1357
  sortType: args.sort_type,
1358
+ expandMergeForward: args.expand_merge_forward !== false,
1214
1359
  }, uc));
1215
1360
  }
1216
1361
  case 'list_user_chats':
@@ -1222,36 +1367,29 @@ async function handleTool(name, args) {
1222
1367
  return json(await getOfficialClient().listChats({ pageSize: args.page_size, pageToken: args.page_token }));
1223
1368
  case 'read_messages': {
1224
1369
  const official = getOfficialClient();
1225
- const msgOpts = { pageSize: args.page_size, startTime: args.start_time, endTime: args.end_time, sortType: args.sort_type };
1370
+ const msgOpts = {
1371
+ pageSize: args.page_size, startTime: args.start_time, endTime: args.end_time,
1372
+ sortType: args.sort_type,
1373
+ expandMergeForward: args.expand_merge_forward !== false,
1374
+ };
1226
1375
  // Get userClient for name resolution fallback (best-effort)
1227
1376
  let uc = null;
1228
1377
  try { uc = await getUserClient(); } catch (_) {}
1229
- const resolvedChatId = await chatIdMapper.resolveToOcId(args.chat_id, official);
1230
1378
 
1231
- // Try bot API first if we resolved an oc_ ID
1379
+ // Path A chat_id that resolves inside bot's / official search scope.
1380
+ const resolvedChatId = await chatIdMapper.resolveToOcId(args.chat_id, official);
1232
1381
  if (resolvedChatId) {
1233
- try {
1234
- return json(await official.readMessages(resolvedChatId, msgOpts, uc));
1235
- } catch (botErr) {
1236
- // Bot API failed (e.g. bot not in group, no permission) — fall through to UAT
1237
- console.error(`[feishu-user-plugin] read_messages bot API failed for ${resolvedChatId}: ${botErr.message}`);
1238
- if (official.hasUAT) {
1239
- try {
1240
- return json(await official.readMessagesAsUser(resolvedChatId, msgOpts, uc));
1241
- } catch (uatErr) {
1242
- console.error(`[feishu-user-plugin] read_messages UAT fallback also failed for ${resolvedChatId}: ${uatErr.message}`);
1243
- }
1244
- }
1245
- throw botErr; // Re-throw original error if UAT also failed
1246
- }
1382
+ return json(await official.readMessagesWithFallback(resolvedChatId, msgOpts, uc));
1247
1383
  }
1248
1384
 
1249
- // Bot couldn't resolve the chat name try search_contacts + UAT for external groups
1385
+ // Path Bexternal group discovered only via cookie search_contacts.
1386
+ // When we got here the bot definitely can't see it, so skip bot entirely
1387
+ // and go straight to UAT with a `contacts` via label.
1250
1388
  if (official.hasUAT) {
1251
1389
  if (!uc) try { uc = await getUserClient(); } catch (_) {}
1252
1390
  const contactChatId = await chatIdMapper.resolveViaContacts(args.chat_id, uc);
1253
1391
  if (contactChatId) {
1254
- return json(await official.readMessagesAsUser(contactChatId, msgOpts, uc));
1392
+ return json(await official.readMessagesWithFallback(contactChatId, msgOpts, uc, { skipBot: true, via: 'contacts' }));
1255
1393
  }
1256
1394
  }
1257
1395
 
@@ -1267,56 +1405,75 @@ async function handleTool(name, args) {
1267
1405
  case 'search_docs':
1268
1406
  return json(await getOfficialClient().searchDocs(args.query));
1269
1407
  case 'read_doc':
1270
- return json(await getOfficialClient().readDoc(args.document_id));
1408
+ return json(await getOfficialClient().readDoc(await resolveDocId(args.document_id)));
1271
1409
  case 'get_doc_blocks':
1272
- return json(await getOfficialClient().getDocBlocks(args.document_id));
1410
+ return json(await getOfficialClient().getDocBlocks(await resolveDocId(args.document_id)));
1273
1411
  case 'create_doc': {
1274
- const r = await getOfficialClient().createDoc(args.title, args.folder_id);
1412
+ const r = await getOfficialClient().createDoc(args.title, args.folder_id, {
1413
+ wikiSpaceId: args.wiki_space_id,
1414
+ wikiParentNodeToken: args.wiki_parent_node_token,
1415
+ });
1275
1416
  const ownership = r.viaUser ? ' (as user)' : ' (as app — UAT unavailable or failed; document owned by the app, not you)';
1276
- return text(`Document created${ownership}: ${r.documentId}`);
1417
+ const wikiNote = r.wikiNodeToken ? ` [wiki node: ${r.wikiNodeToken}]`
1418
+ : r.wikiAttachTaskId ? ` [wiki attach queued — task_id: ${r.wikiAttachTaskId}]`
1419
+ : r.wikiAttachError ? ` [WARNING: wiki attach failed — ${r.wikiAttachError}. Doc exists in drive root/folder.]`
1420
+ : '';
1421
+ const warn = r.fallbackWarning ? `\n\n${r.fallbackWarning}` : '';
1422
+ return text(`Document created${ownership}: ${r.documentId}${wikiNote}${warn}`);
1277
1423
  }
1278
1424
 
1279
1425
  // --- Official API: Bitable ---
1280
1426
 
1281
1427
  case 'create_bitable': {
1282
- const r = await getOfficialClient().createBitable(args.name, args.folder_id);
1428
+ const r = await getOfficialClient().createBitable(args.name, args.folder_id, {
1429
+ wikiSpaceId: args.wiki_space_id,
1430
+ wikiParentNodeToken: args.wiki_parent_node_token,
1431
+ });
1283
1432
  const ownership = r.viaUser ? ' (as user)' : ' (as app — UAT unavailable or failed; bitable owned by the app, not you)';
1284
- return text(`Bitable created${ownership}: ${r.appToken}\nURL: ${r.url || ''}`);
1433
+ const wikiNote = r.wikiNodeToken ? `\nWiki node: ${r.wikiNodeToken}`
1434
+ : r.wikiAttachTaskId ? `\nWiki attach queued — task_id: ${r.wikiAttachTaskId}`
1435
+ : r.wikiAttachError ? `\nWARNING: wiki attach failed — ${r.wikiAttachError}. Bitable exists in drive root/folder.`
1436
+ : '';
1437
+ const warn = r.fallbackWarning ? `\n\n${r.fallbackWarning}` : '';
1438
+ return text(`Bitable created${ownership}: ${r.appToken}\nURL: ${r.url || ''}${wikiNote}${warn}`);
1285
1439
  }
1286
1440
  case 'list_bitable_tables':
1287
- return json(await getOfficialClient().listBitableTables(args.app_token));
1288
- case 'create_bitable_table':
1289
- return text(`Table created: ${(await getOfficialClient().createBitableTable(args.app_token, args.name, args.fields)).tableId}`);
1441
+ return json(await getOfficialClient().listBitableTables(await resolveDocId(args.app_token)));
1442
+ case 'create_bitable_table': {
1443
+ const r = await getOfficialClient().createBitableTable(await resolveDocId(args.app_token), args.name, args.fields);
1444
+ const warn = r.fallbackWarning ? `\n\n${r.fallbackWarning}` : '';
1445
+ return text(`Table created: ${r.tableId}${warn}`);
1446
+ }
1290
1447
  case 'list_bitable_fields':
1291
- return json(await getOfficialClient().listBitableFields(args.app_token, args.table_id));
1448
+ return json(await getOfficialClient().listBitableFields(await resolveDocId(args.app_token), args.table_id));
1292
1449
  case 'create_bitable_field': {
1293
1450
  const config = { field_name: args.field_name, type: args.type };
1294
1451
  if (args.property) config.property = args.property;
1295
- return json(await getOfficialClient().createBitableField(args.app_token, args.table_id, config));
1452
+ return json(await getOfficialClient().createBitableField(await resolveDocId(args.app_token), args.table_id, config));
1296
1453
  }
1297
1454
  case 'update_bitable_field': {
1298
1455
  const config = {};
1299
1456
  if (args.field_name) config.field_name = args.field_name;
1300
1457
  if (args.type) config.type = args.type;
1301
1458
  if (args.property) config.property = args.property;
1302
- return json(await getOfficialClient().updateBitableField(args.app_token, args.table_id, args.field_id, config));
1459
+ return json(await getOfficialClient().updateBitableField(await resolveDocId(args.app_token), args.table_id, args.field_id, config));
1303
1460
  }
1304
1461
  case 'delete_bitable_field': {
1305
- const r = await getOfficialClient().deleteBitableField(args.app_token, args.table_id, args.field_id);
1462
+ const r = await getOfficialClient().deleteBitableField(await resolveDocId(args.app_token), args.table_id, args.field_id);
1306
1463
  return text(r.deleted ? `Field ${r.fieldId} deleted` : `Field deletion returned deleted=${r.deleted}`);
1307
1464
  }
1308
1465
  case 'list_bitable_views':
1309
- return json(await getOfficialClient().listBitableViews(args.app_token, args.table_id));
1466
+ return json(await getOfficialClient().listBitableViews(await resolveDocId(args.app_token), args.table_id));
1310
1467
  case 'search_bitable_records':
1311
- return json(await getOfficialClient().searchBitableRecords(args.app_token, args.table_id, {
1468
+ return json(await getOfficialClient().searchBitableRecords(await resolveDocId(args.app_token), args.table_id, {
1312
1469
  filter: args.filter, sort: args.sort, pageSize: args.page_size,
1313
1470
  }));
1314
1471
  case 'batch_create_bitable_records':
1315
- return json(await getOfficialClient().batchCreateBitableRecords(args.app_token, args.table_id, args.records));
1472
+ return json(await getOfficialClient().batchCreateBitableRecords(await resolveDocId(args.app_token), args.table_id, args.records));
1316
1473
  case 'batch_update_bitable_records':
1317
- return json(await getOfficialClient().batchUpdateBitableRecords(args.app_token, args.table_id, args.records));
1474
+ return json(await getOfficialClient().batchUpdateBitableRecords(await resolveDocId(args.app_token), args.table_id, args.records));
1318
1475
  case 'batch_delete_bitable_records':
1319
- return json(await getOfficialClient().batchDeleteBitableRecords(args.app_token, args.table_id, args.record_ids));
1476
+ return json(await getOfficialClient().batchDeleteBitableRecords(await resolveDocId(args.app_token), args.table_id, args.record_ids));
1320
1477
 
1321
1478
  // --- Official API: Wiki ---
1322
1479
 
@@ -1334,7 +1491,8 @@ async function handleTool(name, args) {
1334
1491
  case 'create_folder': {
1335
1492
  const r = await getOfficialClient().createFolder(args.name, args.parent_token);
1336
1493
  const ownership = r.viaUser ? ' (as user)' : ' (as app — UAT unavailable or failed; folder owned by the app, not you)';
1337
- return text(`Folder created${ownership}: ${r.token}`);
1494
+ const warn = r.fallbackWarning ? `\n\n${r.fallbackWarning}` : '';
1495
+ return text(`Folder created${ownership}: ${r.token}${warn}`);
1338
1496
  }
1339
1497
 
1340
1498
  // --- Official API: Contact ---
@@ -1394,29 +1552,54 @@ async function handleTool(name, args) {
1394
1552
 
1395
1553
  // --- Official API: Doc Block Editing ---
1396
1554
 
1397
- case 'create_doc_block':
1398
- return json(await getOfficialClient().createDocBlock(args.document_id, args.parent_block_id, args.children, args.index));
1399
- case 'update_doc_block':
1400
- return json(await getOfficialClient().updateDocBlock(args.document_id, args.block_id, args.update_body));
1555
+ case 'create_doc_block': {
1556
+ const official = getOfficialClient();
1557
+ const docId = await resolveDocId(args.document_id);
1558
+ // Image shortcut: if image_path or image_token is provided, orchestrate the
1559
+ // 3-step docx image creation. Mutually exclusive with children.
1560
+ if (args.image_path || args.image_token) {
1561
+ if (args.children) return text('create_doc_block: pass children OR image_path OR image_token, not both.');
1562
+ const r = await official.createDocBlockWithImage(docId, args.parent_block_id, {
1563
+ imagePath: args.image_path,
1564
+ imageToken: args.image_token,
1565
+ index: args.index,
1566
+ });
1567
+ return json(r);
1568
+ }
1569
+ if (!args.children) return text('create_doc_block: children (generic blocks), image_path, or image_token is required.');
1570
+ return json(await official.createDocBlock(docId, args.parent_block_id, args.children, args.index));
1571
+ }
1572
+ case 'update_doc_block': {
1573
+ const official = getOfficialClient();
1574
+ const docId = await resolveDocId(args.document_id);
1575
+ if (args.image_token && args.update_body) {
1576
+ return text('update_doc_block: pass image_token OR update_body, not both.');
1577
+ }
1578
+ if (args.image_token) {
1579
+ return json(await official.updateDocBlockImage(docId, args.block_id, args.image_token));
1580
+ }
1581
+ if (!args.update_body) return text('update_doc_block: update_body or image_token is required.');
1582
+ return json(await official.updateDocBlock(docId, args.block_id, args.update_body));
1583
+ }
1401
1584
  case 'delete_doc_blocks':
1402
- return text(`Blocks deleted: ${(await getOfficialClient().deleteDocBlocks(args.document_id, args.parent_block_id, args.start_index, args.end_index)).deleted}`);
1585
+ return text(`Blocks deleted: ${(await getOfficialClient().deleteDocBlocks(await resolveDocId(args.document_id), args.parent_block_id, args.start_index, args.end_index)).deleted}`);
1403
1586
 
1404
1587
  // --- Official API: Bitable Additional ---
1405
1588
 
1406
1589
  case 'get_bitable_record':
1407
- return json(await getOfficialClient().getBitableRecord(args.app_token, args.table_id, args.record_id));
1590
+ return json(await getOfficialClient().getBitableRecord(await resolveDocId(args.app_token), args.table_id, args.record_id));
1408
1591
  case 'delete_bitable_table':
1409
- return text(`Table deleted: ${(await getOfficialClient().deleteBitableTable(args.app_token, args.table_id)).deleted}`);
1592
+ return text(`Table deleted: ${(await getOfficialClient().deleteBitableTable(await resolveDocId(args.app_token), args.table_id)).deleted}`);
1410
1593
  case 'get_bitable_meta':
1411
- return json(await getOfficialClient().getBitableMeta(args.app_token));
1594
+ return json(await getOfficialClient().getBitableMeta(await resolveDocId(args.app_token)));
1412
1595
  case 'update_bitable_table':
1413
- return text(`Table renamed: ${(await getOfficialClient().updateBitableTable(args.app_token, args.table_id, args.name)).name}`);
1596
+ return text(`Table renamed: ${(await getOfficialClient().updateBitableTable(await resolveDocId(args.app_token), args.table_id, args.name)).name}`);
1414
1597
  case 'create_bitable_view':
1415
- return json(await getOfficialClient().createBitableView(args.app_token, args.table_id, args.view_name, args.view_type));
1598
+ return json(await getOfficialClient().createBitableView(await resolveDocId(args.app_token), args.table_id, args.view_name, args.view_type));
1416
1599
  case 'delete_bitable_view':
1417
- return text(`View deleted: ${(await getOfficialClient().deleteBitableView(args.app_token, args.table_id, args.view_id)).deleted}`);
1600
+ return text(`View deleted: ${(await getOfficialClient().deleteBitableView(await resolveDocId(args.app_token), args.table_id, args.view_id)).deleted}`);
1418
1601
  case 'copy_bitable':
1419
- return json(await getOfficialClient().copyBitable(args.app_token, args.name, args.folder_id));
1602
+ return json(await getOfficialClient().copyBitable(await resolveDocId(args.app_token), args.name, args.folder_id));
1420
1603
 
1421
1604
  // --- Official API: Drive File Operations ---
1422
1605
 
@@ -1428,17 +1611,86 @@ async function handleTool(name, args) {
1428
1611
  return text(`File deleted: task=${(await getOfficialClient().deleteFile(args.file_token, args.type)).taskId}`);
1429
1612
 
1430
1613
  case 'download_image': {
1431
- const r = await getOfficialClient().downloadMessageResource(args.message_id, args.image_key, 'image');
1614
+ const official = getOfficialClient();
1615
+ let r;
1616
+ let source;
1617
+ if (args.image_token) {
1618
+ // Docx image mode — doc_token may be a URL / wiki node; resolve it.
1619
+ const docToken = args.doc_token ? await resolveDocId(args.doc_token) : undefined;
1620
+ r = await official.downloadDocImage(args.image_token, docToken);
1621
+ source = docToken ? `docx ${docToken}` : 'drive media';
1622
+ } else if (args.message_id && args.image_key) {
1623
+ r = await official.downloadMessageResource(args.message_id, args.image_key, 'image');
1624
+ source = `message ${args.message_id}`;
1625
+ } else {
1626
+ return text('download_image requires either (message_id + image_key) for chat images, or (image_token, optionally with doc_token) for docx images.');
1627
+ }
1432
1628
  // Return as MCP image content so the model sees the pixels directly.
1433
- // Also include a tiny text preamble so the via-identity + size are visible in transcript.
1434
1629
  return {
1435
1630
  content: [
1436
- { type: 'text', text: `Image downloaded (${r.viaUser ? 'as user' : 'as app'}, ${r.bytes} bytes, ${r.mimeType}):` },
1631
+ { type: 'text', text: `Image downloaded from ${source} (${r.viaUser ? 'as user' : 'as app'}, ${r.bytes} bytes, ${r.mimeType}):` },
1437
1632
  { type: 'image', data: r.base64, mimeType: r.mimeType },
1438
1633
  ],
1439
1634
  };
1440
1635
  }
1441
1636
 
1637
+ case 'download_file': {
1638
+ if (!args.message_id || !args.file_key) {
1639
+ return text('download_file requires message_id + file_key. For merge_forward children pass the PARENT merge_forward message id, not the child id.');
1640
+ }
1641
+ const r = await getOfficialClient().downloadMessageResource(args.message_id, args.file_key, 'file');
1642
+ let saveNote = '';
1643
+ if (args.save_path) {
1644
+ try {
1645
+ const fs = require('fs');
1646
+ fs.writeFileSync(args.save_path, Buffer.from(r.base64, 'base64'));
1647
+ saveNote = `\nSaved to: ${args.save_path}`;
1648
+ } catch (e) {
1649
+ saveNote = `\nSave to ${args.save_path} failed: ${e.message}`;
1650
+ }
1651
+ }
1652
+ // Files are returned as a text summary plus a resource link so agents can
1653
+ // either read the saved copy or decode the base64 themselves. We do not
1654
+ // embed binary file content as MCP image blobs (wrong content-type).
1655
+ const summary = `File downloaded from message ${args.message_id} (${r.viaUser ? 'as user' : 'as app'}, ${r.bytes} bytes, ${r.mimeType})${saveNote}`;
1656
+ return {
1657
+ content: [
1658
+ { type: 'text', text: summary },
1659
+ { type: 'text', text: `base64 (${r.bytes} bytes, truncated display):\n${r.base64.slice(0, 400)}${r.base64.length > 400 ? '…' : ''}` },
1660
+ ],
1661
+ };
1662
+ }
1663
+
1664
+ // --- Wiki Node Resolution (v1.3.4) ---
1665
+ case 'get_wiki_node': {
1666
+ // Accept either a bare wiki node token or a full /wiki/ URL — parse first.
1667
+ const parsed = parseFeishuInput(args.node_token);
1668
+ const token = (parsed.kind === 'wiki' || parsed.kind === 'raw') ? parsed.token : args.node_token;
1669
+ return json(await getOfficialClient().getWikiNode(token));
1670
+ }
1671
+
1672
+ // --- OKR (v1.3.4) ---
1673
+ case 'list_user_okrs':
1674
+ return json(await getOfficialClient().listUserOkrs(args.user_id, {
1675
+ periodIds: args.period_ids, offset: args.offset, limit: args.limit, lang: args.lang,
1676
+ userIdType: args.user_id_type,
1677
+ }));
1678
+ case 'get_okrs':
1679
+ return json(await getOfficialClient().getOkrs(args.okr_ids, { lang: args.lang, userIdType: args.user_id_type }));
1680
+ case 'list_okr_periods':
1681
+ return json(await getOfficialClient().listOkrPeriods({ pageSize: args.page_size, pageToken: args.page_token }));
1682
+
1683
+ // --- Calendar (v1.3.4) ---
1684
+ case 'list_calendars':
1685
+ return json(await getOfficialClient().listCalendars({ pageSize: args.page_size, pageToken: args.page_token, syncToken: args.sync_token }));
1686
+ case 'list_calendar_events':
1687
+ return json(await getOfficialClient().listCalendarEvents(args.calendar_id, {
1688
+ startTime: args.start_time, endTime: args.end_time,
1689
+ pageSize: args.page_size, pageToken: args.page_token, syncToken: args.sync_token,
1690
+ }));
1691
+ case 'get_calendar_event':
1692
+ return json(await getOfficialClient().getCalendarEvent(args.calendar_id, args.event_id));
1693
+
1442
1694
  default:
1443
1695
  return text(`Unknown tool: ${name}`);
1444
1696
  }
package/src/oauth.js CHANGED
@@ -23,7 +23,12 @@ const PORT = 9997;
23
23
  const REDIRECT_URI = `http://127.0.0.1:${PORT}/callback`;
24
24
  // offline_access is required to get refresh_token for auto-renewal
25
25
  // Write scopes (docx:document, drive:drive, bitable:app) allow creating resources as the user, not the app
26
- const SCOPES = 'offline_access auth:user.id:read im:message im:message:readonly im:chat im:chat:readonly contact:user.base:readonly contact:user.id:readonly docx:document drive:drive bitable:app wiki:wiki:readonly';
26
+ // v1.3.4 additions:
27
+ // okr:* for list_user_okrs / get_okrs / list_okr_periods
28
+ // calendar:* for list_calendars / list_calendar_events / get_calendar_event
29
+ // wiki:wiki write access for move_docs_to_wiki (attach docs/bitables to wiki)
30
+ // docs:document.media:(upload|download) for docx image read/write
31
+ const SCOPES = 'offline_access auth:user.id:read im:message im:message:readonly im:chat im:chat:readonly contact:user.base:readonly contact:user.id:readonly docx:document drive:drive bitable:app wiki:wiki:readonly wiki:wiki okr:okr:readonly okr:okr.period:readonly okr:okr.content:readonly calendar:calendar:readonly calendar:calendar.event:read docs:document.media:download docs:document.media:upload';
27
32
 
28
33
  if (!APP_ID || !APP_SECRET) {
29
34
  console.error('Missing LARK_APP_ID or LARK_APP_SECRET.');