feishu-user-plugin 1.3.4 → 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.
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "feishu-user-plugin",
3
- "version": "1.3.4",
4
- "description": "All-in-one Feishu plugin for Claude Code — send messages as yourself (incl. real @-mentions), read chats, manage docs (with image read/write) / bitable / wiki (native + move_docs_to_wiki) / drive / OKR / calendar. 74 tools + 9 skills, 3 auth layers.",
3
+ "version": "1.3.5",
4
+ "description": "All-in-one Feishu plugin for Claude Code — send messages as yourself (incl. real @-mentions), read chats (with auto-expanded merge_forward), manage docs (with image read/write) / bitable / wiki (native + move_docs_to_wiki) / drive / OKR / calendar. 75 tools + 9 skills, 3 auth layers. v1.3.5: cross-process UAT refresh lock, bot-fallback warning, download_file.",
5
5
  "author": {
6
6
  "name": "EthanQC"
7
7
  },
package/README.md CHANGED
@@ -6,7 +6,7 @@
6
6
  [![Tools](https://img.shields.io/badge/Tools-74-orange.svg)](#tools)
7
7
  [![PRs Welcome](https://img.shields.io/badge/PRs-welcome-brightgreen.svg)](CONTRIBUTING.md)
8
8
 
9
- **All-in-one Feishu/Lark MCP Server -- 74 tools, 9 skills, 3 auth layers for messaging, docs, bitable, calendar, tasks, drive, OKR, and more.**
9
+ **All-in-one Feishu/Lark MCP Server -- 75 tools, 9 skills, 3 auth layers for messaging, docs, bitable, calendar, tasks, drive, OKR, and more.**
10
10
 
11
11
  The only MCP server that lets you send messages as your **personal identity** (not a bot), while also integrating the full official Feishu API. Works with Claude Code, Cursor, Windsurf, OpenClaw, and any MCP-compatible client.
12
12
 
@@ -337,7 +337,7 @@ Add to `~/.codeium/windsurf/mcp_config.json`:
337
337
  }
338
338
  ```
339
339
 
340
- ## Tools (74 total)
340
+ ## Tools (75 total)
341
341
 
342
342
  ### User Identity -- Messaging (8 tools, cookie auth)
343
343
 
@@ -390,7 +390,8 @@ Add to `~/.codeium/windsurf/mcp_config.json`:
390
390
  | `add_members` | Add users to a group |
391
391
  | `remove_members` | Remove users from a group |
392
392
  | `upload_image` / `upload_file` | Upload image/file, returns key for sending |
393
- | `download_image` | Download a chat-message image (message_id + image_key) OR a docx image (image_token + optional doc_token). Returned as MCP image content. |
393
+ | `download_image` | Download a chat-message image (message_id + image_key) OR a docx image (image_token + optional doc_token). Returned as MCP image content. For merge_forward children, use the child's `parentMessageId`, not the child id. |
394
+ | `download_file` | Download a file attachment (msg_type=file). Returns base64 + mimeType + byte count; optional `save_path` writes to disk. Same parent-id rule for merge_forward children. (v1.3.5) |
394
395
 
395
396
  ### Wiki, OKR, and Calendar (v1.3.4)
396
397
 
@@ -508,10 +509,12 @@ Skills are automatically available when the plugin is installed.
508
509
  |------------|-------|----------|---------|
509
510
  | Cookie | `sl_session` | 12h max-age | Auto-refreshed every 4h via heartbeat |
510
511
  | App Token | `tenant_access_token` | 2h | Auto-managed by SDK |
511
- | User OAuth | `user_access_token` | ~2h | Auto-refreshed via `refresh_token`, saved to `.env` |
512
+ | User OAuth | `user_access_token` | ~2h | Auto-refreshed via `refresh_token`, saved to MCP config |
512
513
 
513
514
  When the cookie expires (after ~12-24h without heartbeat), re-login at feishu.cn and update `LARK_COOKIE`. Use `get_login_status` to check health proactively.
514
515
 
516
+ If UAT refresh fails with `invalid_grant`, re-run `npx feishu-user-plugin oauth` and restart Claude Code / Codex. v1.3.5+ also re-reads the persisted MCP config before refreshing, so duplicate MCP processes can adopt a token already rotated by another process instead of retrying a stale refresh token.
517
+
515
518
  ## Project Structure
516
519
 
517
520
  ```
@@ -523,7 +526,7 @@ feishu-user-plugin/
523
526
  │ ├── SKILL.md # Main skill definition (trigger, tools, auth)
524
527
  │ └── references/ # 8 skill reference docs + CLAUDE.md
525
528
  ├── src/
526
- │ ├── index.js # MCP server entry point (74 tools)
529
+ │ ├── index.js # MCP server entry point (75 tools)
527
530
  │ ├── client.js # User identity client (Protobuf gateway)
528
531
  │ ├── official.js # Official API client (REST, UAT)
529
532
  │ ├── utils.js # ID generators, cookie parser
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "feishu-user-plugin",
3
- "version": "1.3.4",
4
- "description": "All-in-one Feishu plugin for Claude Code & Codex — messaging, docs (with image read/write), bitable, wiki (native + move_docs_to_wiki), drive, OKR, calendar. 74 tools + 9 skills, 3 auth layers.",
3
+ "version": "1.3.5",
4
+ "description": "All-in-one Feishu plugin for Claude Code & Codex — messaging (with merge_forward expansion), docs (with image read/write), bitable, wiki (native + move_docs_to_wiki), drive, OKR, calendar. 75 tools + 9 skills, 3 auth layers. v1.3.5: cross-process UAT refresh lock + bot-fallback warning + auto-expand merge_forward + download_file.",
5
5
  "main": "src/index.js",
6
6
  "bin": {
7
7
  "feishu-user-plugin": "src/cli.js"
@@ -0,0 +1,24 @@
1
+ // Child worker for test-uat-race.js. Acquires the UAT refresh lock, holds
2
+ // for a brief window (simulating the refresh + persist), then releases.
3
+ // Writes a single line to stdout: "<id> acquired <ts_ms>; released <ts_ms>"
4
+
5
+ const { LarkOfficialClient } = require('../src/official');
6
+
7
+ const id = process.argv[2] || '?';
8
+ const holdMs = parseInt(process.argv[3] || '250');
9
+
10
+ const client = new LarkOfficialClient('test', 'test');
11
+ const lockPath = client._uatLockPath();
12
+
13
+ (async () => {
14
+ const got = await client._acquireRefreshLock(lockPath, { timeoutMs: 15000 });
15
+ if (!got) {
16
+ console.log(`${id} FAILED_TO_ACQUIRE`);
17
+ process.exit(1);
18
+ }
19
+ const acquired = Date.now();
20
+ await new Promise(r => setTimeout(r, holdMs));
21
+ const released = Date.now();
22
+ client._releaseRefreshLock(lockPath);
23
+ console.log(`${id} acquired ${acquired}; released ${released}`);
24
+ })().catch(e => { console.log(`${id} ERROR ${e.message}`); process.exit(1); });
@@ -0,0 +1,68 @@
1
+ #!/usr/bin/env node
2
+ // Spawn N child processes that all try to hold the UAT refresh lock
3
+ // concurrently. Verify mutual exclusion: no two hold-windows overlap.
4
+ // Exit 0 on PASS, 1 on FAIL.
5
+
6
+ const { spawn } = require('child_process');
7
+ const path = require('path');
8
+ const fs = require('fs');
9
+
10
+ const N = 4;
11
+ const HOLD_MS = 300;
12
+
13
+ const child = path.join(__dirname, 'test-uat-race-child.js');
14
+
15
+ // Clean up any stale lock from prior runs
16
+ try {
17
+ const os = require('os');
18
+ fs.unlinkSync(path.join(os.homedir(), '.claude', 'feishu-uat-refresh.lock'));
19
+ console.log('(cleaned up stale lock)');
20
+ } catch (_) {}
21
+
22
+ (async () => {
23
+ const workers = Array.from({ length: N }, (_, i) => {
24
+ const p = spawn('node', [child, String(i), String(HOLD_MS)], { stdio: ['ignore', 'pipe', 'inherit'] });
25
+ let out = '';
26
+ p.stdout.on('data', d => out += d);
27
+ return new Promise(resolve => p.on('close', () => resolve(out.trim())));
28
+ });
29
+
30
+ const lines = await Promise.all(workers);
31
+ console.log('\nraw output:');
32
+ lines.forEach(l => console.log(' ' + l));
33
+
34
+ const events = [];
35
+ for (const l of lines) {
36
+ const m = l.match(/^(\d+) acquired (\d+); released (\d+)$/);
37
+ if (!m) continue;
38
+ events.push({ id: parseInt(m[1]), acquired: parseInt(m[2]), released: parseInt(m[3]) });
39
+ }
40
+
41
+ if (events.length !== N) {
42
+ console.log(`\n❌ expected ${N} successful workers, got ${events.length}`);
43
+ process.exit(1);
44
+ }
45
+
46
+ events.sort((a, b) => a.acquired - b.acquired);
47
+ console.log('\ntimeline (sorted by acquire):');
48
+ events.forEach((e, i) => console.log(` worker ${e.id}: [${e.acquired - events[0].acquired}ms .. ${e.released - events[0].acquired}ms]`));
49
+
50
+ let ok = true;
51
+ for (let i = 1; i < events.length; i++) {
52
+ const prev = events[i - 1];
53
+ const curr = events[i];
54
+ if (curr.acquired < prev.released) {
55
+ ok = false;
56
+ console.log(`\n❌ OVERLAP: worker ${prev.id} held until ${prev.released}, worker ${curr.id} acquired at ${curr.acquired} (overlap ${prev.released - curr.acquired}ms)`);
57
+ }
58
+ }
59
+
60
+ if (ok) {
61
+ const totalSpan = events[events.length - 1].released - events[0].acquired;
62
+ const expectedMin = N * HOLD_MS;
63
+ console.log(`\n✅ mutual exclusion PASSED — ${N} workers serialised in ${totalSpan}ms (expected >= ${expectedMin}ms)`);
64
+ process.exit(0);
65
+ } else {
66
+ process.exit(1);
67
+ }
68
+ })();
@@ -1,8 +1,8 @@
1
1
  ---
2
2
  name: feishu-user-plugin
3
- version: "1.3.4"
4
- description: "All-in-one Feishu plugin — send messages as yourself, read group/P2P chats, manage docs/tables/wiki (with wiki-node + URL transparent resolution), OKR, calendar, docx image read/write. Replaces and extends the official Feishu MCP."
5
- allowed-tools: send_to_user, send_to_group, send_as_user, send_image_as_user, send_file_as_user, send_post_as_user, send_sticker_as_user, send_audio_as_user, search_contacts, create_p2p_chat, get_chat_info, get_user_info, get_login_status, read_p2p_messages, list_user_chats, list_chats, read_messages, reply_message, forward_message, search_docs, read_doc, create_doc, list_bitable_tables, list_bitable_fields, search_bitable_records, create_bitable_record, update_bitable_record, list_wiki_spaces, search_wiki, list_wiki_nodes, get_wiki_node, list_files, create_folder, find_user, download_image, list_user_okrs, get_okrs, list_okr_periods, list_calendars, list_calendar_events, get_calendar_event
3
+ version: "1.3.5"
4
+ description: "All-in-one Feishu plugin — send messages as yourself, read group/P2P chats (auto-expands merge_forward), manage docs/tables/wiki (with wiki-node + URL transparent resolution), OKR, calendar, docx image read/write. v1.3.5: cross-process UAT refresh lock, fallback warning, download_file for chat attachments."
5
+ allowed-tools: send_to_user, send_to_group, send_as_user, send_image_as_user, send_file_as_user, send_post_as_user, send_sticker_as_user, send_audio_as_user, search_contacts, create_p2p_chat, get_chat_info, get_user_info, get_login_status, read_p2p_messages, list_user_chats, list_chats, read_messages, reply_message, forward_message, search_docs, read_doc, create_doc, list_bitable_tables, list_bitable_fields, search_bitable_records, create_bitable_record, update_bitable_record, list_wiki_spaces, search_wiki, list_wiki_nodes, get_wiki_node, list_files, create_folder, find_user, download_image, download_file, list_user_okrs, get_okrs, list_okr_periods, list_calendars, list_calendar_events, get_calendar_event
6
6
  user_invocable: true
7
7
  ---
8
8
 
@@ -6,7 +6,7 @@ All-in-one Feishu plugin for Claude Code with three auth layers:
6
6
  - **Official API** (app credentials): Read group messages, docs, tables, wiki, drive, contacts, upload files
7
7
  - **User OAuth UAT** (user_access_token): Read P2P chat history, list all user's chats
8
8
 
9
- ## Tool Categories (74 tools)
9
+ ## Tool Categories (75 tools)
10
10
 
11
11
  ### User Identity — Messaging (reverse-engineered, cookie-based)
12
12
  - `send_to_user` — Search user + send text (one step, most common). Returns candidates if multiple matches.
@@ -32,7 +32,7 @@ All-in-one Feishu plugin for Claude Code with three auth layers:
32
32
  - **All docx + bitable + drive create/read/write tools are UAT-first**: when UAT is configured, every operation (create/edit/delete doc blocks, bitable tables/fields/views/records, drive folders) tries the user's token first and falls back to app token on failure. This keeps resources consistently owned by the user and avoids 403 errors when the app can't access user-created resources. Read-only tools (e.g. `read_doc`, `get_doc_blocks`, `list_bitable_tables`) are also UAT-first so user-owned resources remain readable.
33
33
 
34
34
  ### Official API Tools (app credentials)
35
- - `list_chats` / `read_messages` — Chat history (read_messages accepts chat name, oc_ ID, or numeric ID; auto-resolves via bot's group list → im.chat.search → search_contacts). **Auto-falls back to UAT for external groups the bot cannot access.** Returns newest messages first by default. Messages include sender names.
35
+ - `list_chats` / `read_messages` — Chat history (read_messages accepts chat name, oc_ ID, or numeric ID; auto-resolves via bot's group list → im.chat.search → search_contacts). **Auto-falls back to UAT for external groups the bot cannot access.** Returns newest messages first by default. Messages include sender names. **v1.3.5**: `merge_forward` messages now auto-expand into their child messages (2 images + 4 texts, with original sender / time / origin chat preserved); text messages get `urls[]` + `feishuDocs[]` extracted so agents can feed them straight into `read_doc` / WebFetch. Disable expansion with `expand_merge_forward=false`.
36
36
  - `send_message_as_bot` — Bot sends message to any chat (text, post, interactive, etc.)
37
37
  - `reply_message` / `forward_message` — Message operations (as bot)
38
38
  - `delete_message` / `update_message` — Recall or edit bot's own messages
@@ -52,7 +52,8 @@ All-in-one Feishu plugin for Claude Code with three auth layers:
52
52
  - `list_files` / `create_folder` — Drive
53
53
  - `copy_file` / `move_file` / `delete_file` — Drive file operations (copy, move, delete)
54
54
  - `upload_image` / `upload_file` — Upload image/file, returns key for send_image/send_file
55
- - `download_image` — Download an image and return it as MCP image content so the model **sees the pixels**. Two modes: (1) **message image** — pass `message_id` + `image_key` from read_messages. (2) **docx image** — pass `image_token` (from `get_doc_blocks` image block) and optionally `doc_token` (native id / wiki node / Feishu URL). Tries UAT first, falls back to app token.
55
+ - `download_image` — Download an image and return it as MCP image content so the model **sees the pixels**. Two modes: (1) **message image** — pass `message_id` + `image_key` from read_messages. (2) **docx image** — pass `image_token` (from `get_doc_blocks` image block) and optionally `doc_token` (native id / wiki node / Feishu URL). Tries UAT first, falls back to app token. **merge_forward children**: use the child's `parentMessageId` (NOT the child id) — Feishu returns `File not in msg` with the child id.
56
+ - `download_file` — Download a file (msg_type=file) attachment. Returns base64 + mimeType + byte count; optional `save_path` writes the file to disk. Same parent-id rule for merge_forward children as download_image.
56
57
  - `list_user_okrs` / `get_okrs` / `list_okr_periods` — OKR read. UAT-first (works for the authenticated user's OKRs) with app fallback when OKR scope is granted.
57
58
  - `list_calendars` / `list_calendar_events` / `get_calendar_event` — Calendar read. UAT-first (primary + shared + subscribed); app identity only sees calendars the bot was explicitly invited to.
58
59
  - `find_user` — Contact lookup by email/mobile
@@ -307,7 +308,22 @@ Two known root causes, both fixed in v1.3.3:
307
308
  ### If UAT refresh fails with "invalid_grant"
308
309
  - The refresh token has expired or been revoked — auto-refresh cannot recover this
309
310
  - **Fix**: Re-run OAuth: `npx feishu-user-plugin oauth`
310
- - Then restart Claude Code
311
+ - Then restart Claude Code / Codex so running MCP server processes load the new token
312
+ - **v1.3.5+ hardening** (no manual action required, fixes the common case):
313
+ - Cross-process file lock at `~/.claude/feishu-uat-refresh.lock` (`O_CREAT|O_EXCL`, 30 s stale detection) — at most one MCP process refreshes at a time.
314
+ - Inside the critical section the lock holder re-reads `~/.claude.json` to see if a peer already rotated the token; if so, it adopts the fresh token instead of consuming an already-invalidated refresh token.
315
+ - This closes the "Codex spawned 6 MCP servers, all shared the same refresh_token, all raced to refresh" failure mode observed on 2026-04-23.
316
+ - `get_login_status` now does a real UAT health check (calls `listChatsAsUser({pageSize:1})`) — no more "token configured but actually 401" surprises.
317
+
318
+ ### If multiple MCP server processes keep spawning
319
+ - Observed on Codex + Claude Code when the client respawns the server for each tool session without cleaning up the previous one. 6 concurrent `src/index.js` processes is not unusual under heavy use.
320
+ - v1.3.5 neutralises the damage (UAT refresh serialised via file lock) but the stale processes still consume memory.
321
+ - **Manual cleanup when you notice**: `pkill -f 'feishu-user-plugin/src/index.js'` — the client will respawn one fresh process on the next tool call.
322
+
323
+ ### If a create_* tool warns "UAT failed, created as BOT"
324
+ - v1.3.5 added an explicit `⚠️` warning to MCP responses whenever `_asUserOrApp` silently fell back to bot identity for a write (create_doc / create_bitable / create_folder / create_doc_block / ...). Before v1.3.5 this was silent and led to the "teammate can read my 'private' doc" issue.
325
+ - **Cause**: your UAT is failing (expired / scope missing / race) so the plugin reached for bot credentials. The resulting resource is owned by the shared bot, tenant-readable by default, NOT by you.
326
+ - **Fix**: run `npx feishu-user-plugin oauth` and restart Claude Code / Codex. If the resource needs to be yours, delete the bot-owned copy and recreate after UAT is valid.
311
327
 
312
328
  ### If OAuth fails with "Missing LARK_APP_ID"
313
329
  - `oauth.js` reads credentials from `~/.claude.json` MCP config (not .env)
package/src/index.js CHANGED
@@ -326,7 +326,7 @@ const TOOLS = [
326
326
  // ========== IM — Official API (User Identity via UAT) ==========
327
327
  {
328
328
  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.',
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.',
330
330
  inputSchema: {
331
331
  type: 'object',
332
332
  properties: {
@@ -335,6 +335,7 @@ const TOOLS = [
335
335
  start_time: { type: 'string', description: 'Start timestamp in seconds (optional)' },
336
336
  end_time: { type: 'string', description: 'End timestamp in seconds (optional)' },
337
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.' },
338
339
  },
339
340
  required: ['chat_id'],
340
341
  },
@@ -365,7 +366,7 @@ const TOOLS = [
365
366
  },
366
367
  {
367
368
  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.',
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.',
369
370
  inputSchema: {
370
371
  type: 'object',
371
372
  properties: {
@@ -374,6 +375,7 @@ const TOOLS = [
374
375
  start_time: { type: 'string', description: 'Start timestamp in seconds (optional)' },
375
376
  end_time: { type: 'string', description: 'End timestamp in seconds (optional)' },
376
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.' },
377
379
  },
378
380
  required: ['chat_id'],
379
381
  },
@@ -1013,17 +1015,30 @@ const TOOLS = [
1013
1015
  // ========== Message Resources (Image/File Download) ==========
1014
1016
  {
1015
1017
  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.',
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.',
1017
1019
  inputSchema: {
1018
1020
  type: 'object',
1019
1021
  properties: {
1020
- message_id: { type: 'string', description: 'Message ID (om_xxx) — for mode 1 only' },
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.' },
1021
1023
  image_key: { type: 'string', description: 'Image key (img_xxx) from message content — for mode 1 only' },
1022
1024
  doc_token: { type: 'string', description: 'Document ID, wiki node token, or Feishu URL — for mode 2 only' },
1023
1025
  image_token: { type: 'string', description: 'Image token from a docx image block (block.image.token via get_doc_blocks) — for mode 2 only' },
1024
1026
  },
1025
1027
  },
1026
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
+ },
1027
1042
 
1028
1043
  // ========== Wiki Node — Object Resolution (v1.3.4) ==========
1029
1044
  {
@@ -1143,7 +1158,13 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
1143
1158
  });
1144
1159
 
1145
1160
  const text = (s) => ({ content: [{ type: 'text', text: s }] });
1146
- const json = (o) => text(JSON.stringify(o, null, 2));
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
+ };
1147
1168
  const sendResult = (r, desc) => text(r.success ? desc : `Send failed (status: ${r.status})`);
1148
1169
 
1149
1170
  // Resolver helper: turn document_id / app_token / wiki node / Feishu URL into
@@ -1288,7 +1309,17 @@ async function handleTool(name, args) {
1288
1309
  parts.push(`App credentials: INVALID — app_id=${probe.appId} rejected by Feishu (${probe.error})`);
1289
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.`);
1290
1311
  }
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)'}`);
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
+ }
1292
1323
  }
1293
1324
  return text(parts.join('\n'));
1294
1325
  }
@@ -1324,6 +1355,7 @@ async function handleTool(name, args) {
1324
1355
  return json(await official.readMessagesAsUser(chatId, {
1325
1356
  pageSize: args.page_size, startTime: args.start_time, endTime: args.end_time,
1326
1357
  sortType: args.sort_type,
1358
+ expandMergeForward: args.expand_merge_forward !== false,
1327
1359
  }, uc));
1328
1360
  }
1329
1361
  case 'list_user_chats':
@@ -1335,7 +1367,11 @@ async function handleTool(name, args) {
1335
1367
  return json(await getOfficialClient().listChats({ pageSize: args.page_size, pageToken: args.page_token }));
1336
1368
  case 'read_messages': {
1337
1369
  const official = getOfficialClient();
1338
- const msgOpts = { pageSize: args.page_size, startTime: args.start_time, endTime: args.end_time, sortType: args.sort_type };
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
+ };
1339
1375
  // Get userClient for name resolution fallback (best-effort)
1340
1376
  let uc = null;
1341
1377
  try { uc = await getUserClient(); } catch (_) {}
@@ -1382,7 +1418,8 @@ async function handleTool(name, args) {
1382
1418
  : r.wikiAttachTaskId ? ` [wiki attach queued — task_id: ${r.wikiAttachTaskId}]`
1383
1419
  : r.wikiAttachError ? ` [WARNING: wiki attach failed — ${r.wikiAttachError}. Doc exists in drive root/folder.]`
1384
1420
  : '';
1385
- return text(`Document created${ownership}: ${r.documentId}${wikiNote}`);
1421
+ const warn = r.fallbackWarning ? `\n\n${r.fallbackWarning}` : '';
1422
+ return text(`Document created${ownership}: ${r.documentId}${wikiNote}${warn}`);
1386
1423
  }
1387
1424
 
1388
1425
  // --- Official API: Bitable ---
@@ -1397,12 +1434,16 @@ async function handleTool(name, args) {
1397
1434
  : r.wikiAttachTaskId ? `\nWiki attach queued — task_id: ${r.wikiAttachTaskId}`
1398
1435
  : r.wikiAttachError ? `\nWARNING: wiki attach failed — ${r.wikiAttachError}. Bitable exists in drive root/folder.`
1399
1436
  : '';
1400
- return text(`Bitable created${ownership}: ${r.appToken}\nURL: ${r.url || ''}${wikiNote}`);
1437
+ const warn = r.fallbackWarning ? `\n\n${r.fallbackWarning}` : '';
1438
+ return text(`Bitable created${ownership}: ${r.appToken}\nURL: ${r.url || ''}${wikiNote}${warn}`);
1401
1439
  }
1402
1440
  case 'list_bitable_tables':
1403
1441
  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}`);
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
+ }
1406
1447
  case 'list_bitable_fields':
1407
1448
  return json(await getOfficialClient().listBitableFields(await resolveDocId(args.app_token), args.table_id));
1408
1449
  case 'create_bitable_field': {
@@ -1450,7 +1491,8 @@ async function handleTool(name, args) {
1450
1491
  case 'create_folder': {
1451
1492
  const r = await getOfficialClient().createFolder(args.name, args.parent_token);
1452
1493
  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}`);
1494
+ const warn = r.fallbackWarning ? `\n\n${r.fallbackWarning}` : '';
1495
+ return text(`Folder created${ownership}: ${r.token}${warn}`);
1454
1496
  }
1455
1497
 
1456
1498
  // --- Official API: Contact ---
@@ -1592,6 +1634,33 @@ async function handleTool(name, args) {
1592
1634
  };
1593
1635
  }
1594
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
+
1595
1664
  // --- Wiki Node Resolution (v1.3.4) ---
1596
1665
  case 'get_wiki_node': {
1597
1666
  // Accept either a bare wiki node token or a full /wiki/ URL — parse first.
package/src/official.js CHANGED
@@ -34,7 +34,7 @@ class LarkOfficialClient {
34
34
  if (token) {
35
35
  this._uat = token;
36
36
  this._uatRefresh = refresh || null;
37
- this._uatExpires = expires;
37
+ this._uatExpires = expires || this._decodeTokenExpiry(token);
38
38
  }
39
39
  }
40
40
 
@@ -90,6 +90,7 @@ class LarkOfficialClient {
90
90
  if (!this._uat) throw new Error('No user_access_token. Run: npx feishu-user-plugin oauth');
91
91
 
92
92
  const now = Math.floor(Date.now() / 1000);
93
+ if (!this._uatExpires) this._uatExpires = this._decodeTokenExpiry(this._uat);
93
94
  // Proactively refresh if we know it's expiring within 5 min
94
95
  if (this._uatExpires > 0 && this._uatExpires <= now + 300) {
95
96
  return this._refreshUAT();
@@ -97,30 +98,125 @@ class LarkOfficialClient {
97
98
  return this._uat;
98
99
  }
99
100
 
101
+ _decodeTokenExpiry(token) {
102
+ try {
103
+ const payload = token?.split('.')?.[1];
104
+ if (!payload) return 0;
105
+ const data = JSON.parse(Buffer.from(payload, 'base64url').toString('utf8'));
106
+ return typeof data.exp === 'number' ? data.exp : 0;
107
+ } catch (_) {
108
+ return 0;
109
+ }
110
+ }
111
+
112
+ _adoptPersistedUATIfNewer() {
113
+ try {
114
+ const { readCredentials } = require('./config');
115
+ const creds = readCredentials();
116
+ const token = creds.LARK_USER_ACCESS_TOKEN;
117
+ const refresh = creds.LARK_USER_REFRESH_TOKEN;
118
+ if (!token && !refresh) return false;
119
+
120
+ const expires = parseInt(creds.LARK_UAT_EXPIRES || '0') || this._decodeTokenExpiry(token);
121
+ const changed = (token && token !== this._uat)
122
+ || (refresh && refresh !== this._uatRefresh)
123
+ || (expires && expires !== this._uatExpires);
124
+ if (!changed) return false;
125
+
126
+ if (token) this._uat = token;
127
+ if (refresh) this._uatRefresh = refresh;
128
+ this._uatExpires = expires || 0;
129
+ console.error('[feishu-user-plugin] UAT adopted latest persisted token before refresh');
130
+ return true;
131
+ } catch (e) {
132
+ console.error(`[feishu-user-plugin] UAT persisted-token check failed: ${e.message}`);
133
+ return false;
134
+ }
135
+ }
136
+
137
+ // Cross-process advisory lock for UAT refresh. Feishu rotates the refresh_token
138
+ // on every refresh (old one invalidated instantly). When multiple MCP server
139
+ // processes share the same persisted refresh_token and all wake up near expiry,
140
+ // they race: the first wins, the rest see `invalid_grant` and can't recover.
141
+ // This lock serialises refreshes across processes; inside the critical section
142
+ // we also re-read the persisted config so late arrivals adopt the winner's
143
+ // token instead of attempting a doomed refresh with the already-rotated one.
144
+ _uatLockPath() {
145
+ const path = require('path');
146
+ const os = require('os');
147
+ return path.join(os.homedir(), '.claude', 'feishu-uat-refresh.lock');
148
+ }
149
+
150
+ async _acquireRefreshLock(lockPath, { staleMs = 30000, pollMs = 200, timeoutMs = 20000 } = {}) {
151
+ const fs = require('fs');
152
+ const path = require('path');
153
+ try { fs.mkdirSync(path.dirname(lockPath), { recursive: true }); } catch (_) {}
154
+ const start = Date.now();
155
+ while (Date.now() - start < timeoutMs) {
156
+ try {
157
+ const fd = fs.openSync(lockPath, 'wx'); // O_CREAT | O_EXCL
158
+ fs.writeSync(fd, `${process.pid}\n${Date.now()}\n`);
159
+ fs.closeSync(fd);
160
+ return true;
161
+ } catch (e) {
162
+ if (e.code !== 'EEXIST') throw e;
163
+ try {
164
+ const stat = fs.statSync(lockPath);
165
+ if (Date.now() - stat.mtimeMs > staleMs) {
166
+ try { fs.unlinkSync(lockPath); } catch (_) {}
167
+ continue;
168
+ }
169
+ } catch (_) { /* lock vanished under us — retry */ }
170
+ await new Promise(r => setTimeout(r, pollMs));
171
+ }
172
+ }
173
+ return false;
174
+ }
175
+
176
+ _releaseRefreshLock(lockPath) {
177
+ try { require('fs').unlinkSync(lockPath); } catch (_) {}
178
+ }
179
+
100
180
  async _refreshUAT() {
101
- if (!this._uatRefresh) throw new Error('UAT expired and no refresh token. Run: npx feishu-user-plugin oauth');
181
+ const lockPath = this._uatLockPath();
182
+ const acquired = await this._acquireRefreshLock(lockPath);
183
+ if (!acquired) {
184
+ console.error('[feishu-user-plugin] UAT refresh lock timed out; proceeding without mutual exclusion');
185
+ }
186
+ try {
187
+ // Re-check under lock: another process may have already refreshed and
188
+ // persisted a new token while we waited. If so, adopt and skip the refresh.
189
+ const now = Math.floor(Date.now() / 1000);
190
+ if (this._adoptPersistedUATIfNewer() && this._uatExpires > now + 300) {
191
+ return this._uat;
192
+ }
102
193
 
103
- const res = await fetchWithTimeout('https://open.feishu.cn/open-apis/authen/v2/oauth/token', {
104
- method: 'POST',
105
- headers: { 'content-type': 'application/json' },
106
- body: JSON.stringify({
107
- grant_type: 'refresh_token',
108
- client_id: this.appId,
109
- client_secret: this.appSecret,
110
- refresh_token: this._uatRefresh,
111
- }),
112
- });
113
- const data = await res.json();
114
- const tokenData = data.access_token ? data : data.data;
115
- if (!tokenData?.access_token) throw new Error(`UAT refresh failed: ${JSON.stringify(data)}. Run: npx feishu-user-plugin oauth`);
116
-
117
- this._uat = tokenData.access_token;
118
- this._uatRefresh = tokenData.refresh_token || this._uatRefresh;
119
- const expiresIn = typeof tokenData.expires_in === 'number' && tokenData.expires_in > 0 ? tokenData.expires_in : 7200;
120
- this._uatExpires = Math.floor(Date.now() / 1000) + expiresIn;
121
- this._persistUAT();
122
- console.error('[feishu-user-plugin] UAT refreshed successfully');
123
- return this._uat;
194
+ if (!this._uatRefresh) throw new Error('UAT expired and no refresh token. Run: npx feishu-user-plugin oauth');
195
+
196
+ const res = await fetchWithTimeout('https://open.feishu.cn/open-apis/authen/v2/oauth/token', {
197
+ method: 'POST',
198
+ headers: { 'content-type': 'application/json' },
199
+ body: JSON.stringify({
200
+ grant_type: 'refresh_token',
201
+ client_id: this.appId,
202
+ client_secret: this.appSecret,
203
+ refresh_token: this._uatRefresh,
204
+ }),
205
+ });
206
+ const data = await res.json();
207
+ const tokenData = data.access_token ? data : data.data;
208
+ if (!tokenData?.access_token) throw new Error(`UAT refresh failed: ${JSON.stringify(data)}. Run: npx feishu-user-plugin oauth`);
209
+
210
+ this._uat = tokenData.access_token;
211
+ this._uatRefresh = tokenData.refresh_token || this._uatRefresh;
212
+ const expiresIn = typeof tokenData.expires_in === 'number' && tokenData.expires_in > 0 ? tokenData.expires_in : 7200;
213
+ this._uatExpires = Math.floor(Date.now() / 1000) + expiresIn;
214
+ this._persistUAT();
215
+ console.error('[feishu-user-plugin] UAT refreshed successfully');
216
+ return this._uat;
217
+ } finally {
218
+ if (acquired) this._releaseRefreshLock(lockPath);
219
+ }
124
220
  }
125
221
 
126
222
  _persistUAT() {
@@ -210,7 +306,17 @@ class LarkOfficialClient {
210
306
  }
211
307
  try {
212
308
  const appData = await this._safeSDKCall(sdkFn, label);
213
- if (appData && typeof appData === 'object') appData._viaUser = false;
309
+ if (appData && typeof appData === 'object') {
310
+ appData._viaUser = false;
311
+ // Attach a warning when we silently fell back to bot identity. This lets
312
+ // write handlers surface "⚠️ created as BOT, not you" so the user doesn't
313
+ // discover it days later when a teammate can read the "private" resource.
314
+ if (uatSummary) {
315
+ appData._fallbackWarning = `⚠️ UAT 不可用 (${uatSummary}),本次操作以 bot 身份执行。资源归属于共享 bot「Claude聊天助手」,不是你。恢复方法:运行 \`npx feishu-user-plugin oauth\` 后重启 Claude Code / Codex。`;
316
+ } else if (!this.hasUAT) {
317
+ appData._fallbackWarning = `⚠️ 未配置 UAT,本次操作以 bot 身份执行。资源归属于共享 bot「Claude聊天助手」,不是你。想让资源归你所有,先跑 \`npx feishu-user-plugin oauth\` 然后重启 Claude Code / Codex。`;
318
+ }
319
+ }
214
320
  return appData;
215
321
  } catch (appErr) {
216
322
  if (uatSummary) {
@@ -236,7 +342,7 @@ class LarkOfficialClient {
236
342
  return { items: data.data.items || [], pageToken: data.data.page_token, hasMore: data.data.has_more };
237
343
  }
238
344
 
239
- async readMessagesAsUser(chatId, { pageSize = 20, startTime, endTime, pageToken, sortType = 'ByCreateTimeDesc' } = {}, userClient) {
345
+ async readMessagesAsUser(chatId, { pageSize = 20, startTime, endTime, pageToken, sortType = 'ByCreateTimeDesc', expandMergeForward = true } = {}, userClient) {
240
346
  // Feishu API requires end_time >= start_time; auto-set end_time to now if missing
241
347
  if (startTime && !endTime) {
242
348
  endTime = String(Math.floor(Date.now() / 1000));
@@ -257,6 +363,7 @@ class LarkOfficialClient {
257
363
  if (data.code !== 0) throw new Error(`readMessagesAsUser failed (${data.code}): ${data.msg}`);
258
364
  const items = (data.data.items || []).map(m => this._formatMessage(m));
259
365
  await this._populateSenderNames(items, userClient);
366
+ if (expandMergeForward) await this._expandMergeForwardItems(items, userClient, { preferUAT: true });
260
367
  return { items, hasMore: data.data.has_more, pageToken: data.data.page_token };
261
368
  }
262
369
 
@@ -270,7 +377,7 @@ class LarkOfficialClient {
270
377
  return { items: res.data.items || [], pageToken: res.data.page_token, hasMore: res.data.has_more };
271
378
  }
272
379
 
273
- async readMessages(chatId, { pageSize = 20, startTime, endTime, pageToken, sortType = 'ByCreateTimeDesc' } = {}, userClient) {
380
+ async readMessages(chatId, { pageSize = 20, startTime, endTime, pageToken, sortType = 'ByCreateTimeDesc', expandMergeForward = true } = {}, userClient) {
274
381
  const params = { container_id_type: 'chat', container_id: chatId, page_size: pageSize, sort_type: sortType };
275
382
  if (startTime) params.start_time = startTime;
276
383
  if (endTime) params.end_time = endTime;
@@ -278,6 +385,7 @@ class LarkOfficialClient {
278
385
  const res = await this._safeSDKCall(() => this.client.im.message.list({ params }), 'readMessages');
279
386
  const items = (res.data.items || []).map(m => this._formatMessage(m));
280
387
  await this._populateSenderNames(items, userClient);
388
+ if (expandMergeForward) await this._expandMergeForwardItems(items, userClient, { preferUAT: false });
281
389
  return { items, hasMore: res.data.has_more, pageToken: res.data.page_token };
282
390
  }
283
391
 
@@ -562,7 +670,7 @@ class LarkOfficialClient {
562
670
  label: 'createDoc',
563
671
  });
564
672
  const documentId = res.data.document?.document_id;
565
- const out = { documentId, viaUser: !!res._viaUser };
673
+ const out = { documentId, viaUser: !!res._viaUser, fallbackWarning: res._fallbackWarning || null };
566
674
  if (documentId && wikiSpaceId) {
567
675
  try {
568
676
  const node = await this.attachToWiki(wikiSpaceId, 'docx', documentId, wikiParentNodeToken);
@@ -598,7 +706,7 @@ class LarkOfficialClient {
598
706
  }),
599
707
  label: 'createDocBlock',
600
708
  });
601
- return { blocks: res.data.children || [] };
709
+ return { blocks: res.data.children || [], fallbackWarning: res._fallbackWarning || null };
602
710
  }
603
711
 
604
712
  async updateDocBlock(documentId, blockId, updateBody) {
@@ -653,7 +761,7 @@ class LarkOfficialClient {
653
761
  label: 'createBitable',
654
762
  });
655
763
  const appToken = res.data.app?.app_token;
656
- const out = { appToken, name: res.data.app?.name, url: res.data.app?.url, viaUser: !!res._viaUser };
764
+ const out = { appToken, name: res.data.app?.name, url: res.data.app?.url, viaUser: !!res._viaUser, fallbackWarning: res._fallbackWarning || null };
657
765
  if (appToken && wikiSpaceId) {
658
766
  try {
659
767
  const node = await this.attachToWiki(wikiSpaceId, 'bitable', appToken, wikiParentNodeToken);
@@ -686,7 +794,7 @@ class LarkOfficialClient {
686
794
  sdkFn: () => this.client.bitable.appTable.create({ path: { app_token: appToken }, data }),
687
795
  label: 'createTable',
688
796
  });
689
- return { tableId: res.data.table_id };
797
+ return { tableId: res.data.table_id, fallbackWarning: res._fallbackWarning || null };
690
798
  }
691
799
 
692
800
  async listBitableFields(appToken, tableId) {
@@ -706,7 +814,7 @@ class LarkOfficialClient {
706
814
  sdkFn: () => this.client.bitable.appTableField.create({ path: { app_token: appToken, table_id: tableId }, data: fieldConfig }),
707
815
  label: 'createField',
708
816
  });
709
- return { field: res.data.field };
817
+ return { field: res.data.field, fallbackWarning: res._fallbackWarning || null };
710
818
  }
711
819
 
712
820
  async updateBitableField(appToken, tableId, fieldId, fieldConfig) {
@@ -760,7 +868,7 @@ class LarkOfficialClient {
760
868
  sdkFn: () => this.client.bitable.appTableRecord.create({ path: { app_token: appToken, table_id: tableId }, data: { fields } }),
761
869
  label: 'createRecord',
762
870
  });
763
- return { recordId: res.data.record?.record_id };
871
+ return { recordId: res.data.record?.record_id, fallbackWarning: res._fallbackWarning || null };
764
872
  }
765
873
 
766
874
  async updateBitableRecord(appToken, tableId, recordId, fields) {
@@ -792,7 +900,7 @@ class LarkOfficialClient {
792
900
  sdkFn: () => this.client.bitable.appTableRecord.batchCreate({ path: { app_token: appToken, table_id: tableId }, data: { records } }),
793
901
  label: 'batchCreateRecords',
794
902
  });
795
- return { records: res.data.records || [] };
903
+ return { records: res.data.records || [], fallbackWarning: res._fallbackWarning || null };
796
904
  }
797
905
 
798
906
  async batchUpdateBitableRecords(appToken, tableId, records) {
@@ -874,7 +982,7 @@ class LarkOfficialClient {
874
982
  sdkFn: () => this.client.bitable.appTableView.create({ path: { app_token: appToken, table_id: tableId }, data: { view_name: viewName, view_type: viewType } }),
875
983
  label: 'createView',
876
984
  });
877
- return { view: res.data.view };
985
+ return { view: res.data.view, fallbackWarning: res._fallbackWarning || null };
878
986
  }
879
987
 
880
988
  async deleteBitableView(appToken, tableId, viewId) {
@@ -897,7 +1005,7 @@ class LarkOfficialClient {
897
1005
  sdkFn: () => this.client.bitable.app.copy({ path: { app_token: appToken }, data }),
898
1006
  label: 'copyBitable',
899
1007
  });
900
- return { app: res.data.app };
1008
+ return { app: res.data.app, fallbackWarning: res._fallbackWarning || null };
901
1009
  }
902
1010
 
903
1011
  // --- Wiki ---
@@ -952,7 +1060,7 @@ class LarkOfficialClient {
952
1060
  sdkFn: () => this.client.drive.file.createFolder({ data: body }),
953
1061
  label: 'createFolder',
954
1062
  });
955
- return { token: res.data.token, viaUser: !!res._viaUser };
1063
+ return { token: res.data.token, viaUser: !!res._viaUser, fallbackWarning: res._fallbackWarning || null };
956
1064
  }
957
1065
 
958
1066
  // --- Drive: File Operations ---
@@ -1110,9 +1218,108 @@ class LarkOfficialClient {
1110
1218
  updateTime: this._normalizeTimestamp(m.update_time),
1111
1219
  };
1112
1220
  if (Array.isArray(m.mentions) && m.mentions.length > 0) out.mentions = m.mentions;
1221
+ if (m.upper_message_id) out.upperMessageId = m.upper_message_id;
1222
+ if (m.root_id) out.rootId = m.root_id;
1223
+ if (m.parent_id) out.parentId = m.parent_id;
1224
+ // Extract URL-like strings from text bodies so agents can call WebFetch /
1225
+ // read_doc / get_doc_blocks without having to regex the body themselves.
1226
+ if (out.msgType === 'text' && typeof body?.text === 'string') {
1227
+ const urls = body.text.match(/https?:\/\/[^\s一-鿿]+/g);
1228
+ if (urls && urls.length > 0) {
1229
+ out.urls = Array.from(new Set(urls));
1230
+ const feishuDocs = out.urls.filter(u =>
1231
+ /feishu\.cn\/(?:docx|wiki|base|sheets|docs|mindnotes)\//i.test(u));
1232
+ if (feishuDocs.length > 0) out.feishuDocs = feishuDocs;
1233
+ }
1234
+ }
1113
1235
  return out;
1114
1236
  }
1115
1237
 
1238
+ // Fetch the child messages inside a merge_forward parent. Feishu exposes them
1239
+ // via `/im/v1/messages/{parent_id}` (single-message GET). The response is
1240
+ // actually a list: items[0] is the parent merge_forward placeholder,
1241
+ // items[1..N] are the children carrying `upper_message_id` pointing back to
1242
+ // the parent and `chat_id` pointing to their ORIGIN chat (the one being
1243
+ // forwarded from, not where the merge_forward was posted).
1244
+ //
1245
+ // Media resources (image_key / file_key) on children must be downloaded
1246
+ // using the PARENT message id — a Feishu quirk: downloading with the child
1247
+ // id returns "File not in msg".
1248
+ async readMergeForwardChildren(parentMessageId, userClient, { preferUAT = true } = {}) {
1249
+ const url = `https://open.feishu.cn/open-apis/im/v1/messages/${encodeURIComponent(parentMessageId)}`;
1250
+
1251
+ const tryPath = async (bearer) => {
1252
+ const res = await fetchWithTimeout(url, {
1253
+ headers: { 'Authorization': `Bearer ${bearer}` },
1254
+ timeoutMs: 30000,
1255
+ });
1256
+ return res.json();
1257
+ };
1258
+
1259
+ let data = null;
1260
+ const order = preferUAT ? ['uat', 'bot'] : ['bot', 'uat'];
1261
+ const errors = [];
1262
+ for (const identity of order) {
1263
+ try {
1264
+ if (identity === 'uat') {
1265
+ if (!this.hasUAT) { errors.push('uat: not configured'); continue; }
1266
+ const uat = await this._getValidUAT();
1267
+ const resp = await tryPath(uat);
1268
+ if (resp.code === 0) { data = resp; break; }
1269
+ errors.push(`uat: code=${resp.code} msg=${resp.msg}`);
1270
+ } else {
1271
+ const tat = await this._getAppToken();
1272
+ const resp = await tryPath(tat);
1273
+ if (resp.code === 0) { data = resp; break; }
1274
+ errors.push(`bot: code=${resp.code} msg=${resp.msg}`);
1275
+ }
1276
+ } catch (e) {
1277
+ errors.push(`${identity}: ${e.message}`);
1278
+ }
1279
+ }
1280
+ if (!data) {
1281
+ throw new Error(`readMergeForwardChildren failed: ${errors.join(' | ')}`);
1282
+ }
1283
+
1284
+ // items[0] is the parent itself — filter it out. The rest are children.
1285
+ const rawChildren = (data.data?.items || []).filter(m =>
1286
+ m.message_id !== parentMessageId && m.upper_message_id);
1287
+
1288
+ const children = rawChildren.map(raw => {
1289
+ const f = this._formatMessage(raw);
1290
+ // Surface the parent id on the child so downstream tools (download_image /
1291
+ // download_file) know which id to pass to Feishu's resource endpoint.
1292
+ f.parentMessageId = parentMessageId;
1293
+ // Mark the origin chat explicitly — child.chatId is the ORIGINAL chat the
1294
+ // message came from, not the chat where the merge_forward was posted.
1295
+ f.originChatId = raw.chat_id;
1296
+ return f;
1297
+ });
1298
+ await this._populateSenderNames(children, userClient);
1299
+ return children;
1300
+ }
1301
+
1302
+ // Expand merge_forward placeholders in-place. Adds `children: [...]` or
1303
+ // `expandError` on each merge_forward item. `depth` guards against nesting
1304
+ // (Feishu does allow nested merge_forward, but we cap at 1 level to avoid
1305
+ // exponential fan-out in agent contexts).
1306
+ async _expandMergeForwardItems(items, userClient, { preferUAT = true, depth = 0, maxDepth = 1 } = {}) {
1307
+ if (!items || depth >= maxDepth) return;
1308
+ for (const m of items) {
1309
+ if (m.msgType !== 'merge_forward') continue;
1310
+ try {
1311
+ const children = await this.readMergeForwardChildren(m.messageId, userClient, { preferUAT });
1312
+ m.children = children;
1313
+ // One extra level deep if user really wants, via recursive call.
1314
+ if (depth + 1 < maxDepth) {
1315
+ await this._expandMergeForwardItems(children, userClient, { preferUAT, depth: depth + 1, maxDepth });
1316
+ }
1317
+ } catch (e) {
1318
+ m.expandError = e.message;
1319
+ }
1320
+ }
1321
+ }
1322
+
1116
1323
  _normalizeTimestamp(ts) {
1117
1324
  if (!ts) return null;
1118
1325
  const n = parseInt(ts);
@@ -1335,6 +1542,7 @@ class LarkOfficialClient {
1335
1542
  // Step 2 — upload (if needed).
1336
1543
  let finalToken = imageToken;
1337
1544
  let viaUser = !!created._viaUser;
1545
+ let fallbackWarning = created._fallbackWarning || null;
1338
1546
  if (!finalToken) {
1339
1547
  const uploaded = await this.uploadDocMedia(imagePath, blockId, 'docx_image');
1340
1548
  finalToken = uploaded.fileToken;
@@ -1354,7 +1562,7 @@ class LarkOfficialClient {
1354
1562
  label: 'createDocBlockWithImage.replaceImage',
1355
1563
  });
1356
1564
 
1357
- return { blockId, imageToken: finalToken, viaUser };
1565
+ return { blockId, imageToken: finalToken, viaUser, fallbackWarning };
1358
1566
  }
1359
1567
 
1360
1568
  // Replace an existing image block's media token (e.g. swap the picture in an