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/.claude-plugin/plugin.json +2 -2
- package/CHANGELOG.md +21 -0
- package/README.md +23 -6
- package/package.json +2 -2
- package/scripts/test-uat-race-child.js +24 -0
- package/scripts/test-uat-race.js +68 -0
- package/skills/feishu-user-plugin/SKILL.md +3 -3
- package/skills/feishu-user-plugin/references/CLAUDE.md +117 -5
- package/src/doc-blocks.js +70 -0
- package/src/error-codes.js +78 -0
- package/src/index.js +325 -73
- package/src/oauth.js +6 -1
- package/src/official.js +717 -43
- package/src/resolver.js +151 -0
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.
|
|
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: '
|
|
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'
|
|
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
|
|
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: '
|
|
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'
|
|
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
|
|
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
|
-
|
|
1013
|
-
|
|
1133
|
+
calendar_id: { type: 'string', description: 'Calendar ID' },
|
|
1134
|
+
event_id: { type: 'string', description: 'Event ID from list_calendar_events' },
|
|
1014
1135
|
},
|
|
1015
|
-
required: ['
|
|
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) =>
|
|
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
|
-
|
|
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 = {
|
|
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
|
-
//
|
|
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
|
-
|
|
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
|
-
//
|
|
1385
|
+
// Path B — external 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.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1399
|
-
|
|
1400
|
-
|
|
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
|
|
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
|
-
|
|
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.');
|