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.
- package/.claude-plugin/plugin.json +2 -2
- package/README.md +8 -5
- 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 +20 -4
- package/src/index.js +81 -12
- package/src/official.js +245 -37
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "feishu-user-plugin",
|
|
3
|
-
"version": "1.3.
|
|
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.
|
|
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)
|
|
7
7
|
[](CONTRIBUTING.md)
|
|
8
8
|
|
|
9
|
-
**All-in-one Feishu/Lark MCP Server --
|
|
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 (
|
|
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
|
|
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 (
|
|
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
|
-
"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.
|
|
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
|
-
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.
|
|
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 (
|
|
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) =>
|
|
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
|
-
|
|
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 = {
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
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')
|
|
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
|