feishu-user-plugin 1.3.3 → 1.3.4

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
 
@@ -434,12 +435,14 @@ const TOOLS = [
434
435
  },
435
436
  {
436
437
  name: 'create_doc',
437
- description: '[Official API] Create a new Feishu document.',
438
+ 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
439
  inputSchema: {
439
440
  type: 'object',
440
441
  properties: {
441
442
  title: { type: 'string', description: 'Document title' },
442
- folder_id: { type: 'string', description: 'Parent folder token (optional)' },
443
+ folder_id: { type: 'string', description: 'Parent folder token (optional; ignored when wiki_space_id is set)' },
444
+ wiki_space_id: { type: 'string', description: 'Wiki space ID to place the doc under (optional)' },
445
+ wiki_parent_node_token: { type: 'string', description: 'Parent wiki node token within the space (optional; defaults to space root)' },
443
446
  },
444
447
  required: ['title'],
445
448
  },
@@ -448,12 +451,14 @@ const TOOLS = [
448
451
  // ========== Bitable — Official API ==========
449
452
  {
450
453
  name: 'create_bitable',
451
- description: '[Official API] Create a new Bitable (multi-dimensional table) app.',
454
+ 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
455
  inputSchema: {
453
456
  type: 'object',
454
457
  properties: {
455
458
  name: { type: 'string', description: 'Bitable app name' },
456
- folder_id: { type: 'string', description: 'Parent folder token (optional, defaults to root)' },
459
+ folder_id: { type: 'string', description: 'Parent folder token (optional, defaults to root; ignored when wiki_space_id is set)' },
460
+ wiki_space_id: { type: 'string', description: 'Wiki space ID to place the bitable under (optional)' },
461
+ wiki_parent_node_token: { type: 'string', description: 'Parent wiki node token within the space (optional)' },
457
462
  },
458
463
  },
459
464
  },
@@ -830,29 +835,32 @@ const TOOLS = [
830
835
  // ========== Docs — Block Editing ==========
831
836
  {
832
837
  name: 'create_doc_block',
833
- description: '[Official API] Insert content blocks into a document. Add text, headings, lists, etc. after create_doc.',
838
+ 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
839
  inputSchema: {
835
840
  type: 'object',
836
841
  properties: {
837
- document_id: { type: 'string', description: 'Document ID' },
842
+ document_id: { type: 'string', description: 'Document ID, wiki node token, or Feishu URL' },
838
843
  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' } },
844
+ children: { type: 'array', description: 'Generic block objects mode A. E.g. [{block_type:2, text:{elements:[{text_run:{content:"Hello"}}]}}]', items: { type: 'object' } },
845
+ image_path: { type: 'string', description: 'Local image path — mode B (mutually exclusive with children / image_token)' },
846
+ image_token: { type: 'string', description: 'Pre-uploaded docx image token — mode C (mutually exclusive with children / image_path)' },
840
847
  index: { type: 'number', description: 'Insert position (optional, appends to end if omitted)' },
841
848
  },
842
- required: ['document_id', 'parent_block_id', 'children'],
849
+ required: ['document_id', 'parent_block_id'],
843
850
  },
844
851
  },
845
852
  {
846
853
  name: 'update_doc_block',
847
- description: '[Official API] Update a specific block in a document (change text content, style, etc.).',
854
+ 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
855
  inputSchema: {
849
856
  type: 'object',
850
857
  properties: {
851
- document_id: { type: 'string', description: 'Document ID' },
858
+ document_id: { type: 'string', description: 'Document ID, wiki node token, or Feishu URL' },
852
859
  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"}}]}}' },
860
+ update_body: { type: 'object', description: 'Generic update payload. E.g. {update_text_elements:{elements:[{text_run:{content:"new text"}}]}}' },
861
+ 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
862
  },
855
- required: ['document_id', 'block_id', 'update_body'],
863
+ required: ['document_id', 'block_id'],
856
864
  },
857
865
  },
858
866
  {
@@ -1005,14 +1013,112 @@ const TOOLS = [
1005
1013
  // ========== Message Resources (Image/File Download) ==========
1006
1014
  {
1007
1015
  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).',
1016
+ 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.',
1009
1017
  inputSchema: {
1010
1018
  type: 'object',
1011
1019
  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)' },
1020
+ message_id: { type: 'string', description: 'Message ID (om_xxx) — for mode 1 only' },
1021
+ image_key: { type: 'string', description: 'Image key (img_xxx) from message content — for mode 1 only' },
1022
+ doc_token: { type: 'string', description: 'Document ID, wiki node token, or Feishu URL — for mode 2 only' },
1023
+ image_token: { type: 'string', description: 'Image token from a docx image block (block.image.token via get_doc_blocks) — for mode 2 only' },
1014
1024
  },
1015
- required: ['message_id', 'image_key'],
1025
+ },
1026
+ },
1027
+
1028
+ // ========== Wiki Node — Object Resolution (v1.3.4) ==========
1029
+ {
1030
+ name: 'get_wiki_node',
1031
+ 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.',
1032
+ inputSchema: {
1033
+ type: 'object',
1034
+ properties: {
1035
+ node_token: { type: 'string', description: 'Wiki node token (wikcnXXX / wikmXXX / wiknXXX) or full Feishu /wiki/<token> URL' },
1036
+ },
1037
+ required: ['node_token'],
1038
+ },
1039
+ },
1040
+
1041
+ // ========== OKR — Official API (v1.3.4) ==========
1042
+ {
1043
+ name: 'list_user_okrs',
1044
+ 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.',
1045
+ inputSchema: {
1046
+ type: 'object',
1047
+ properties: {
1048
+ user_id: { type: 'string', description: 'Target user\'s open_id (or the matching user_id_type)' },
1049
+ user_id_type: { type: 'string', enum: ['user_id', 'union_id', 'open_id', 'people_admin_id'], description: 'Type of user_id (default: open_id)' },
1050
+ period_ids: { type: 'array', items: { type: 'string' }, description: 'Filter by OKR period IDs (optional). Get period IDs via list_okr_periods.' },
1051
+ offset: { type: 'number', description: 'Pagination offset (default 0)' },
1052
+ limit: { type: 'number', description: 'Items per page (default 10, max 10)' },
1053
+ lang: { type: 'string', description: 'Response language (optional, e.g. "zh_cn", "en_us")' },
1054
+ },
1055
+ required: ['user_id'],
1056
+ },
1057
+ },
1058
+ {
1059
+ name: 'get_okrs',
1060
+ description: '[Official API + UAT] Batch-fetch full OKR details (objectives, key results, progress, alignments) by OKR IDs.',
1061
+ inputSchema: {
1062
+ type: 'object',
1063
+ properties: {
1064
+ okr_ids: { type: 'array', items: { type: 'string' }, description: 'OKR IDs (max 10 per call). From list_user_okrs.' },
1065
+ 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)' },
1066
+ lang: { type: 'string', description: 'Response language (optional)' },
1067
+ },
1068
+ required: ['okr_ids'],
1069
+ },
1070
+ },
1071
+ {
1072
+ name: 'list_okr_periods',
1073
+ description: '[Official API + UAT] List OKR periods (quarters / years) defined in the tenant. Use period_ids from this to filter list_user_okrs.',
1074
+ inputSchema: {
1075
+ type: 'object',
1076
+ properties: {
1077
+ page_size: { type: 'number', description: 'Items per page (default 10)' },
1078
+ page_token: { type: 'string', description: 'Pagination token' },
1079
+ },
1080
+ },
1081
+ },
1082
+
1083
+ // ========== Calendar — Official API (v1.3.4) ==========
1084
+ {
1085
+ name: 'list_calendars',
1086
+ 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.',
1087
+ inputSchema: {
1088
+ type: 'object',
1089
+ properties: {
1090
+ page_size: { type: 'number', description: 'Items per page (min 50, default 50). Feishu\'s calendar endpoint rejects page_size < 50.' },
1091
+ page_token: { type: 'string', description: 'Pagination token' },
1092
+ sync_token: { type: 'string', description: 'Incremental sync token (optional)' },
1093
+ },
1094
+ },
1095
+ },
1096
+ {
1097
+ name: 'list_calendar_events',
1098
+ 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).',
1099
+ inputSchema: {
1100
+ type: 'object',
1101
+ properties: {
1102
+ calendar_id: { type: 'string', description: 'Calendar ID from list_calendars' },
1103
+ start_time: { type: 'string', description: 'Range start (Unix seconds, optional)' },
1104
+ end_time: { type: 'string', description: 'Range end (Unix seconds, optional)' },
1105
+ page_size: { type: 'number', description: 'Items per page (default 50)' },
1106
+ page_token: { type: 'string', description: 'Pagination token' },
1107
+ sync_token: { type: 'string', description: 'Incremental sync token (optional)' },
1108
+ },
1109
+ required: ['calendar_id'],
1110
+ },
1111
+ },
1112
+ {
1113
+ name: 'get_calendar_event',
1114
+ description: '[Official API + UAT] Get full details of a single calendar event (summary, description, start/end, attendees, location, attachments, meeting link).',
1115
+ inputSchema: {
1116
+ type: 'object',
1117
+ properties: {
1118
+ calendar_id: { type: 'string', description: 'Calendar ID' },
1119
+ event_id: { type: 'string', description: 'Event ID from list_calendar_events' },
1120
+ },
1121
+ required: ['calendar_id', 'event_id'],
1016
1122
  },
1017
1123
  },
1018
1124
 
@@ -1040,6 +1146,13 @@ const text = (s) => ({ content: [{ type: 'text', text: s }] });
1040
1146
  const json = (o) => text(JSON.stringify(o, null, 2));
1041
1147
  const sendResult = (r, desc) => text(r.success ? desc : `Send failed (status: ${r.status})`);
1042
1148
 
1149
+ // Resolver helper: turn document_id / app_token / wiki node / Feishu URL into
1150
+ // a native token. No-op for already-native inputs. See src/resolver.js.
1151
+ async function resolveDocId(input) {
1152
+ if (!input) return input;
1153
+ return resolveToken(input, getOfficialClient());
1154
+ }
1155
+
1043
1156
  async function handleTool(name, args) {
1044
1157
 
1045
1158
  switch (name) {
@@ -1226,32 +1339,21 @@ async function handleTool(name, args) {
1226
1339
  // Get userClient for name resolution fallback (best-effort)
1227
1340
  let uc = null;
1228
1341
  try { uc = await getUserClient(); } catch (_) {}
1229
- const resolvedChatId = await chatIdMapper.resolveToOcId(args.chat_id, official);
1230
1342
 
1231
- // Try bot API first if we resolved an oc_ ID
1343
+ // Path A chat_id that resolves inside bot's / official search scope.
1344
+ const resolvedChatId = await chatIdMapper.resolveToOcId(args.chat_id, official);
1232
1345
  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
- }
1346
+ return json(await official.readMessagesWithFallback(resolvedChatId, msgOpts, uc));
1247
1347
  }
1248
1348
 
1249
- // Bot couldn't resolve the chat name try search_contacts + UAT for external groups
1349
+ // Path Bexternal group discovered only via cookie search_contacts.
1350
+ // When we got here the bot definitely can't see it, so skip bot entirely
1351
+ // and go straight to UAT with a `contacts` via label.
1250
1352
  if (official.hasUAT) {
1251
1353
  if (!uc) try { uc = await getUserClient(); } catch (_) {}
1252
1354
  const contactChatId = await chatIdMapper.resolveViaContacts(args.chat_id, uc);
1253
1355
  if (contactChatId) {
1254
- return json(await official.readMessagesAsUser(contactChatId, msgOpts, uc));
1356
+ return json(await official.readMessagesWithFallback(contactChatId, msgOpts, uc, { skipBot: true, via: 'contacts' }));
1255
1357
  }
1256
1358
  }
1257
1359
 
@@ -1267,56 +1369,70 @@ async function handleTool(name, args) {
1267
1369
  case 'search_docs':
1268
1370
  return json(await getOfficialClient().searchDocs(args.query));
1269
1371
  case 'read_doc':
1270
- return json(await getOfficialClient().readDoc(args.document_id));
1372
+ return json(await getOfficialClient().readDoc(await resolveDocId(args.document_id)));
1271
1373
  case 'get_doc_blocks':
1272
- return json(await getOfficialClient().getDocBlocks(args.document_id));
1374
+ return json(await getOfficialClient().getDocBlocks(await resolveDocId(args.document_id)));
1273
1375
  case 'create_doc': {
1274
- const r = await getOfficialClient().createDoc(args.title, args.folder_id);
1376
+ const r = await getOfficialClient().createDoc(args.title, args.folder_id, {
1377
+ wikiSpaceId: args.wiki_space_id,
1378
+ wikiParentNodeToken: args.wiki_parent_node_token,
1379
+ });
1275
1380
  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}`);
1381
+ const wikiNote = r.wikiNodeToken ? ` [wiki node: ${r.wikiNodeToken}]`
1382
+ : r.wikiAttachTaskId ? ` [wiki attach queued — task_id: ${r.wikiAttachTaskId}]`
1383
+ : r.wikiAttachError ? ` [WARNING: wiki attach failed — ${r.wikiAttachError}. Doc exists in drive root/folder.]`
1384
+ : '';
1385
+ return text(`Document created${ownership}: ${r.documentId}${wikiNote}`);
1277
1386
  }
1278
1387
 
1279
1388
  // --- Official API: Bitable ---
1280
1389
 
1281
1390
  case 'create_bitable': {
1282
- const r = await getOfficialClient().createBitable(args.name, args.folder_id);
1391
+ const r = await getOfficialClient().createBitable(args.name, args.folder_id, {
1392
+ wikiSpaceId: args.wiki_space_id,
1393
+ wikiParentNodeToken: args.wiki_parent_node_token,
1394
+ });
1283
1395
  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 || ''}`);
1396
+ const wikiNote = r.wikiNodeToken ? `\nWiki node: ${r.wikiNodeToken}`
1397
+ : r.wikiAttachTaskId ? `\nWiki attach queued — task_id: ${r.wikiAttachTaskId}`
1398
+ : r.wikiAttachError ? `\nWARNING: wiki attach failed — ${r.wikiAttachError}. Bitable exists in drive root/folder.`
1399
+ : '';
1400
+ return text(`Bitable created${ownership}: ${r.appToken}\nURL: ${r.url || ''}${wikiNote}`);
1285
1401
  }
1286
1402
  case 'list_bitable_tables':
1287
- return json(await getOfficialClient().listBitableTables(args.app_token));
1403
+ return json(await getOfficialClient().listBitableTables(await resolveDocId(args.app_token)));
1288
1404
  case 'create_bitable_table':
1289
- return text(`Table created: ${(await getOfficialClient().createBitableTable(args.app_token, args.name, args.fields)).tableId}`);
1405
+ return text(`Table created: ${(await getOfficialClient().createBitableTable(await resolveDocId(args.app_token), args.name, args.fields)).tableId}`);
1290
1406
  case 'list_bitable_fields':
1291
- return json(await getOfficialClient().listBitableFields(args.app_token, args.table_id));
1407
+ return json(await getOfficialClient().listBitableFields(await resolveDocId(args.app_token), args.table_id));
1292
1408
  case 'create_bitable_field': {
1293
1409
  const config = { field_name: args.field_name, type: args.type };
1294
1410
  if (args.property) config.property = args.property;
1295
- return json(await getOfficialClient().createBitableField(args.app_token, args.table_id, config));
1411
+ return json(await getOfficialClient().createBitableField(await resolveDocId(args.app_token), args.table_id, config));
1296
1412
  }
1297
1413
  case 'update_bitable_field': {
1298
1414
  const config = {};
1299
1415
  if (args.field_name) config.field_name = args.field_name;
1300
1416
  if (args.type) config.type = args.type;
1301
1417
  if (args.property) config.property = args.property;
1302
- return json(await getOfficialClient().updateBitableField(args.app_token, args.table_id, args.field_id, config));
1418
+ return json(await getOfficialClient().updateBitableField(await resolveDocId(args.app_token), args.table_id, args.field_id, config));
1303
1419
  }
1304
1420
  case 'delete_bitable_field': {
1305
- const r = await getOfficialClient().deleteBitableField(args.app_token, args.table_id, args.field_id);
1421
+ const r = await getOfficialClient().deleteBitableField(await resolveDocId(args.app_token), args.table_id, args.field_id);
1306
1422
  return text(r.deleted ? `Field ${r.fieldId} deleted` : `Field deletion returned deleted=${r.deleted}`);
1307
1423
  }
1308
1424
  case 'list_bitable_views':
1309
- return json(await getOfficialClient().listBitableViews(args.app_token, args.table_id));
1425
+ return json(await getOfficialClient().listBitableViews(await resolveDocId(args.app_token), args.table_id));
1310
1426
  case 'search_bitable_records':
1311
- return json(await getOfficialClient().searchBitableRecords(args.app_token, args.table_id, {
1427
+ return json(await getOfficialClient().searchBitableRecords(await resolveDocId(args.app_token), args.table_id, {
1312
1428
  filter: args.filter, sort: args.sort, pageSize: args.page_size,
1313
1429
  }));
1314
1430
  case 'batch_create_bitable_records':
1315
- return json(await getOfficialClient().batchCreateBitableRecords(args.app_token, args.table_id, args.records));
1431
+ return json(await getOfficialClient().batchCreateBitableRecords(await resolveDocId(args.app_token), args.table_id, args.records));
1316
1432
  case 'batch_update_bitable_records':
1317
- return json(await getOfficialClient().batchUpdateBitableRecords(args.app_token, args.table_id, args.records));
1433
+ return json(await getOfficialClient().batchUpdateBitableRecords(await resolveDocId(args.app_token), args.table_id, args.records));
1318
1434
  case 'batch_delete_bitable_records':
1319
- return json(await getOfficialClient().batchDeleteBitableRecords(args.app_token, args.table_id, args.record_ids));
1435
+ return json(await getOfficialClient().batchDeleteBitableRecords(await resolveDocId(args.app_token), args.table_id, args.record_ids));
1320
1436
 
1321
1437
  // --- Official API: Wiki ---
1322
1438
 
@@ -1394,29 +1510,54 @@ async function handleTool(name, args) {
1394
1510
 
1395
1511
  // --- Official API: Doc Block Editing ---
1396
1512
 
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));
1513
+ case 'create_doc_block': {
1514
+ const official = getOfficialClient();
1515
+ const docId = await resolveDocId(args.document_id);
1516
+ // Image shortcut: if image_path or image_token is provided, orchestrate the
1517
+ // 3-step docx image creation. Mutually exclusive with children.
1518
+ if (args.image_path || args.image_token) {
1519
+ if (args.children) return text('create_doc_block: pass children OR image_path OR image_token, not both.');
1520
+ const r = await official.createDocBlockWithImage(docId, args.parent_block_id, {
1521
+ imagePath: args.image_path,
1522
+ imageToken: args.image_token,
1523
+ index: args.index,
1524
+ });
1525
+ return json(r);
1526
+ }
1527
+ if (!args.children) return text('create_doc_block: children (generic blocks), image_path, or image_token is required.');
1528
+ return json(await official.createDocBlock(docId, args.parent_block_id, args.children, args.index));
1529
+ }
1530
+ case 'update_doc_block': {
1531
+ const official = getOfficialClient();
1532
+ const docId = await resolveDocId(args.document_id);
1533
+ if (args.image_token && args.update_body) {
1534
+ return text('update_doc_block: pass image_token OR update_body, not both.');
1535
+ }
1536
+ if (args.image_token) {
1537
+ return json(await official.updateDocBlockImage(docId, args.block_id, args.image_token));
1538
+ }
1539
+ if (!args.update_body) return text('update_doc_block: update_body or image_token is required.');
1540
+ return json(await official.updateDocBlock(docId, args.block_id, args.update_body));
1541
+ }
1401
1542
  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}`);
1543
+ return text(`Blocks deleted: ${(await getOfficialClient().deleteDocBlocks(await resolveDocId(args.document_id), args.parent_block_id, args.start_index, args.end_index)).deleted}`);
1403
1544
 
1404
1545
  // --- Official API: Bitable Additional ---
1405
1546
 
1406
1547
  case 'get_bitable_record':
1407
- return json(await getOfficialClient().getBitableRecord(args.app_token, args.table_id, args.record_id));
1548
+ return json(await getOfficialClient().getBitableRecord(await resolveDocId(args.app_token), args.table_id, args.record_id));
1408
1549
  case 'delete_bitable_table':
1409
- return text(`Table deleted: ${(await getOfficialClient().deleteBitableTable(args.app_token, args.table_id)).deleted}`);
1550
+ return text(`Table deleted: ${(await getOfficialClient().deleteBitableTable(await resolveDocId(args.app_token), args.table_id)).deleted}`);
1410
1551
  case 'get_bitable_meta':
1411
- return json(await getOfficialClient().getBitableMeta(args.app_token));
1552
+ return json(await getOfficialClient().getBitableMeta(await resolveDocId(args.app_token)));
1412
1553
  case 'update_bitable_table':
1413
- return text(`Table renamed: ${(await getOfficialClient().updateBitableTable(args.app_token, args.table_id, args.name)).name}`);
1554
+ return text(`Table renamed: ${(await getOfficialClient().updateBitableTable(await resolveDocId(args.app_token), args.table_id, args.name)).name}`);
1414
1555
  case 'create_bitable_view':
1415
- return json(await getOfficialClient().createBitableView(args.app_token, args.table_id, args.view_name, args.view_type));
1556
+ return json(await getOfficialClient().createBitableView(await resolveDocId(args.app_token), args.table_id, args.view_name, args.view_type));
1416
1557
  case 'delete_bitable_view':
1417
- return text(`View deleted: ${(await getOfficialClient().deleteBitableView(args.app_token, args.table_id, args.view_id)).deleted}`);
1558
+ return text(`View deleted: ${(await getOfficialClient().deleteBitableView(await resolveDocId(args.app_token), args.table_id, args.view_id)).deleted}`);
1418
1559
  case 'copy_bitable':
1419
- return json(await getOfficialClient().copyBitable(args.app_token, args.name, args.folder_id));
1560
+ return json(await getOfficialClient().copyBitable(await resolveDocId(args.app_token), args.name, args.folder_id));
1420
1561
 
1421
1562
  // --- Official API: Drive File Operations ---
1422
1563
 
@@ -1428,17 +1569,59 @@ async function handleTool(name, args) {
1428
1569
  return text(`File deleted: task=${(await getOfficialClient().deleteFile(args.file_token, args.type)).taskId}`);
1429
1570
 
1430
1571
  case 'download_image': {
1431
- const r = await getOfficialClient().downloadMessageResource(args.message_id, args.image_key, 'image');
1572
+ const official = getOfficialClient();
1573
+ let r;
1574
+ let source;
1575
+ if (args.image_token) {
1576
+ // Docx image mode — doc_token may be a URL / wiki node; resolve it.
1577
+ const docToken = args.doc_token ? await resolveDocId(args.doc_token) : undefined;
1578
+ r = await official.downloadDocImage(args.image_token, docToken);
1579
+ source = docToken ? `docx ${docToken}` : 'drive media';
1580
+ } else if (args.message_id && args.image_key) {
1581
+ r = await official.downloadMessageResource(args.message_id, args.image_key, 'image');
1582
+ source = `message ${args.message_id}`;
1583
+ } else {
1584
+ return text('download_image requires either (message_id + image_key) for chat images, or (image_token, optionally with doc_token) for docx images.');
1585
+ }
1432
1586
  // 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
1587
  return {
1435
1588
  content: [
1436
- { type: 'text', text: `Image downloaded (${r.viaUser ? 'as user' : 'as app'}, ${r.bytes} bytes, ${r.mimeType}):` },
1589
+ { type: 'text', text: `Image downloaded from ${source} (${r.viaUser ? 'as user' : 'as app'}, ${r.bytes} bytes, ${r.mimeType}):` },
1437
1590
  { type: 'image', data: r.base64, mimeType: r.mimeType },
1438
1591
  ],
1439
1592
  };
1440
1593
  }
1441
1594
 
1595
+ // --- Wiki Node Resolution (v1.3.4) ---
1596
+ case 'get_wiki_node': {
1597
+ // Accept either a bare wiki node token or a full /wiki/ URL — parse first.
1598
+ const parsed = parseFeishuInput(args.node_token);
1599
+ const token = (parsed.kind === 'wiki' || parsed.kind === 'raw') ? parsed.token : args.node_token;
1600
+ return json(await getOfficialClient().getWikiNode(token));
1601
+ }
1602
+
1603
+ // --- OKR (v1.3.4) ---
1604
+ case 'list_user_okrs':
1605
+ return json(await getOfficialClient().listUserOkrs(args.user_id, {
1606
+ periodIds: args.period_ids, offset: args.offset, limit: args.limit, lang: args.lang,
1607
+ userIdType: args.user_id_type,
1608
+ }));
1609
+ case 'get_okrs':
1610
+ return json(await getOfficialClient().getOkrs(args.okr_ids, { lang: args.lang, userIdType: args.user_id_type }));
1611
+ case 'list_okr_periods':
1612
+ return json(await getOfficialClient().listOkrPeriods({ pageSize: args.page_size, pageToken: args.page_token }));
1613
+
1614
+ // --- Calendar (v1.3.4) ---
1615
+ case 'list_calendars':
1616
+ return json(await getOfficialClient().listCalendars({ pageSize: args.page_size, pageToken: args.page_token, syncToken: args.sync_token }));
1617
+ case 'list_calendar_events':
1618
+ return json(await getOfficialClient().listCalendarEvents(args.calendar_id, {
1619
+ startTime: args.start_time, endTime: args.end_time,
1620
+ pageSize: args.page_size, pageToken: args.page_token, syncToken: args.sync_token,
1621
+ }));
1622
+ case 'get_calendar_event':
1623
+ return json(await getOfficialClient().getCalendarEvent(args.calendar_id, args.event_id));
1624
+
1442
1625
  default:
1443
1626
  return text(`Unknown tool: ${name}`);
1444
1627
  }
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.');