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/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 cookie = process.env.LARK_COOKIE;
159
+ const env = profileEnv(currentProfile);
160
+ const cookie = env.LARK_COOKIE;
125
161
  if (!cookie) throw new Error(
126
- 'LARK_COOKIE not set. To fix:\n' +
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 appId = process.env.LARK_APP_ID;
141
- const appSecret = process.env.LARK_APP_SECRET;
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
- 'LARK_APP_ID and LARK_APP_SECRET not set.\n' +
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. 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.',
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 children / image_token)' },
846
- image_token: { type: 'string', description: 'Pre-uploaded docx image token — mode C (mutually exclusive with children / image_path)' },
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) => text(JSON.stringify(o, null, 2));
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
- 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)'}`);
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 = { pageSize: args.page_size, startTime: args.start_time, endTime: args.end_time, sortType: args.sort_type };
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
- return text(`Document created${ownership}: ${r.documentId}${wikiNote}`);
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
- return text(`Bitable created${ownership}: ${r.appToken}\nURL: ${r.url || ''}${wikiNote}`);
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
- return text(`Table created: ${(await getOfficialClient().createBitableTable(await resolveDocId(args.app_token), args.name, args.fields)).tableId}`);
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
- return text(`Folder created${ownership}: ${r.token}`);
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
- // Image shortcut: if image_path or image_token is provided, orchestrate the
1517
- // 3-step docx image creation. Mutually exclusive with children.
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 (!args.children) return text('create_doc_block: children (generic blocks), image_path, or image_token is required.');
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
- if (args.image_token && args.update_body) {
1534
- return text('update_doc_block: pass image_token OR update_body, not both.');
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 (!args.update_body) return text('update_doc_block: update_body or image_token is required.');
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
- 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';
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.');