feishu-user-plugin 1.3.2 → 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/.claude-plugin/plugin.json +2 -2
- package/CHANGELOG.md +37 -0
- package/README.md +19 -4
- package/package.json +2 -2
- package/skills/feishu-user-plugin/SKILL.md +3 -3
- package/skills/feishu-user-plugin/references/CLAUDE.md +114 -5
- package/src/client.js +4 -4
- package/src/doc-blocks.js +70 -0
- package/src/error-codes.js +78 -0
- package/src/index.js +318 -68
- package/src/oauth.js +6 -1
- package/src/official.js +584 -15
- package/src/resolver.js +151 -0
- package/src/utils.js +13 -0
package/src/index.js
CHANGED
|
@@ -1,4 +1,14 @@
|
|
|
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
|
+
|
|
2
12
|
const { Server } = require('@modelcontextprotocol/sdk/server/index.js');
|
|
3
13
|
const { StdioServerTransport } = require('@modelcontextprotocol/sdk/server/stdio.js');
|
|
4
14
|
const {
|
|
@@ -11,6 +21,7 @@ const path = require('path');
|
|
|
11
21
|
require('dotenv').config({ path: path.join(__dirname, '..', '.env') });
|
|
12
22
|
const { LarkUserClient } = require('./client');
|
|
13
23
|
const { LarkOfficialClient } = require('./official');
|
|
24
|
+
const { resolveToObj, resolveToken, parseFeishuInput } = require('./resolver');
|
|
14
25
|
|
|
15
26
|
// --- Chat ID Mapper ---
|
|
16
27
|
|
|
@@ -424,12 +435,14 @@ const TOOLS = [
|
|
|
424
435
|
},
|
|
425
436
|
{
|
|
426
437
|
name: 'create_doc',
|
|
427
|
-
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.',
|
|
428
439
|
inputSchema: {
|
|
429
440
|
type: 'object',
|
|
430
441
|
properties: {
|
|
431
442
|
title: { type: 'string', description: 'Document title' },
|
|
432
|
-
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)' },
|
|
433
446
|
},
|
|
434
447
|
required: ['title'],
|
|
435
448
|
},
|
|
@@ -438,12 +451,14 @@ const TOOLS = [
|
|
|
438
451
|
// ========== Bitable — Official API ==========
|
|
439
452
|
{
|
|
440
453
|
name: 'create_bitable',
|
|
441
|
-
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).',
|
|
442
455
|
inputSchema: {
|
|
443
456
|
type: 'object',
|
|
444
457
|
properties: {
|
|
445
458
|
name: { type: 'string', description: 'Bitable app name' },
|
|
446
|
-
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)' },
|
|
447
462
|
},
|
|
448
463
|
},
|
|
449
464
|
},
|
|
@@ -820,29 +835,32 @@ const TOOLS = [
|
|
|
820
835
|
// ========== Docs — Block Editing ==========
|
|
821
836
|
{
|
|
822
837
|
name: 'create_doc_block',
|
|
823
|
-
description: '[Official API] Insert content blocks into a document.
|
|
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.',
|
|
824
839
|
inputSchema: {
|
|
825
840
|
type: 'object',
|
|
826
841
|
properties: {
|
|
827
|
-
document_id: { type: 'string', description: 'Document ID' },
|
|
842
|
+
document_id: { type: 'string', description: 'Document ID, wiki node token, or Feishu URL' },
|
|
828
843
|
parent_block_id: { type: 'string', description: 'Parent block ID (use document_id for root)' },
|
|
829
|
-
children: { type: 'array', description: '
|
|
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)' },
|
|
830
847
|
index: { type: 'number', description: 'Insert position (optional, appends to end if omitted)' },
|
|
831
848
|
},
|
|
832
|
-
required: ['document_id', 'parent_block_id'
|
|
849
|
+
required: ['document_id', 'parent_block_id'],
|
|
833
850
|
},
|
|
834
851
|
},
|
|
835
852
|
{
|
|
836
853
|
name: 'update_doc_block',
|
|
837
|
-
description: '[Official API] Update a specific block in a document
|
|
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.',
|
|
838
855
|
inputSchema: {
|
|
839
856
|
type: 'object',
|
|
840
857
|
properties: {
|
|
841
|
-
document_id: { type: 'string', description: 'Document ID' },
|
|
858
|
+
document_id: { type: 'string', description: 'Document ID, wiki node token, or Feishu URL' },
|
|
842
859
|
block_id: { type: 'string', description: 'Block ID to update' },
|
|
843
|
-
update_body: { type: 'object', description: '
|
|
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}}' },
|
|
844
862
|
},
|
|
845
|
-
required: ['document_id', 'block_id'
|
|
863
|
+
required: ['document_id', 'block_id'],
|
|
846
864
|
},
|
|
847
865
|
},
|
|
848
866
|
{
|
|
@@ -992,6 +1010,118 @@ const TOOLS = [
|
|
|
992
1010
|
},
|
|
993
1011
|
},
|
|
994
1012
|
|
|
1013
|
+
// ========== Message Resources (Image/File Download) ==========
|
|
1014
|
+
{
|
|
1015
|
+
name: 'download_image',
|
|
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.',
|
|
1017
|
+
inputSchema: {
|
|
1018
|
+
type: 'object',
|
|
1019
|
+
properties: {
|
|
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' },
|
|
1024
|
+
},
|
|
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'],
|
|
1122
|
+
},
|
|
1123
|
+
},
|
|
1124
|
+
|
|
995
1125
|
];
|
|
996
1126
|
|
|
997
1127
|
// --- Server ---
|
|
@@ -1016,6 +1146,13 @@ const text = (s) => ({ content: [{ type: 'text', text: s }] });
|
|
|
1016
1146
|
const json = (o) => text(JSON.stringify(o, null, 2));
|
|
1017
1147
|
const sendResult = (r, desc) => text(r.success ? desc : `Send failed (status: ${r.status})`);
|
|
1018
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
|
+
|
|
1019
1156
|
async function handleTool(name, args) {
|
|
1020
1157
|
|
|
1021
1158
|
switch (name) {
|
|
@@ -1139,9 +1276,20 @@ async function handleTool(name, args) {
|
|
|
1139
1276
|
parts.push(` ${status.message}`);
|
|
1140
1277
|
} catch (e) { parts.push(`Cookie: ${e.message}`); }
|
|
1141
1278
|
const hasApp = !!(process.env.LARK_APP_ID && process.env.LARK_APP_SECRET);
|
|
1142
|
-
|
|
1143
|
-
|
|
1144
|
-
|
|
1279
|
+
if (!hasApp) {
|
|
1280
|
+
parts.push(`App credentials: Not set`);
|
|
1281
|
+
} else {
|
|
1282
|
+
const official = getOfficialClient();
|
|
1283
|
+
const probe = await official.verifyApp();
|
|
1284
|
+
if (probe.valid) {
|
|
1285
|
+
const nameBit = probe.appName ? ` "${probe.appName}"` : '';
|
|
1286
|
+
parts.push(`App credentials: Valid — app_id=${probe.appId}${nameBit}`);
|
|
1287
|
+
} else {
|
|
1288
|
+
parts.push(`App credentials: INVALID — app_id=${probe.appId} rejected by Feishu (${probe.error})`);
|
|
1289
|
+
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.`);
|
|
1290
|
+
}
|
|
1291
|
+
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)'}`);
|
|
1292
|
+
}
|
|
1145
1293
|
return text(parts.join('\n'));
|
|
1146
1294
|
}
|
|
1147
1295
|
|
|
@@ -1191,32 +1339,21 @@ async function handleTool(name, args) {
|
|
|
1191
1339
|
// Get userClient for name resolution fallback (best-effort)
|
|
1192
1340
|
let uc = null;
|
|
1193
1341
|
try { uc = await getUserClient(); } catch (_) {}
|
|
1194
|
-
const resolvedChatId = await chatIdMapper.resolveToOcId(args.chat_id, official);
|
|
1195
1342
|
|
|
1196
|
-
//
|
|
1343
|
+
// Path A — chat_id that resolves inside bot's / official search scope.
|
|
1344
|
+
const resolvedChatId = await chatIdMapper.resolveToOcId(args.chat_id, official);
|
|
1197
1345
|
if (resolvedChatId) {
|
|
1198
|
-
|
|
1199
|
-
return json(await official.readMessages(resolvedChatId, msgOpts, uc));
|
|
1200
|
-
} catch (botErr) {
|
|
1201
|
-
// Bot API failed (e.g. bot not in group, no permission) — fall through to UAT
|
|
1202
|
-
console.error(`[feishu-user-plugin] read_messages bot API failed for ${resolvedChatId}: ${botErr.message}`);
|
|
1203
|
-
if (official.hasUAT) {
|
|
1204
|
-
try {
|
|
1205
|
-
return json(await official.readMessagesAsUser(resolvedChatId, msgOpts, uc));
|
|
1206
|
-
} catch (uatErr) {
|
|
1207
|
-
console.error(`[feishu-user-plugin] read_messages UAT fallback also failed for ${resolvedChatId}: ${uatErr.message}`);
|
|
1208
|
-
}
|
|
1209
|
-
}
|
|
1210
|
-
throw botErr; // Re-throw original error if UAT also failed
|
|
1211
|
-
}
|
|
1346
|
+
return json(await official.readMessagesWithFallback(resolvedChatId, msgOpts, uc));
|
|
1212
1347
|
}
|
|
1213
1348
|
|
|
1214
|
-
//
|
|
1349
|
+
// Path B — external 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.
|
|
1215
1352
|
if (official.hasUAT) {
|
|
1216
1353
|
if (!uc) try { uc = await getUserClient(); } catch (_) {}
|
|
1217
1354
|
const contactChatId = await chatIdMapper.resolveViaContacts(args.chat_id, uc);
|
|
1218
1355
|
if (contactChatId) {
|
|
1219
|
-
return json(await official.
|
|
1356
|
+
return json(await official.readMessagesWithFallback(contactChatId, msgOpts, uc, { skipBot: true, via: 'contacts' }));
|
|
1220
1357
|
}
|
|
1221
1358
|
}
|
|
1222
1359
|
|
|
@@ -1232,57 +1369,70 @@ async function handleTool(name, args) {
|
|
|
1232
1369
|
case 'search_docs':
|
|
1233
1370
|
return json(await getOfficialClient().searchDocs(args.query));
|
|
1234
1371
|
case 'read_doc':
|
|
1235
|
-
return json(await getOfficialClient().readDoc(args.document_id));
|
|
1372
|
+
return json(await getOfficialClient().readDoc(await resolveDocId(args.document_id)));
|
|
1236
1373
|
case 'get_doc_blocks':
|
|
1237
|
-
return json(await getOfficialClient().getDocBlocks(args.document_id));
|
|
1374
|
+
return json(await getOfficialClient().getDocBlocks(await resolveDocId(args.document_id)));
|
|
1238
1375
|
case 'create_doc': {
|
|
1239
|
-
const
|
|
1240
|
-
|
|
1241
|
-
|
|
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
|
+
});
|
|
1380
|
+
const ownership = r.viaUser ? ' (as user)' : ' (as app — UAT unavailable or failed; document owned by the app, not you)';
|
|
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}`);
|
|
1242
1386
|
}
|
|
1243
1387
|
|
|
1244
1388
|
// --- Official API: Bitable ---
|
|
1245
1389
|
|
|
1246
1390
|
case 'create_bitable': {
|
|
1247
|
-
const
|
|
1248
|
-
|
|
1249
|
-
|
|
1250
|
-
|
|
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
|
+
});
|
|
1395
|
+
const ownership = r.viaUser ? ' (as user)' : ' (as app — UAT unavailable or failed; bitable owned by the app, not you)';
|
|
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}`);
|
|
1251
1401
|
}
|
|
1252
1402
|
case 'list_bitable_tables':
|
|
1253
|
-
return json(await getOfficialClient().listBitableTables(args.app_token));
|
|
1403
|
+
return json(await getOfficialClient().listBitableTables(await resolveDocId(args.app_token)));
|
|
1254
1404
|
case 'create_bitable_table':
|
|
1255
|
-
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}`);
|
|
1256
1406
|
case 'list_bitable_fields':
|
|
1257
|
-
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));
|
|
1258
1408
|
case 'create_bitable_field': {
|
|
1259
1409
|
const config = { field_name: args.field_name, type: args.type };
|
|
1260
1410
|
if (args.property) config.property = args.property;
|
|
1261
|
-
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));
|
|
1262
1412
|
}
|
|
1263
1413
|
case 'update_bitable_field': {
|
|
1264
1414
|
const config = {};
|
|
1265
1415
|
if (args.field_name) config.field_name = args.field_name;
|
|
1266
1416
|
if (args.type) config.type = args.type;
|
|
1267
1417
|
if (args.property) config.property = args.property;
|
|
1268
|
-
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));
|
|
1269
1419
|
}
|
|
1270
1420
|
case 'delete_bitable_field': {
|
|
1271
|
-
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);
|
|
1272
1422
|
return text(r.deleted ? `Field ${r.fieldId} deleted` : `Field deletion returned deleted=${r.deleted}`);
|
|
1273
1423
|
}
|
|
1274
1424
|
case 'list_bitable_views':
|
|
1275
|
-
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));
|
|
1276
1426
|
case 'search_bitable_records':
|
|
1277
|
-
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, {
|
|
1278
1428
|
filter: args.filter, sort: args.sort, pageSize: args.page_size,
|
|
1279
1429
|
}));
|
|
1280
1430
|
case 'batch_create_bitable_records':
|
|
1281
|
-
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));
|
|
1282
1432
|
case 'batch_update_bitable_records':
|
|
1283
|
-
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));
|
|
1284
1434
|
case 'batch_delete_bitable_records':
|
|
1285
|
-
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));
|
|
1286
1436
|
|
|
1287
1437
|
// --- Official API: Wiki ---
|
|
1288
1438
|
|
|
@@ -1298,9 +1448,9 @@ async function handleTool(name, args) {
|
|
|
1298
1448
|
case 'list_files':
|
|
1299
1449
|
return json(await getOfficialClient().listFiles(args.folder_token));
|
|
1300
1450
|
case 'create_folder': {
|
|
1301
|
-
const
|
|
1302
|
-
const ownership =
|
|
1303
|
-
return text(`Folder created${ownership}: ${
|
|
1451
|
+
const r = await getOfficialClient().createFolder(args.name, args.parent_token);
|
|
1452
|
+
const ownership = r.viaUser ? ' (as user)' : ' (as app — UAT unavailable or failed; folder owned by the app, not you)';
|
|
1453
|
+
return text(`Folder created${ownership}: ${r.token}`);
|
|
1304
1454
|
}
|
|
1305
1455
|
|
|
1306
1456
|
// --- Official API: Contact ---
|
|
@@ -1360,29 +1510,54 @@ async function handleTool(name, args) {
|
|
|
1360
1510
|
|
|
1361
1511
|
// --- Official API: Doc Block Editing ---
|
|
1362
1512
|
|
|
1363
|
-
case 'create_doc_block':
|
|
1364
|
-
|
|
1365
|
-
|
|
1366
|
-
|
|
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
|
+
}
|
|
1367
1542
|
case 'delete_doc_blocks':
|
|
1368
|
-
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}`);
|
|
1369
1544
|
|
|
1370
1545
|
// --- Official API: Bitable Additional ---
|
|
1371
1546
|
|
|
1372
1547
|
case 'get_bitable_record':
|
|
1373
|
-
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));
|
|
1374
1549
|
case 'delete_bitable_table':
|
|
1375
|
-
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}`);
|
|
1376
1551
|
case 'get_bitable_meta':
|
|
1377
|
-
return json(await getOfficialClient().getBitableMeta(args.app_token));
|
|
1552
|
+
return json(await getOfficialClient().getBitableMeta(await resolveDocId(args.app_token)));
|
|
1378
1553
|
case 'update_bitable_table':
|
|
1379
|
-
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}`);
|
|
1380
1555
|
case 'create_bitable_view':
|
|
1381
|
-
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));
|
|
1382
1557
|
case 'delete_bitable_view':
|
|
1383
|
-
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}`);
|
|
1384
1559
|
case 'copy_bitable':
|
|
1385
|
-
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));
|
|
1386
1561
|
|
|
1387
1562
|
// --- Official API: Drive File Operations ---
|
|
1388
1563
|
|
|
@@ -1393,6 +1568,60 @@ async function handleTool(name, args) {
|
|
|
1393
1568
|
case 'delete_file':
|
|
1394
1569
|
return text(`File deleted: task=${(await getOfficialClient().deleteFile(args.file_token, args.type)).taskId}`);
|
|
1395
1570
|
|
|
1571
|
+
case 'download_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
|
+
}
|
|
1586
|
+
// Return as MCP image content so the model sees the pixels directly.
|
|
1587
|
+
return {
|
|
1588
|
+
content: [
|
|
1589
|
+
{ type: 'text', text: `Image downloaded from ${source} (${r.viaUser ? 'as user' : 'as app'}, ${r.bytes} bytes, ${r.mimeType}):` },
|
|
1590
|
+
{ type: 'image', data: r.base64, mimeType: r.mimeType },
|
|
1591
|
+
],
|
|
1592
|
+
};
|
|
1593
|
+
}
|
|
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
|
+
|
|
1396
1625
|
default:
|
|
1397
1626
|
return text(`Unknown tool: ${name}`);
|
|
1398
1627
|
}
|
|
@@ -1422,6 +1651,27 @@ async function main() {
|
|
|
1422
1651
|
if (!hasCookie) console.error('[feishu-user-plugin] WARNING: LARK_COOKIE not set — user identity tools (send_to_user, etc.) will fail');
|
|
1423
1652
|
if (!hasApp) console.error('[feishu-user-plugin] WARNING: LARK_APP_ID/SECRET not set — official API tools (read_messages, docs, etc.) will fail');
|
|
1424
1653
|
if (!hasUAT) console.error('[feishu-user-plugin] WARNING: LARK_USER_ACCESS_TOKEN not set — P2P chat reading (read_p2p_messages) will fail');
|
|
1654
|
+
|
|
1655
|
+
// Validate APP_ID/SECRET against Feishu before serving any tool calls.
|
|
1656
|
+
// Catches the "Claude filled in a wrong/stale APP_ID during install" failure mode
|
|
1657
|
+
// that otherwise surfaces as cryptic 401s on every Official API call (looks like
|
|
1658
|
+
// "MCP 掉线" to the user). Non-blocking — we warn but still serve, because the
|
|
1659
|
+
// user may only need user-identity (cookie) tools.
|
|
1660
|
+
if (hasApp) {
|
|
1661
|
+
try {
|
|
1662
|
+
const probe = await getOfficialClient().verifyApp();
|
|
1663
|
+
if (probe.valid) {
|
|
1664
|
+
const nameBit = probe.appName ? ` "${probe.appName}"` : '';
|
|
1665
|
+
console.error(`[feishu-user-plugin] App verified: ${probe.appId}${nameBit}`);
|
|
1666
|
+
} else {
|
|
1667
|
+
console.error(`[feishu-user-plugin] ERROR: LARK_APP_ID=${probe.appId} was REJECTED by Feishu (${probe.error}).`);
|
|
1668
|
+
console.error('[feishu-user-plugin] → Every Official API tool call will fail. Likely wrong/stale APP_ID.');
|
|
1669
|
+
console.error('[feishu-user-plugin] → Re-run the install prompt from team-skills/plugins/feishu-user-plugin/README.md to get the correct credentials.');
|
|
1670
|
+
}
|
|
1671
|
+
} catch (e) {
|
|
1672
|
+
console.error(`[feishu-user-plugin] WARNING: Could not verify APP_ID (${e.message}); network issue or cold start. Proceeding anyway.`);
|
|
1673
|
+
}
|
|
1674
|
+
}
|
|
1425
1675
|
}
|
|
1426
1676
|
|
|
1427
1677
|
main().catch(console.error);
|
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
|
-
|
|
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.');
|