feishu-user-plugin 1.3.4 → 1.3.6
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/README.md +25 -11
- 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 +39 -6
- package/src/doc-blocks.js +20 -5
- package/src/index.js +332 -30
- package/src/oauth.js +4 -1
- package/src/official.js +471 -53
package/src/index.js
CHANGED
|
@@ -113,17 +113,53 @@ class ChatIdMapper {
|
|
|
113
113
|
}
|
|
114
114
|
}
|
|
115
115
|
|
|
116
|
-
// --- Client Singletons ---
|
|
116
|
+
// --- Client Singletons + Profiles ---
|
|
117
117
|
|
|
118
118
|
let userClient = null;
|
|
119
119
|
let officialClient = null;
|
|
120
120
|
const chatIdMapper = new ChatIdMapper();
|
|
121
121
|
|
|
122
|
+
// Profile system (v1.3.6).
|
|
123
|
+
// Default behaviour is identical to pre-1.3.6: LARK_COOKIE / LARK_APP_ID / etc.
|
|
124
|
+
// from process.env act as profile "default". To register more profiles, set
|
|
125
|
+
// LARK_PROFILES_JSON in the MCP env to a JSON object:
|
|
126
|
+
// { "alt": { "LARK_COOKIE": "...", "LARK_APP_ID": "...", ... }, ... }
|
|
127
|
+
// Then call switch_profile to change which credential set is active.
|
|
128
|
+
let currentProfile = 'default';
|
|
129
|
+
|
|
130
|
+
function loadProfileMap() {
|
|
131
|
+
const raw = process.env.LARK_PROFILES_JSON;
|
|
132
|
+
if (!raw) return {};
|
|
133
|
+
try {
|
|
134
|
+
const parsed = JSON.parse(raw);
|
|
135
|
+
if (parsed && typeof parsed === 'object') return parsed;
|
|
136
|
+
} catch (e) {
|
|
137
|
+
console.error(`[feishu-user-plugin] LARK_PROFILES_JSON parse failed: ${e.message}`);
|
|
138
|
+
}
|
|
139
|
+
return {};
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function profileEnv(name) {
|
|
143
|
+
if (name === 'default') {
|
|
144
|
+
return {
|
|
145
|
+
LARK_COOKIE: process.env.LARK_COOKIE,
|
|
146
|
+
LARK_APP_ID: process.env.LARK_APP_ID,
|
|
147
|
+
LARK_APP_SECRET: process.env.LARK_APP_SECRET,
|
|
148
|
+
LARK_USER_ACCESS_TOKEN: process.env.LARK_USER_ACCESS_TOKEN,
|
|
149
|
+
LARK_USER_REFRESH_TOKEN: process.env.LARK_USER_REFRESH_TOKEN,
|
|
150
|
+
};
|
|
151
|
+
}
|
|
152
|
+
const profiles = loadProfileMap();
|
|
153
|
+
if (!profiles[name]) throw new Error(`Profile "${name}" not found. Available: ${['default', ...Object.keys(profiles)].join(', ')}`);
|
|
154
|
+
return profiles[name];
|
|
155
|
+
}
|
|
156
|
+
|
|
122
157
|
async function getUserClient() {
|
|
123
158
|
if (userClient) return userClient;
|
|
124
|
-
const
|
|
159
|
+
const env = profileEnv(currentProfile);
|
|
160
|
+
const cookie = env.LARK_COOKIE;
|
|
125
161
|
if (!cookie) throw new Error(
|
|
126
|
-
|
|
162
|
+
`LARK_COOKIE not set for profile "${currentProfile}". To fix:\n` +
|
|
127
163
|
'1. Open https://www.feishu.cn/messenger/ and log in\n' +
|
|
128
164
|
'2. DevTools → Network tab → Disable cache → Reload → Click first request → Request Headers → Cookie → Copy value\n' +
|
|
129
165
|
' (Do NOT use document.cookie or Application→Cookies — they miss HttpOnly cookies like session/sl_session)\n' +
|
|
@@ -137,21 +173,52 @@ async function getUserClient() {
|
|
|
137
173
|
|
|
138
174
|
function getOfficialClient() {
|
|
139
175
|
if (officialClient) return officialClient;
|
|
140
|
-
const
|
|
141
|
-
const
|
|
176
|
+
const env = profileEnv(currentProfile);
|
|
177
|
+
const appId = env.LARK_APP_ID;
|
|
178
|
+
const appSecret = env.LARK_APP_SECRET;
|
|
142
179
|
if (!appId || !appSecret) throw new Error(
|
|
143
|
-
|
|
180
|
+
`LARK_APP_ID and LARK_APP_SECRET not set for profile "${currentProfile}".\n` +
|
|
144
181
|
'For team members: these should be pre-filled in your .mcp.json. Check that the config was copied correctly from the team-skills README.\n' +
|
|
145
182
|
'For external users: create a Custom App at https://open.feishu.cn/app, get the App ID and App Secret, add them to your .mcp.json env.'
|
|
146
183
|
);
|
|
184
|
+
// Honor profile-specific UAT env if present (LarkOfficialClient.loadUAT uses
|
|
185
|
+
// process.env directly; we patch the env temporarily for non-default profiles)
|
|
186
|
+
const prevUAT = process.env.LARK_USER_ACCESS_TOKEN;
|
|
187
|
+
const prevRT = process.env.LARK_USER_REFRESH_TOKEN;
|
|
188
|
+
if (currentProfile !== 'default') {
|
|
189
|
+
if (env.LARK_USER_ACCESS_TOKEN) process.env.LARK_USER_ACCESS_TOKEN = env.LARK_USER_ACCESS_TOKEN;
|
|
190
|
+
if (env.LARK_USER_REFRESH_TOKEN) process.env.LARK_USER_REFRESH_TOKEN = env.LARK_USER_REFRESH_TOKEN;
|
|
191
|
+
}
|
|
147
192
|
officialClient = new LarkOfficialClient(appId, appSecret);
|
|
148
193
|
officialClient.loadUAT();
|
|
194
|
+
if (currentProfile !== 'default') {
|
|
195
|
+
process.env.LARK_USER_ACCESS_TOKEN = prevUAT;
|
|
196
|
+
process.env.LARK_USER_REFRESH_TOKEN = prevRT;
|
|
197
|
+
}
|
|
149
198
|
return officialClient;
|
|
150
199
|
}
|
|
151
200
|
|
|
152
201
|
// --- Tool Definitions ---
|
|
153
202
|
|
|
154
203
|
const TOOLS = [
|
|
204
|
+
// ========== Profile management (v1.3.6) ==========
|
|
205
|
+
{
|
|
206
|
+
name: 'list_profiles',
|
|
207
|
+
description: '[Plugin] List all available identity profiles (sets of LARK_COOKIE/APP_ID/APP_SECRET/UAT). The "default" profile uses the top-level env vars; additional profiles come from LARK_PROFILES_JSON. Marks the currently active profile.',
|
|
208
|
+
inputSchema: { type: 'object', properties: {} },
|
|
209
|
+
},
|
|
210
|
+
{
|
|
211
|
+
name: 'switch_profile',
|
|
212
|
+
description: '[Plugin] Switch the active identity profile. Subsequent tool calls use the new profile\'s credentials. Cached client instances are reset so the next call rebuilds against the new creds.',
|
|
213
|
+
inputSchema: {
|
|
214
|
+
type: 'object',
|
|
215
|
+
properties: {
|
|
216
|
+
name: { type: 'string', description: 'Profile name. "default" for top-level env vars; any key from LARK_PROFILES_JSON otherwise.' },
|
|
217
|
+
},
|
|
218
|
+
required: ['name'],
|
|
219
|
+
},
|
|
220
|
+
},
|
|
221
|
+
|
|
155
222
|
// ========== User Identity — Send Messages ==========
|
|
156
223
|
{
|
|
157
224
|
name: 'send_as_user',
|
|
@@ -206,6 +273,22 @@ const TOOLS = [
|
|
|
206
273
|
required: ['group_name', 'text'],
|
|
207
274
|
},
|
|
208
275
|
},
|
|
276
|
+
{
|
|
277
|
+
name: 'batch_send',
|
|
278
|
+
description: '[User Identity / Official API] Send the same or different content to multiple targets in one call. Each target dispatches sequentially with a small delay (anti-rate-limit) and reports per-target success/error. Identity is the cookie user (user-identity sends) unless target.via=bot. Use for broadcast / fan-out scenarios.',
|
|
279
|
+
inputSchema: {
|
|
280
|
+
type: 'object',
|
|
281
|
+
properties: {
|
|
282
|
+
targets: {
|
|
283
|
+
type: 'array',
|
|
284
|
+
description: 'Array of targets. Each entry: { type: "user"|"group"|"chat", id: <user_name | group_name | chat_id>, content: { kind: "text"|"image"|"file"|"post", ... } }. For kind="text": { text }. For "image": { image_key }. For "file": { file_key, file_name }. For "post": { title, paragraphs }. Optional per-target: via="bot" routes through send_message_as_bot (chat_id required).',
|
|
285
|
+
items: { type: 'object' },
|
|
286
|
+
},
|
|
287
|
+
delay_ms: { type: 'number', description: 'Delay between sends in milliseconds (default 200, increase for risky volumes).' },
|
|
288
|
+
},
|
|
289
|
+
required: ['targets'],
|
|
290
|
+
},
|
|
291
|
+
},
|
|
209
292
|
{
|
|
210
293
|
name: 'send_image_as_user',
|
|
211
294
|
description: '[User Identity] Send an image as the logged-in user. Requires image_key (upload via Official API first).',
|
|
@@ -326,7 +409,7 @@ const TOOLS = [
|
|
|
326
409
|
// ========== IM — Official API (User Identity via UAT) ==========
|
|
327
410
|
{
|
|
328
411
|
name: 'read_p2p_messages',
|
|
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. Requires OAuth setup.',
|
|
412
|
+
description: '[User UAT] Read P2P (direct message) chat history using user_access_token. Works for chats the bot cannot access. Returns newest messages first by default. Auto-expands merge_forward messages into their child messages by default — disable with expand_merge_forward=false. Requires OAuth setup.',
|
|
330
413
|
inputSchema: {
|
|
331
414
|
type: 'object',
|
|
332
415
|
properties: {
|
|
@@ -335,6 +418,7 @@ const TOOLS = [
|
|
|
335
418
|
start_time: { type: 'string', description: 'Start timestamp in seconds (optional)' },
|
|
336
419
|
end_time: { type: 'string', description: 'End timestamp in seconds (optional)' },
|
|
337
420
|
sort_type: { type: 'string', enum: ['ByCreateTimeDesc', 'ByCreateTimeAsc'], description: 'Sort order (default: ByCreateTimeDesc = newest first)' },
|
|
421
|
+
expand_merge_forward: { type: 'boolean', description: 'Auto-expand merge_forward placeholders into their child messages (default true). Children carry parentMessageId; use that id (not the child id) with download_image / download_file.' },
|
|
338
422
|
},
|
|
339
423
|
required: ['chat_id'],
|
|
340
424
|
},
|
|
@@ -365,7 +449,7 @@ const TOOLS = [
|
|
|
365
449
|
},
|
|
366
450
|
{
|
|
367
451
|
name: 'read_messages',
|
|
368
|
-
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.',
|
|
452
|
+
description: '[Official API + UAT fallback] Read message history from any group. Accepts oc_xxx ID, numeric ID, or chat name (auto-searched). Auto-falls back to UAT for external groups the bot cannot access. Returns newest messages first by default, with sender names resolved. Auto-expands merge_forward messages into their child messages (with original sender / time / content preserved) by default — disable with expand_merge_forward=false. Text messages have URLs extracted into `urls`; Feishu doc links are additionally surfaced as `feishuDocs` so agents can feed them straight into read_doc / get_doc_blocks.',
|
|
369
453
|
inputSchema: {
|
|
370
454
|
type: 'object',
|
|
371
455
|
properties: {
|
|
@@ -374,6 +458,7 @@ const TOOLS = [
|
|
|
374
458
|
start_time: { type: 'string', description: 'Start timestamp in seconds (optional)' },
|
|
375
459
|
end_time: { type: 'string', description: 'End timestamp in seconds (optional)' },
|
|
376
460
|
sort_type: { type: 'string', enum: ['ByCreateTimeDesc', 'ByCreateTimeAsc'], description: 'Sort order (default: ByCreateTimeDesc = newest first)' },
|
|
461
|
+
expand_merge_forward: { type: 'boolean', description: 'Auto-expand merge_forward placeholders into their child messages (default true). Children carry parentMessageId; use that id (not the child id) with download_image / download_file.' },
|
|
377
462
|
},
|
|
378
463
|
required: ['chat_id'],
|
|
379
464
|
},
|
|
@@ -687,6 +772,33 @@ const TOOLS = [
|
|
|
687
772
|
required: ['file_path'],
|
|
688
773
|
},
|
|
689
774
|
},
|
|
775
|
+
{
|
|
776
|
+
name: 'upload_drive_file',
|
|
777
|
+
description: '[Official API] Upload a file from disk to a Feishu Drive folder (drive/v1/files/upload_all, parent_type=explorer). Returns file_token + url. If wiki_space_id is provided, the uploaded file is then attached to that Wiki space via move_docs_to_wiki (obj_type=file). UAT-first with app fallback.',
|
|
778
|
+
inputSchema: {
|
|
779
|
+
type: 'object',
|
|
780
|
+
properties: {
|
|
781
|
+
file_path: { type: 'string', description: 'Absolute path to the file on disk' },
|
|
782
|
+
folder_token: { type: 'string', description: 'Destination folder token. Use list_files to find one, or pass the user "我的空间" root token.' },
|
|
783
|
+
wiki_space_id: { type: 'string', description: 'Optional. If set, also attach the uploaded file to this Wiki space.' },
|
|
784
|
+
wiki_parent_node_token: { type: 'string', description: 'Optional. Parent node under which to attach in the Wiki space.' },
|
|
785
|
+
},
|
|
786
|
+
required: ['file_path', 'folder_token'],
|
|
787
|
+
},
|
|
788
|
+
},
|
|
789
|
+
{
|
|
790
|
+
name: 'upload_bitable_attachment',
|
|
791
|
+
description: '[Official API] Upload a file as a Bitable attachment (drive/v1/medias/upload_all with parent_type=bitable_image or bitable_file). Returns file_token suitable for writing into a Bitable Attachment-type field via batch_create/update_bitable_records (the field value should be [{file_token}]).',
|
|
792
|
+
inputSchema: {
|
|
793
|
+
type: 'object',
|
|
794
|
+
properties: {
|
|
795
|
+
app_token: { type: 'string', description: 'Bitable app token (the bascn... or basc... id)' },
|
|
796
|
+
file_path: { type: 'string', description: 'Absolute path to the file on disk' },
|
|
797
|
+
kind: { type: 'string', enum: ['image', 'file'], description: 'Whether the attachment is an image (bitable_image) or a generic file (bitable_file). Default: file.' },
|
|
798
|
+
},
|
|
799
|
+
required: ['app_token', 'file_path'],
|
|
800
|
+
},
|
|
801
|
+
},
|
|
690
802
|
|
|
691
803
|
// ========== Contact — Official API ==========
|
|
692
804
|
{
|
|
@@ -715,6 +827,19 @@ const TOOLS = [
|
|
|
715
827
|
required: ['chat_id', 'msg_type', 'content'],
|
|
716
828
|
},
|
|
717
829
|
},
|
|
830
|
+
{
|
|
831
|
+
name: 'send_card_as_user',
|
|
832
|
+
description: '[v1.3.6: bot-routed default] Send an interactive card to a chat. **As of v1.3.6, identity defaults to BOT** because user-identity card sending requires reverse-engineering the Feishu web protobuf and is deferred to v1.3.7. The tool name keeps the "as_user" suffix so callers don\'t have to migrate when v1.3.7 lands; once user-identity is implemented the default flips. Pass `card` as a JSON object (Feishu card schema). To force bot explicitly set via="bot".',
|
|
833
|
+
inputSchema: {
|
|
834
|
+
type: 'object',
|
|
835
|
+
properties: {
|
|
836
|
+
chat_id: { type: 'string', description: 'Target chat_id (oc_xxx) or open_id' },
|
|
837
|
+
card: { description: 'Feishu card JSON. See https://open.feishu.cn/cardkit for the schema; build cards visually then paste the resulting JSON here.' },
|
|
838
|
+
via: { type: 'string', enum: ['bot', 'user'], description: 'Identity to send as. Default "bot". "user" returns an explicit not-yet-implemented error in v1.3.6.' },
|
|
839
|
+
},
|
|
840
|
+
required: ['chat_id', 'card'],
|
|
841
|
+
},
|
|
842
|
+
},
|
|
718
843
|
{
|
|
719
844
|
name: 'delete_message',
|
|
720
845
|
description: '[Official API] Recall/delete a message (bot can only delete its own messages).',
|
|
@@ -835,15 +960,17 @@ const TOOLS = [
|
|
|
835
960
|
// ========== Docs — Block Editing ==========
|
|
836
961
|
{
|
|
837
962
|
name: 'create_doc_block',
|
|
838
|
-
description: '[Official API] Insert content blocks into a document.
|
|
963
|
+
description: '[Official API] Insert content blocks into a document. Five modes:\n (A) Generic — pass `children` array (e.g. [{block_type:2, text:{...}}]) for text/heading/list/etc.\n (B) Image from local file — pass `image_path` (absolute path); the plugin creates an image block, uploads the file to drive, and patches the block with the token. Returns block_id + image_token.\n (C) Image from uploaded token — pass `image_token` to reuse an already-uploaded image.\n (D) File attachment from local file — pass `file_path`; the plugin creates a file block (block_type=23), uploads via parent_type=docx_file, and patches with replace_file.\n (E) File from uploaded token — pass `file_token` to reuse an already-uploaded file.\n`document_id` accepts native document_id, wiki node token, or Feishu URL.',
|
|
839
964
|
inputSchema: {
|
|
840
965
|
type: 'object',
|
|
841
966
|
properties: {
|
|
842
967
|
document_id: { type: 'string', description: 'Document ID, wiki node token, or Feishu URL' },
|
|
843
968
|
parent_block_id: { type: 'string', description: 'Parent block ID (use document_id for root)' },
|
|
844
969
|
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
|
|
846
|
-
image_token: { type: 'string', description: 'Pre-uploaded docx image token — mode C (mutually exclusive with
|
|
970
|
+
image_path: { type: 'string', description: 'Local image path — mode B (mutually exclusive with other modes)' },
|
|
971
|
+
image_token: { type: 'string', description: 'Pre-uploaded docx image token — mode C (mutually exclusive with other modes)' },
|
|
972
|
+
file_path: { type: 'string', description: 'Local file path for an attachment block — mode D (mutually exclusive with other modes)' },
|
|
973
|
+
file_token: { type: 'string', description: 'Pre-uploaded docx file token — mode E (mutually exclusive with other modes)' },
|
|
847
974
|
index: { type: 'number', description: 'Insert position (optional, appends to end if omitted)' },
|
|
848
975
|
},
|
|
849
976
|
required: ['document_id', 'parent_block_id'],
|
|
@@ -851,7 +978,7 @@ const TOOLS = [
|
|
|
851
978
|
},
|
|
852
979
|
{
|
|
853
980
|
name: 'update_doc_block',
|
|
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.',
|
|
981
|
+
description: '[Official API] Update a specific block in a document. Generic mode: pass update_body. Image-replace mode: pass image_token to swap the picture in an existing image block. File-replace mode: pass file_token to swap an existing file block. document_id accepts native ID, wiki node token, or Feishu URL.',
|
|
855
982
|
inputSchema: {
|
|
856
983
|
type: 'object',
|
|
857
984
|
properties: {
|
|
@@ -859,6 +986,7 @@ const TOOLS = [
|
|
|
859
986
|
block_id: { type: 'string', description: 'Block ID to update' },
|
|
860
987
|
update_body: { type: 'object', description: 'Generic update payload. E.g. {update_text_elements:{elements:[{text_run:{content:"new text"}}]}}' },
|
|
861
988
|
image_token: { type: 'string', description: 'Pre-uploaded image token — if provided, update_body is ignored and the block is patched with {replace_image:{token}}' },
|
|
989
|
+
file_token: { type: 'string', description: 'Pre-uploaded file token — patches the block with {replace_file:{token}}' },
|
|
862
990
|
},
|
|
863
991
|
required: ['document_id', 'block_id'],
|
|
864
992
|
},
|
|
@@ -1013,17 +1141,30 @@ const TOOLS = [
|
|
|
1013
1141
|
// ========== Message Resources (Image/File Download) ==========
|
|
1014
1142
|
{
|
|
1015
1143
|
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.',
|
|
1144
|
+
description: '[User Identity / Official API] Download an image so the model can actually see it. Two modes: (1) message image — pass message_id + image_key from read_messages / read_p2p_messages. (2) docx image — pass doc_token + image_token (the block.image.token from get_doc_blocks). doc_token accepts native document_id, wiki node token, or Feishu URL. Tries user identity first, falls back to app. NOTE: for merge_forward children, pass the child\'s `parentMessageId` (NOT the child message id) — Feishu keys media by the parent merge_forward id.',
|
|
1017
1145
|
inputSchema: {
|
|
1018
1146
|
type: 'object',
|
|
1019
1147
|
properties: {
|
|
1020
|
-
message_id: { type: 'string', description: 'Message ID (om_xxx) — for mode 1 only' },
|
|
1148
|
+
message_id: { type: 'string', description: 'Message ID (om_xxx) — for mode 1 only. For merge_forward children use the parent merge_forward message id.' },
|
|
1021
1149
|
image_key: { type: 'string', description: 'Image key (img_xxx) from message content — for mode 1 only' },
|
|
1022
1150
|
doc_token: { type: 'string', description: 'Document ID, wiki node token, or Feishu URL — for mode 2 only' },
|
|
1023
1151
|
image_token: { type: 'string', description: 'Image token from a docx image block (block.image.token via get_doc_blocks) — for mode 2 only' },
|
|
1024
1152
|
},
|
|
1025
1153
|
},
|
|
1026
1154
|
},
|
|
1155
|
+
{
|
|
1156
|
+
name: 'download_file',
|
|
1157
|
+
description: '[User Identity / Official API] Download a file attached to a message (msg_type=file). Returns base64 bytes + mimeType + filename. Tries user identity first, falls back to app. For merge_forward children, pass the child\'s `parentMessageId` (NOT the child message id) — Feishu keys media by the parent merge_forward id.',
|
|
1158
|
+
inputSchema: {
|
|
1159
|
+
type: 'object',
|
|
1160
|
+
properties: {
|
|
1161
|
+
message_id: { type: 'string', description: 'Message ID (om_xxx). For merge_forward children use the parent merge_forward message id.' },
|
|
1162
|
+
file_key: { type: 'string', description: 'File key from message content (content.file_key for msg_type=file)' },
|
|
1163
|
+
save_path: { type: 'string', description: 'Optional absolute local path to save the file to. If omitted, file is only returned as inline base64 in the response.' },
|
|
1164
|
+
},
|
|
1165
|
+
required: ['message_id', 'file_key'],
|
|
1166
|
+
},
|
|
1167
|
+
},
|
|
1027
1168
|
|
|
1028
1169
|
// ========== Wiki Node — Object Resolution (v1.3.4) ==========
|
|
1029
1170
|
{
|
|
@@ -1143,7 +1284,13 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
1143
1284
|
});
|
|
1144
1285
|
|
|
1145
1286
|
const text = (s) => ({ content: [{ type: 'text', text: s }] });
|
|
1146
|
-
const json = (o) =>
|
|
1287
|
+
const json = (o) => {
|
|
1288
|
+
// If the underlying method surfaced a fallback warning (UAT unavailable,
|
|
1289
|
+
// resource owned by bot), lift it to the top of the response so the human /
|
|
1290
|
+
// agent sees it *before* the structured body. Keeps the JSON payload intact.
|
|
1291
|
+
const warn = o && typeof o === 'object' && o.fallbackWarning ? `${o.fallbackWarning}\n\n` : '';
|
|
1292
|
+
return text(warn + JSON.stringify(o, null, 2));
|
|
1293
|
+
};
|
|
1147
1294
|
const sendResult = (r, desc) => text(r.success ? desc : `Send failed (status: ${r.status})`);
|
|
1148
1295
|
|
|
1149
1296
|
// Resolver helper: turn document_id / app_token / wiki node / Feishu URL into
|
|
@@ -1156,6 +1303,25 @@ async function resolveDocId(input) {
|
|
|
1156
1303
|
async function handleTool(name, args) {
|
|
1157
1304
|
|
|
1158
1305
|
switch (name) {
|
|
1306
|
+
// --- Profile management (v1.3.6) ---
|
|
1307
|
+
|
|
1308
|
+
case 'list_profiles': {
|
|
1309
|
+
const profiles = loadProfileMap();
|
|
1310
|
+
const all = ['default', ...Object.keys(profiles)];
|
|
1311
|
+
return json({ active: currentProfile, profiles: all });
|
|
1312
|
+
}
|
|
1313
|
+
case 'switch_profile': {
|
|
1314
|
+
const target = args.name;
|
|
1315
|
+
const profiles = loadProfileMap();
|
|
1316
|
+
const all = ['default', ...Object.keys(profiles)];
|
|
1317
|
+
if (!all.includes(target)) return text(`Profile "${target}" not found. Available: ${all.join(', ')}. To add more, set LARK_PROFILES_JSON in your MCP env.`);
|
|
1318
|
+
currentProfile = target;
|
|
1319
|
+
// Invalidate cached client instances so the next call uses the new creds
|
|
1320
|
+
userClient = null;
|
|
1321
|
+
officialClient = null;
|
|
1322
|
+
return text(`Switched to profile: ${target}`);
|
|
1323
|
+
}
|
|
1324
|
+
|
|
1159
1325
|
// --- User Identity: Text Messaging ---
|
|
1160
1326
|
|
|
1161
1327
|
case 'send_as_user': {
|
|
@@ -1191,6 +1357,56 @@ async function handleTool(name, args) {
|
|
|
1191
1357
|
const r = await c.sendMessage(group.id, args.text, { ats: args.ats });
|
|
1192
1358
|
return sendResult(r, `Text sent to group "${group.title}" (${group.id})`);
|
|
1193
1359
|
}
|
|
1360
|
+
case 'batch_send': {
|
|
1361
|
+
if (!Array.isArray(args.targets) || args.targets.length === 0) return text('batch_send: targets must be a non-empty array');
|
|
1362
|
+
const delay = typeof args.delay_ms === 'number' ? args.delay_ms : 200;
|
|
1363
|
+
const userClient = await getUserClient();
|
|
1364
|
+
const officialClient = getOfficialClient();
|
|
1365
|
+
const results = [];
|
|
1366
|
+
for (let i = 0; i < args.targets.length; i++) {
|
|
1367
|
+
const t = args.targets[i];
|
|
1368
|
+
try {
|
|
1369
|
+
if (!t.content || !t.content.kind) throw new Error('content.kind is required');
|
|
1370
|
+
// Resolve chat id from name when applicable
|
|
1371
|
+
let chatId = t.id;
|
|
1372
|
+
if (t.type === 'user' || t.type === 'group') {
|
|
1373
|
+
const matches = await userClient.search(t.id);
|
|
1374
|
+
const want = matches.filter(m => m.type === t.type);
|
|
1375
|
+
if (want.length === 0) throw new Error(`No ${t.type} matches "${t.id}"`);
|
|
1376
|
+
if (want.length > 1) throw new Error(`Ambiguous ${t.type} "${t.id}" (${want.length} matches). Use type="chat" with explicit chat_id.`);
|
|
1377
|
+
const picked = want[0];
|
|
1378
|
+
chatId = t.type === 'user' ? await userClient.createChat(picked.id) : picked.id;
|
|
1379
|
+
if (!chatId) throw new Error(`Could not resolve chat for ${t.type} ${picked.title}`);
|
|
1380
|
+
}
|
|
1381
|
+
let r;
|
|
1382
|
+
if (t.via === 'bot') {
|
|
1383
|
+
const c = t.content;
|
|
1384
|
+
const payload = c.kind === 'text' ? { text: c.text }
|
|
1385
|
+
: c.kind === 'post' ? { post: { zh_cn: { title: c.title || '', content: c.paragraphs || [] } } }
|
|
1386
|
+
: c.kind === 'image' ? { image_key: c.image_key }
|
|
1387
|
+
: c.kind === 'interactive' ? c.card
|
|
1388
|
+
: null;
|
|
1389
|
+
if (!payload) throw new Error(`bot path does not support content.kind=${c.kind}`);
|
|
1390
|
+
const msgType = c.kind === 'interactive' ? 'interactive' : c.kind;
|
|
1391
|
+
r = await officialClient.sendMessageAsBot(chatId, msgType, payload);
|
|
1392
|
+
results.push({ ok: true, target: t, messageId: r.messageId, via: 'bot' });
|
|
1393
|
+
} else {
|
|
1394
|
+
const c = t.content;
|
|
1395
|
+
if (c.kind === 'text') r = await userClient.sendMessage(chatId, c.text, { ats: c.ats });
|
|
1396
|
+
else if (c.kind === 'image') r = await userClient.sendImage(chatId, c.image_key);
|
|
1397
|
+
else if (c.kind === 'file') r = await userClient.sendFile(chatId, c.file_key, c.file_name);
|
|
1398
|
+
else if (c.kind === 'post') r = await userClient.sendPost(chatId, c.title, c.paragraphs);
|
|
1399
|
+
else throw new Error(`unknown content.kind=${c.kind}`);
|
|
1400
|
+
results.push({ ok: true, target: t, messageId: r.messageId, via: 'user' });
|
|
1401
|
+
}
|
|
1402
|
+
} catch (e) {
|
|
1403
|
+
results.push({ ok: false, target: t, error: e.message });
|
|
1404
|
+
}
|
|
1405
|
+
if (i < args.targets.length - 1 && delay > 0) await new Promise(r => setTimeout(r, delay));
|
|
1406
|
+
}
|
|
1407
|
+
const okCount = results.filter(r => r.ok).length;
|
|
1408
|
+
return json({ summary: `${okCount}/${results.length} sent`, results });
|
|
1409
|
+
}
|
|
1194
1410
|
|
|
1195
1411
|
// --- User Identity: Rich Message Types ---
|
|
1196
1412
|
|
|
@@ -1288,7 +1504,17 @@ async function handleTool(name, args) {
|
|
|
1288
1504
|
parts.push(`App credentials: INVALID — app_id=${probe.appId} rejected by Feishu (${probe.error})`);
|
|
1289
1505
|
parts.push(` → Likely wrong/stale APP_ID. Re-run the install prompt from team-skills/plugins/feishu-user-plugin/README.md to get the correct credentials.`);
|
|
1290
1506
|
}
|
|
1291
|
-
|
|
1507
|
+
if (official.hasUAT) {
|
|
1508
|
+
try {
|
|
1509
|
+
await official.listChatsAsUser({ pageSize: 1 });
|
|
1510
|
+
parts.push('User access token: Valid (P2P/group UAT reading enabled)');
|
|
1511
|
+
} catch (e) {
|
|
1512
|
+
parts.push(`User access token: INVALID — ${e.message}`);
|
|
1513
|
+
parts.push(' → Re-run OAuth: npx feishu-user-plugin oauth, then restart Claude Code / Codex so running MCP servers load the new token.');
|
|
1514
|
+
}
|
|
1515
|
+
} else {
|
|
1516
|
+
parts.push('User access token: Not set (optional — needed for P2P chat reading. Run OAuth flow to obtain, see README for details)');
|
|
1517
|
+
}
|
|
1292
1518
|
}
|
|
1293
1519
|
return text(parts.join('\n'));
|
|
1294
1520
|
}
|
|
@@ -1324,6 +1550,7 @@ async function handleTool(name, args) {
|
|
|
1324
1550
|
return json(await official.readMessagesAsUser(chatId, {
|
|
1325
1551
|
pageSize: args.page_size, startTime: args.start_time, endTime: args.end_time,
|
|
1326
1552
|
sortType: args.sort_type,
|
|
1553
|
+
expandMergeForward: args.expand_merge_forward !== false,
|
|
1327
1554
|
}, uc));
|
|
1328
1555
|
}
|
|
1329
1556
|
case 'list_user_chats':
|
|
@@ -1335,7 +1562,11 @@ async function handleTool(name, args) {
|
|
|
1335
1562
|
return json(await getOfficialClient().listChats({ pageSize: args.page_size, pageToken: args.page_token }));
|
|
1336
1563
|
case 'read_messages': {
|
|
1337
1564
|
const official = getOfficialClient();
|
|
1338
|
-
const msgOpts = {
|
|
1565
|
+
const msgOpts = {
|
|
1566
|
+
pageSize: args.page_size, startTime: args.start_time, endTime: args.end_time,
|
|
1567
|
+
sortType: args.sort_type,
|
|
1568
|
+
expandMergeForward: args.expand_merge_forward !== false,
|
|
1569
|
+
};
|
|
1339
1570
|
// Get userClient for name resolution fallback (best-effort)
|
|
1340
1571
|
let uc = null;
|
|
1341
1572
|
try { uc = await getUserClient(); } catch (_) {}
|
|
@@ -1382,7 +1613,8 @@ async function handleTool(name, args) {
|
|
|
1382
1613
|
: r.wikiAttachTaskId ? ` [wiki attach queued — task_id: ${r.wikiAttachTaskId}]`
|
|
1383
1614
|
: r.wikiAttachError ? ` [WARNING: wiki attach failed — ${r.wikiAttachError}. Doc exists in drive root/folder.]`
|
|
1384
1615
|
: '';
|
|
1385
|
-
|
|
1616
|
+
const warn = r.fallbackWarning ? `\n\n${r.fallbackWarning}` : '';
|
|
1617
|
+
return text(`Document created${ownership}: ${r.documentId}${wikiNote}${warn}`);
|
|
1386
1618
|
}
|
|
1387
1619
|
|
|
1388
1620
|
// --- Official API: Bitable ---
|
|
@@ -1397,12 +1629,16 @@ async function handleTool(name, args) {
|
|
|
1397
1629
|
: r.wikiAttachTaskId ? `\nWiki attach queued — task_id: ${r.wikiAttachTaskId}`
|
|
1398
1630
|
: r.wikiAttachError ? `\nWARNING: wiki attach failed — ${r.wikiAttachError}. Bitable exists in drive root/folder.`
|
|
1399
1631
|
: '';
|
|
1400
|
-
|
|
1632
|
+
const warn = r.fallbackWarning ? `\n\n${r.fallbackWarning}` : '';
|
|
1633
|
+
return text(`Bitable created${ownership}: ${r.appToken}\nURL: ${r.url || ''}${wikiNote}${warn}`);
|
|
1401
1634
|
}
|
|
1402
1635
|
case 'list_bitable_tables':
|
|
1403
1636
|
return json(await getOfficialClient().listBitableTables(await resolveDocId(args.app_token)));
|
|
1404
|
-
case 'create_bitable_table':
|
|
1405
|
-
|
|
1637
|
+
case 'create_bitable_table': {
|
|
1638
|
+
const r = await getOfficialClient().createBitableTable(await resolveDocId(args.app_token), args.name, args.fields);
|
|
1639
|
+
const warn = r.fallbackWarning ? `\n\n${r.fallbackWarning}` : '';
|
|
1640
|
+
return text(`Table created: ${r.tableId}${warn}`);
|
|
1641
|
+
}
|
|
1406
1642
|
case 'list_bitable_fields':
|
|
1407
1643
|
return json(await getOfficialClient().listBitableFields(await resolveDocId(args.app_token), args.table_id));
|
|
1408
1644
|
case 'create_bitable_field': {
|
|
@@ -1450,7 +1686,8 @@ async function handleTool(name, args) {
|
|
|
1450
1686
|
case 'create_folder': {
|
|
1451
1687
|
const r = await getOfficialClient().createFolder(args.name, args.parent_token);
|
|
1452
1688
|
const ownership = r.viaUser ? ' (as user)' : ' (as app — UAT unavailable or failed; folder owned by the app, not you)';
|
|
1453
|
-
|
|
1689
|
+
const warn = r.fallbackWarning ? `\n\n${r.fallbackWarning}` : '';
|
|
1690
|
+
return text(`Folder created${ownership}: ${r.token}${warn}`);
|
|
1454
1691
|
}
|
|
1455
1692
|
|
|
1456
1693
|
// --- Official API: Contact ---
|
|
@@ -1468,9 +1705,38 @@ async function handleTool(name, args) {
|
|
|
1468
1705
|
const r = await getOfficialClient().uploadFile(args.file_path, args.file_type, args.file_name);
|
|
1469
1706
|
return text(`File uploaded: ${r.fileKey}\nUse this file_key with send_file_as_user to send it.`);
|
|
1470
1707
|
}
|
|
1708
|
+
case 'upload_drive_file': {
|
|
1709
|
+
const official = getOfficialClient();
|
|
1710
|
+
const up = await official.uploadDriveFile(args.file_path, args.folder_token);
|
|
1711
|
+
const out = { fileToken: up.fileToken, viaUser: up.viaUser, url: `https://feishu.cn/file/${up.fileToken}` };
|
|
1712
|
+
if (args.wiki_space_id) {
|
|
1713
|
+
try {
|
|
1714
|
+
const node = await official.attachToWiki(args.wiki_space_id, 'file', up.fileToken, args.wiki_parent_node_token);
|
|
1715
|
+
out.wikiNodeToken = node.node_token || null;
|
|
1716
|
+
out.wikiAttachTaskId = node.task_id || null;
|
|
1717
|
+
} catch (e) {
|
|
1718
|
+
out.wikiAttachError = e.message;
|
|
1719
|
+
}
|
|
1720
|
+
}
|
|
1721
|
+
return json(out);
|
|
1722
|
+
}
|
|
1723
|
+
case 'upload_bitable_attachment': {
|
|
1724
|
+
const kind = args.kind === 'image' ? 'bitable_image' : 'bitable_file';
|
|
1725
|
+
const appToken = await resolveDocId(args.app_token);
|
|
1726
|
+
const up = await getOfficialClient().uploadMedia(args.file_path, appToken, kind);
|
|
1727
|
+
return json({ fileToken: up.fileToken, viaUser: up.viaUser, parentType: kind, hint: `Pass [{ file_token: "${up.fileToken}" }] as the value of an Attachment-type Bitable field.` });
|
|
1728
|
+
}
|
|
1471
1729
|
|
|
1472
1730
|
// --- Official API: Bot Send / Edit / Delete ---
|
|
1473
1731
|
|
|
1732
|
+
case 'send_card_as_user': {
|
|
1733
|
+
const via = args.via || 'bot';
|
|
1734
|
+
if (via === 'user') {
|
|
1735
|
+
return text('send_card_as_user via="user" is not implemented in v1.3.6 — user-identity card sending requires reverse-engineering the Feishu web protobuf and is scheduled for v1.3.7. Use via="bot" (default) for now.');
|
|
1736
|
+
}
|
|
1737
|
+
const r = await getOfficialClient().sendMessageAsBot(args.chat_id, 'interactive', args.card);
|
|
1738
|
+
return text(`Card sent (${via}): ${r.messageId}`);
|
|
1739
|
+
}
|
|
1474
1740
|
case 'send_message_as_bot': {
|
|
1475
1741
|
const r = await getOfficialClient().sendMessageAsBot(args.chat_id, args.msg_type, args.content);
|
|
1476
1742
|
return text(`Message sent (bot): ${r.messageId}`);
|
|
@@ -1513,10 +1779,9 @@ async function handleTool(name, args) {
|
|
|
1513
1779
|
case 'create_doc_block': {
|
|
1514
1780
|
const official = getOfficialClient();
|
|
1515
1781
|
const docId = await resolveDocId(args.document_id);
|
|
1516
|
-
|
|
1517
|
-
|
|
1782
|
+
const modes = [args.children, args.image_path, args.image_token, args.file_path, args.file_token].filter(Boolean);
|
|
1783
|
+
if (modes.length > 1) return text('create_doc_block: pass exactly ONE of children / image_path / image_token / file_path / file_token.');
|
|
1518
1784
|
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
1785
|
const r = await official.createDocBlockWithImage(docId, args.parent_block_id, {
|
|
1521
1786
|
imagePath: args.image_path,
|
|
1522
1787
|
imageToken: args.image_token,
|
|
@@ -1524,19 +1789,29 @@ async function handleTool(name, args) {
|
|
|
1524
1789
|
});
|
|
1525
1790
|
return json(r);
|
|
1526
1791
|
}
|
|
1527
|
-
if (
|
|
1792
|
+
if (args.file_path || args.file_token) {
|
|
1793
|
+
const r = await official.createDocBlockWithFile(docId, args.parent_block_id, {
|
|
1794
|
+
filePath: args.file_path,
|
|
1795
|
+
fileToken: args.file_token,
|
|
1796
|
+
index: args.index,
|
|
1797
|
+
});
|
|
1798
|
+
return json(r);
|
|
1799
|
+
}
|
|
1800
|
+
if (!args.children) return text('create_doc_block: children, image_path, image_token, file_path, or file_token is required.');
|
|
1528
1801
|
return json(await official.createDocBlock(docId, args.parent_block_id, args.children, args.index));
|
|
1529
1802
|
}
|
|
1530
1803
|
case 'update_doc_block': {
|
|
1531
1804
|
const official = getOfficialClient();
|
|
1532
1805
|
const docId = await resolveDocId(args.document_id);
|
|
1533
|
-
|
|
1534
|
-
|
|
1535
|
-
}
|
|
1806
|
+
const modes = [args.update_body, args.image_token, args.file_token].filter(Boolean);
|
|
1807
|
+
if (modes.length > 1) return text('update_doc_block: pass exactly ONE of update_body / image_token / file_token.');
|
|
1536
1808
|
if (args.image_token) {
|
|
1537
1809
|
return json(await official.updateDocBlockImage(docId, args.block_id, args.image_token));
|
|
1538
1810
|
}
|
|
1539
|
-
if (
|
|
1811
|
+
if (args.file_token) {
|
|
1812
|
+
return json(await official.updateDocBlockFile(docId, args.block_id, args.file_token));
|
|
1813
|
+
}
|
|
1814
|
+
if (!args.update_body) return text('update_doc_block: update_body, image_token, or file_token is required.');
|
|
1540
1815
|
return json(await official.updateDocBlock(docId, args.block_id, args.update_body));
|
|
1541
1816
|
}
|
|
1542
1817
|
case 'delete_doc_blocks':
|
|
@@ -1592,6 +1867,33 @@ async function handleTool(name, args) {
|
|
|
1592
1867
|
};
|
|
1593
1868
|
}
|
|
1594
1869
|
|
|
1870
|
+
case 'download_file': {
|
|
1871
|
+
if (!args.message_id || !args.file_key) {
|
|
1872
|
+
return text('download_file requires message_id + file_key. For merge_forward children pass the PARENT merge_forward message id, not the child id.');
|
|
1873
|
+
}
|
|
1874
|
+
const r = await getOfficialClient().downloadMessageResource(args.message_id, args.file_key, 'file');
|
|
1875
|
+
let saveNote = '';
|
|
1876
|
+
if (args.save_path) {
|
|
1877
|
+
try {
|
|
1878
|
+
const fs = require('fs');
|
|
1879
|
+
fs.writeFileSync(args.save_path, Buffer.from(r.base64, 'base64'));
|
|
1880
|
+
saveNote = `\nSaved to: ${args.save_path}`;
|
|
1881
|
+
} catch (e) {
|
|
1882
|
+
saveNote = `\nSave to ${args.save_path} failed: ${e.message}`;
|
|
1883
|
+
}
|
|
1884
|
+
}
|
|
1885
|
+
// Files are returned as a text summary plus a resource link so agents can
|
|
1886
|
+
// either read the saved copy or decode the base64 themselves. We do not
|
|
1887
|
+
// embed binary file content as MCP image blobs (wrong content-type).
|
|
1888
|
+
const summary = `File downloaded from message ${args.message_id} (${r.viaUser ? 'as user' : 'as app'}, ${r.bytes} bytes, ${r.mimeType})${saveNote}`;
|
|
1889
|
+
return {
|
|
1890
|
+
content: [
|
|
1891
|
+
{ type: 'text', text: summary },
|
|
1892
|
+
{ type: 'text', text: `base64 (${r.bytes} bytes, truncated display):\n${r.base64.slice(0, 400)}${r.base64.length > 400 ? '…' : ''}` },
|
|
1893
|
+
],
|
|
1894
|
+
};
|
|
1895
|
+
}
|
|
1896
|
+
|
|
1595
1897
|
// --- Wiki Node Resolution (v1.3.4) ---
|
|
1596
1898
|
case 'get_wiki_node': {
|
|
1597
1899
|
// Accept either a bare wiki node token or a full /wiki/ URL — parse first.
|
package/src/oauth.js
CHANGED
|
@@ -28,7 +28,10 @@ const REDIRECT_URI = `http://127.0.0.1:${PORT}/callback`;
|
|
|
28
28
|
// calendar:* for list_calendars / list_calendar_events / get_calendar_event
|
|
29
29
|
// wiki:wiki write access for move_docs_to_wiki (attach docs/bitables to wiki)
|
|
30
30
|
// docs:document.media:(upload|download) for docx image read/write
|
|
31
|
-
|
|
31
|
+
// v1.3.6 additions:
|
|
32
|
+
// sheets:spreadsheet for sheet_image / sheet_file media uploads
|
|
33
|
+
// drive:file:upload narrower scope for drive/v1/files/upload_all (independent of drive:drive)
|
|
34
|
+
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 drive:file:upload 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 sheets:spreadsheet';
|
|
32
35
|
|
|
33
36
|
if (!APP_ID || !APP_SECRET) {
|
|
34
37
|
console.error('Missing LARK_APP_ID or LARK_APP_SECRET.');
|