feishu-user-plugin 1.3.3 → 1.3.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "feishu-user-plugin",
3
- "version": "1.3.3",
4
- "description": "All-in-one Feishu plugin for Claude Code — send messages as yourself (incl. real @-mentions), read chats, manage docs/tables/wiki. 67 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/CHANGELOG.md CHANGED
@@ -4,6 +4,27 @@ All notable changes to this project will be documented in this file.
4
4
 
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/), and this project adheres to [Semantic Versioning](https://semver.org/).
6
6
 
7
+ ## [1.3.4] - 2026-04-22
8
+
9
+ ### Added
10
+ - **Wiki-hosted content is now first-class**: every docx and bitable tool accepts the `document_id` / `app_token` parameter in three forms — native token (unchanged), wiki node token (`wikcnXXX` / `wikmXXX` / `wiknXXX`), or a full Feishu URL (`https://xxx.feishu.cn/docx/XXX`, `.../wiki/XXX`, `.../base/XXX`). A new `src/resolver.js` parses the input, calls `wiki/v2/spaces/get_node` when needed to resolve to `obj_token` + `obj_type`, and caches the mapping for 10 min. Zero-lookup path for direct URLs.
11
+ - **`get_wiki_node` tool**: explicitly resolves a Wiki node to its backing object (`obj_type` + `obj_token` + `space_id`). Useful when you need to branch behaviour on whether a node points at a docx, bitable, sheet, mindnote, file, or slides.
12
+ - **Create docx / bitable directly under Wiki**: `create_doc` / `create_bitable` accept optional `wiki_space_id` (and `wiki_parent_node_token` for nested placement). Plugin creates the resource in drive, then calls `wiki/v2/spaces/{space_id}/nodes/move_docs_to_wiki` to attach it. Returns `wikiNodeToken` on success, `wikiAttachTaskId` when Feishu queues the move, or a warning if attach fails (resource still in drive).
13
+ - **Docx image read**: `download_image` now has a docx mode — pass `image_token` (from `get_doc_blocks` image block) and optional `doc_token` (native / wiki node / URL). Routes through `drive/v1/medias/{token}/download`, returns base64 as MCP image content so the model sees the pixels.
14
+ - **Docx image write**: `create_doc_block` gains two shortcut parameters — `image_path` (local file) automatically runs the three-step Feishu flow (create empty image block → upload via `drive/v1/medias/upload_all` with `parent_type=docx_image` and the new block_id → patch with `replace_image`); `image_token` reuses an already-uploaded media token. `update_doc_block` accepts `image_token` to swap the picture in an existing image block.
15
+ - **`list_user_okrs` / `get_okrs` / `list_okr_periods` tools**: read a user's OKRs, batch fetch full objective + key result details (progress, alignments, mentions), and enumerate periods. UAT-first with app fallback when the OKR scope is granted.
16
+ - **`list_calendars` / `list_calendar_events` / `get_calendar_event` tools**: list the user's calendars (primary / shared / subscribed), list events in a time window, and fetch full event details (attendees, location, meeting links, attachments).
17
+
18
+ ### Fixed
19
+ - **External-group `read_messages` hardening**: new `src/error-codes.js` classifies bot failures. Known-needs-UAT codes (`240001` external tenant, `70009` no permission, `70003` / `99991668` bot not in chat, `19001` chat not found) hop straight to UAT. Transient codes (`42101` rate limit, `5xx`, `ECONNRESET`, fetch timeouts) retry once after a 2 s delay before falling back. Response now includes `via: "bot" | "user" | "contacts"` and, when fallback fires, `via_reason` (e.g. `bot_external_tenant`). When the `chat_id` was discovered via `search_contacts` (i.e. definitely external) the bot path is skipped entirely.
20
+ - **Raw Feishu payload no longer leaks when UAT is missing**: bot failures with no UAT configured now produce `Cannot read chat <id> as bot (<reason>). To read external/private groups, configure UAT via: npx feishu-user-plugin oauth` — previously the caller got the unwrapped Feishu error JSON.
21
+ - **`_uatREST` array query params**: OKR / calendar endpoints that take repeated query keys (e.g. `period_ids=p1&period_ids=p2`) now serialize correctly. Previously `URLSearchParams(query)` would call `toString` on arrays and produce CSV, which Feishu rejects.
22
+
23
+ ### Changed
24
+ - Tool count 67 → **74** (+7: `get_wiki_node`, `list_user_okrs`, `get_okrs`, `list_okr_periods`, `list_calendars`, `list_calendar_events`, `get_calendar_event`).
25
+ - `getWikiNode(nodeToken, _spaceId)` — `spaceId` parameter position swapped; retained only for backward-compatibility of any external caller. The endpoint itself ignores `space_id`.
26
+ - `create_doc_block` no longer requires `children` — callers who use the new `image_path` or `image_token` shortcut omit it. One of `children` / `image_path` / `image_token` must be provided.
27
+
7
28
  ## [1.3.3] - 2026-04-20
8
29
 
9
30
  ### Fixed
package/README.md CHANGED
@@ -3,10 +3,10 @@
3
3
  [![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](LICENSE)
4
4
  [![Node.js](https://img.shields.io/badge/Node.js-%3E%3D18-green.svg)](https://nodejs.org)
5
5
  [![MCP](https://img.shields.io/badge/MCP-Compatible-purple.svg)](https://modelcontextprotocol.io)
6
- [![Tools](https://img.shields.io/badge/Tools-67-orange.svg)](#tools)
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 -- 67 tools, 9 skills, 3 auth layers for messaging, docs, bitable, calendar, tasks, drive, 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 (67 total)
340
+ ## Tools (75 total)
341
341
 
342
342
  ### User Identity -- Messaging (8 tools, cookie auth)
343
343
 
@@ -390,7 +390,22 @@ 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 an image from a message by message_id + image_key, returned as MCP image content so the model can see the pixels |
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) |
395
+
396
+ ### Wiki, OKR, and Calendar (v1.3.4)
397
+
398
+ | Tool | Description |
399
+ |------|-------------|
400
+ | `get_wiki_node` | Resolve a Wiki node token to its underlying obj_type + obj_token + space_id |
401
+ | `list_user_okrs` | List a user's OKRs (requires open_id; filter by period_ids) |
402
+ | `get_okrs` | Batch-fetch full OKR details (objectives, key results, progress, alignments) |
403
+ | `list_okr_periods` | List OKR periods (quarters / years) |
404
+ | `list_calendars` | List the current user's calendars (primary + shared + subscribed) |
405
+ | `list_calendar_events` | List events in a calendar within a time range |
406
+ | `get_calendar_event` | Full event details (attendees, location, meeting link, attachments) |
407
+
408
+ All docx / bitable tools' `document_id` / `app_token` parameter also accepts a Wiki node token or a full Feishu URL — the plugin resolves it transparently.
394
409
 
395
410
  ### Official API -- Documents (7 tools)
396
411
 
@@ -494,10 +509,12 @@ Skills are automatically available when the plugin is installed.
494
509
  |------------|-------|----------|---------|
495
510
  | Cookie | `sl_session` | 12h max-age | Auto-refreshed every 4h via heartbeat |
496
511
  | App Token | `tenant_access_token` | 2h | Auto-managed by SDK |
497
- | 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 |
498
513
 
499
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.
500
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
+
501
518
  ## Project Structure
502
519
 
503
520
  ```
@@ -509,7 +526,7 @@ feishu-user-plugin/
509
526
  │ ├── SKILL.md # Main skill definition (trigger, tools, auth)
510
527
  │ └── references/ # 8 skill reference docs + CLAUDE.md
511
528
  ├── src/
512
- │ ├── index.js # MCP server entry point (67 tools)
529
+ │ ├── index.js # MCP server entry point (75 tools)
513
530
  │ ├── client.js # User identity client (Protobuf gateway)
514
531
  │ ├── official.js # Official API client (REST, UAT)
515
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.3",
4
- "description": "All-in-one Feishu plugin for Claude Code & Codex — messaging, docs, bitable, wiki, drive. 67 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.3"
4
- description: "All-in-one Feishu plugin — send messages as yourself, read group/P2P chats, manage docs/tables/wiki, download images. 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, list_files, create_folder, find_user, download_image
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 (67 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
@@ -48,15 +48,49 @@ All-in-one Feishu plugin for Claude Code with three auth layers:
48
48
  - `list_bitable_views` / `create_bitable_view` / `delete_bitable_view` — View management (grid, kanban, gallery, form, gantt, calendar)
49
49
  - `search_bitable_records` / `get_bitable_record` — Query records
50
50
  - `batch_create_bitable_records` / `batch_update_bitable_records` / `batch_delete_bitable_records` — Record CRUD (single or batch, max 500/call)
51
- - `list_wiki_spaces` / `search_wiki` / `list_wiki_nodes` — Wiki
51
+ - `list_wiki_spaces` / `search_wiki` / `list_wiki_nodes` / `get_wiki_node` — Wiki (v1.3.4 adds `get_wiki_node` which resolves a wiki node token to its underlying `obj_type` + `obj_token`, so you can feed the node straight into `read_doc`, bitable tools, etc.)
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 from a message (needs message_id + image_key from read_messages) and return it as MCP image content so the model can **see the pixels**, not just the key. Tries UAT first, falls back to app token (app path requires the bot to be in the chat).
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.
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.
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.
56
59
  - `find_user` — Contact lookup by email/mobile
57
60
 
58
61
  ## Usage Patterns
59
62
 
63
+ ### Wiki-hosted content (docx / bitable / sheet)
64
+ All docx and bitable tools now accept three input forms for their `document_id` / `app_token` parameter:
65
+ - Native token (unchanged): `doccnXXX`, `docxXXX`, `bascnXXX`, ...
66
+ - Wiki node token: `wikcnXXX`, `wikmXXX`, `wiknXXX`
67
+ - Full Feishu URL: `https://xxx.feishu.cn/docx/XXX`, `.../wiki/XXX`, `.../base/XXX`
68
+ The plugin resolves wiki nodes to their underlying `obj_token` via `getWikiNode`, then calls the normal docx / bitable endpoint. Results are cached for 10 min to avoid repeated node lookups.
69
+
70
+ Create content directly into a Wiki space:
71
+ - `create_doc` / `create_bitable` accept optional `wiki_space_id` (+ `wiki_parent_node_token`). The plugin creates the resource in drive, then calls `wiki/v2/spaces/{space_id}/nodes/move_docs_to_wiki` to attach it — returns `wikiNodeToken` on immediate success, or `wikiAttachTaskId` if Feishu queues the move.
72
+
73
+ ### Document images
74
+ Read — `download_image` with `doc_token` + `image_token` returns the image as MCP image content (base64 + mimeType). `doc_token` accepts native id / wiki node / URL.
75
+ Write — `create_doc_block` now has image shortcuts:
76
+ - `image_path` (absolute local file path) → plugin creates an image block, uploads the pixels via `drive/v1/medias/upload_all`, and patches the block with the uploaded token.
77
+ - `image_token` (already uploaded) → plugin creates block and attaches token.
78
+ `update_doc_block` accepts `image_token` to swap the picture in an existing image block.
79
+
80
+ ### OKR
81
+ 1. `list_okr_periods` — find the period id for current quarter.
82
+ 2. `list_user_okrs(user_id=<open_id>, period_ids=[...])` — list the target user's OKRs.
83
+ 3. `get_okrs(okr_ids)` — batch fetch full objective + key result structure with progress + alignments.
84
+ `user_id` is required — use your own open_id (from `get_login_status` / `search_contacts`) to read your own OKRs, or a colleague's open_id for theirs (subject to permissions).
85
+
86
+ ### Calendar
87
+ 1. `list_calendars` — get your calendars; the one with `type=primary` is your personal calendar.
88
+ 2. `list_calendar_events(calendar_id, start_time=<unix_sec>, end_time=<unix_sec>)` — list events in a time window.
89
+ 3. `get_calendar_event(calendar_id, event_id)` — full details (attendees, location, attachments, meeting link).
90
+
91
+ ### External-group message read (hardened in v1.3.4)
92
+ `read_messages` and `read_p2p_messages` now expose a `via` field in the response (`"bot"`, `"user"`, or `"contacts"`) so callers can tell which identity actually read the data. When bot fails with a known code (external tenant / no permission / not in chat) the plugin hops straight to UAT; transient errors (rate limit / 5xx / ECONNRESET / fetch timeout) retry once with a 2 s delay before falling back. When UAT isn't configured, the error message now tells the user to run `npx feishu-user-plugin oauth` instead of leaking the raw Feishu payload.
93
+
60
94
  ### Messaging
61
95
  - Send text as yourself → `send_to_user` or `send_to_group`
62
96
  - Send image → `upload_image` → `send_image_as_user`
@@ -274,7 +308,22 @@ Two known root causes, both fixed in v1.3.3:
274
308
  ### If UAT refresh fails with "invalid_grant"
275
309
  - The refresh token has expired or been revoked — auto-refresh cannot recover this
276
310
  - **Fix**: Re-run OAuth: `npx feishu-user-plugin oauth`
277
- - 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.
278
327
 
279
328
  ### If OAuth fails with "Missing LARK_APP_ID"
280
329
  - `oauth.js` reads credentials from `~/.claude.json` MCP config (not .env)
@@ -359,6 +408,7 @@ When making ANY code change (new tools, bug fixes, features), update ALL of thes
359
408
 
360
409
  **本仓库内:**
361
410
  - `CLAUDE.md` — tool count, tool list, usage patterns, known limitations
411
+ - `AGENTS.md` — **每次改 CLAUDE.md 必须同步**:正文(第 2 行起)与 CLAUDE.md 完全一致,仅首行标题保留 `# feishu-user-plugin — Codex Instructions`。同步命令:`tail -n +2 CLAUDE.md > /tmp/body.md && { echo "# feishu-user-plugin — Codex Instructions"; cat /tmp/body.md; } > AGENTS.md`
362
412
  - `README.md` — tool count (badge + heading + tool table), feature highlights, OpenClaw/Claude Code config examples
363
413
  - `ROADMAP.md` — check off completed items, add new findings
364
414
  - `package.json` — version, description (tool count)
@@ -421,6 +471,55 @@ Steps:
421
471
  3. `git add <files> && git commit -m "v1.x.x: description"`
422
472
  4. `git tag v1.x.x && git push && git push --tags`
423
473
  5. GitHub Actions verifies tag matches package.json, then auto-publishes to npm
474
+ 6. **After npm confirms the new version is live, draft a release announcement in Chinese for the "AI技术解决(内部)" Feishu group and show it to the user for approval BEFORE sending.** Do not send until the user explicitly approves.
475
+
476
+ ### Release announcement rules (every release)
477
+ After a successful publish, draft a group announcement to "AI技术解决(内部)" (chat_id `7599552782038813643`) and ALWAYS show it to the user for review first. Only send after explicit approval.
478
+
479
+ **Transport**: `send_post_as_user` (rich-text post). No @-mentions — announcements are impersonal broadcasts. No emojis. No marketing language.
480
+
481
+ **Structure** (in this order; omit a section if it doesn't apply this release):
482
+
483
+ ```
484
+ feishu-user-plugin vX.Y.Z 发布
485
+
486
+ <一到两句开篇总结本次发布的主题,陈述语气,不推销>
487
+
488
+ 修复
489
+ • <缺陷描述>:<根因与修复机制,引用具体错误码/接口名/参数>
490
+ • ...
491
+
492
+ 新增
493
+ • 新增 <tool 名> 工具:<一句话功能描述>。<关键约束或调用条件>
494
+ • ...
495
+
496
+ 调整
497
+ • <行为变化的描述>
498
+ • ...
499
+
500
+ 下版本计划
501
+ • <条目>
502
+ • ...
503
+
504
+ 升级方式
505
+ • 重启 Claude Code / Codex 即可自动拉取 X.Y.Z
506
+ • <若有相关新日志/错误提示,说明怎么应对>
507
+ • 建议复测 N 个场景:<场景 1>、<场景 2>、<场景 3>
508
+ ```
509
+
510
+ **写作规范**:
511
+ - **开篇**:一到两句陈述式总结,不宣传、不夸大。参考 v1.3.2:"本次更新主要补齐了 X 能力,并修复了 Y 问题;同时将 Z 统一调整为 ..."
512
+ - **每条 bullet**:先写用户可见现象,再写底层机制。引用具体错误码(如 1770032 / 91403)、接口名(如 create_doc_block)、参数名(如 RichText.atIds)——专业读者信赖的是细节
513
+ - **字符**:bullet 用 `•`(U+2022),不用 `-` 或 `*`;代码/工具名在正文中直接写,不加反引号
514
+ - **禁用**:emoji、🔴🟡🟢 之类严重度标记、`@` 任何人、营销词("强大"、"全新"、"重磅")、夸张修辞
515
+ - **语气**:技术 release note 的中性语气,像写给同行的内部更新。参考 v1.3.2 全文
516
+ - **长度**:单屏为宜,一般 400–700 汉字。每条 bullet 一到三行
517
+ - **下版本计划**:复制自上一版公告仍未完成的条目 + 本次发布中暴露的新方向。本版已完成的条目必须删除
518
+ - **升级方式**:至少包含重启指令;若本次修了某类错误(如 APP_ID 校验),列出对应诊断日志字样;以"建议复测 N 个场景"收尾,场景要具体可操作
519
+
520
+ **结尾**:不加 CHANGELOG 链接(v1.3.2 风格未含链接,群内读者不需要)。
521
+
522
+ **发送前**:始终先用 `send_to_user` 或类似工具发给用户自己审核,或直接以文本形式贴在对话里等用户批准。用户说"发"才调 `send_post_as_user` 到目标群。
424
523
 
425
524
  ### Syncing to team-skills (after any CLAUDE.md or skills change)
426
525
  1. Copy CLAUDE.md to skill reference: `cp CLAUDE.md skills/feishu-user-plugin/references/CLAUDE.md`
@@ -434,6 +533,19 @@ Steps:
434
533
  - For Cookie tools: need active session, test via MCP tool call
435
534
  - Always verify `_safeSDKCall` handles the response format (multipart uploads return data at top level, not nested under `.data`)
436
535
 
536
+ ## OAuth Scopes (when re-running `npx feishu-user-plugin oauth`)
537
+
538
+ The v1.3.4 tools require additional scopes on the app + UAT:
539
+
540
+ | Feature | Scopes to enable on app + include in OAuth |
541
+ |---------|-------------------------------------------|
542
+ | OKR read | `okr:okr:readonly`, `okr:period:read` |
543
+ | Calendar read | `calendar:calendar:readonly`, `calendar:calendar.event:read` |
544
+ | Docx image write (`uploadDocMedia`) | `drive:drive`, `docx:document`, `docx:document:readonly` (image upload piggy-backs on existing docx editing scopes) |
545
+ | Wiki attach (`move_docs_to_wiki`) | `wiki:wiki` (edit scope, the readonly one is insufficient) |
546
+
547
+ If a tool returns `access_denied` or error code `99991672` (scope not granted), enable the missing scope at https://open.feishu.cn/app → Permissions, then re-run `npx feishu-user-plugin oauth` so the UAT picks up the new scopes.
548
+
437
549
  ## Known Limitations
438
550
  - CARD message type (type=14) not yet implemented — complex JSON schema
439
551
  - External tenant users may not be resolvable via `get_user_info` (contact API scope limitation)
@@ -0,0 +1,70 @@
1
+ // Helpers for constructing docx block payloads.
2
+ //
3
+ // Right now (v1.3.4) only the image path is implemented — enough to cover the
4
+ // create_doc_block / update_doc_block image shortcuts. More block builders
5
+ // (heading / list / code / table) will land in v1.3.5 when the local-markdown
6
+ // → wiki-docx sync feature is built; parking the skeleton here now so the
7
+ // later additions don't introduce a new file.
8
+
9
+ // docx v1 block_type enum (relevant subset).
10
+ // Docs: https://open.feishu.cn/document/server-docs/docs/docs/docx-v1/document-block/create
11
+ const BLOCK_TYPE = {
12
+ PAGE: 1,
13
+ TEXT: 2,
14
+ HEADING1: 3,
15
+ HEADING2: 4,
16
+ HEADING3: 5,
17
+ HEADING4: 6,
18
+ HEADING5: 7,
19
+ HEADING6: 8,
20
+ HEADING7: 9,
21
+ HEADING8: 10,
22
+ HEADING9: 11,
23
+ BULLET: 12,
24
+ ORDERED: 13,
25
+ CODE: 14,
26
+ QUOTE: 15,
27
+ EQUATION: 16,
28
+ TODO: 17,
29
+ BITABLE: 18,
30
+ CALLOUT: 19,
31
+ CHAT_CARD: 20,
32
+ DIAGRAM: 21,
33
+ DIVIDER: 22,
34
+ FILE: 23,
35
+ GRID: 24,
36
+ GRID_COL: 25,
37
+ IFRAME: 26,
38
+ IMAGE: 27,
39
+ TABLE: 31,
40
+ };
41
+
42
+ /**
43
+ * The image-block creation flow on Feishu docx v1 is three steps:
44
+ * 1. POST .../blocks/<parent>/children with an empty image placeholder:
45
+ * { block_type: 27, image: {} }
46
+ * This returns a real block_id for the image slot.
47
+ * 2. POST /open-apis/drive/v1/medias/upload_all with parent_type=docx_image
48
+ * and parent_node=<that block_id> to upload the pixels. Returns file_token.
49
+ * 3. PATCH .../blocks/<block_id> with { replace_image: { token: file_token } }
50
+ * to populate the placeholder with the uploaded image.
51
+ *
52
+ * See official.js::createDocBlockWithImage which orchestrates all three steps.
53
+ */
54
+
55
+ /** Empty image block used as the placeholder in step 1. */
56
+ function buildEmptyImageBlock() {
57
+ return { block_type: BLOCK_TYPE.IMAGE, image: {} };
58
+ }
59
+
60
+ /** Patch body for step 3 — attaches an uploaded image_token to a placeholder block. */
61
+ function buildReplaceImagePayload(imageToken) {
62
+ if (!imageToken) throw new Error('buildReplaceImagePayload: imageToken is required');
63
+ return { replace_image: { token: imageToken } };
64
+ }
65
+
66
+ module.exports = {
67
+ BLOCK_TYPE,
68
+ buildEmptyImageBlock,
69
+ buildReplaceImagePayload,
70
+ };
@@ -0,0 +1,78 @@
1
+ // Feishu error-code classification for read_messages fallback routing.
2
+ //
3
+ // The v1.3.2 read_messages handler catches any bot failure and unconditionally
4
+ // retries with UAT. That's cheap when the bot fails fast, but it has two flaws
5
+ // in v1.3.3:
6
+ // • Transient errors (rate-limit, network stalls) are treated the same as
7
+ // permanent permission errors — the UAT path runs when a 2-second retry
8
+ // would have worked.
9
+ // • When UAT is absent, the raw Feishu payload leaks to the user verbatim,
10
+ // with no hint that OAuth is the fix.
11
+ //
12
+ // This table classifies known codes into three buckets:
13
+ // 'uat' — permanent bot failure; hop straight to UAT.
14
+ // 'retry' — likely transient; caller should retry once (after short delay)
15
+ // and fall through to UAT if still failing.
16
+ // 'unknown' — not seen before; preserve v1.3.3 behaviour (try UAT silently).
17
+
18
+ const FAILURE_MAP = {
19
+ // External tenant — bot lives in a different tenant, will never be granted.
20
+ 240001: { action: 'uat', reason: 'bot_external_tenant' },
21
+ // No permission for the resource (scope missing, or chat restricts bot reads).
22
+ 70009: { action: 'uat', reason: 'bot_no_permission' },
23
+ // Bot is not a member of the chat.
24
+ 70003: { action: 'uat', reason: 'bot_not_in_chat' },
25
+ 99991668: { action: 'uat', reason: 'bot_not_in_chat' },
26
+ // Chat does not exist (from the bot's POV — may still be accessible to user).
27
+ 19001: { action: 'uat', reason: 'bot_chat_not_found' },
28
+
29
+ // Rate limited — Feishu throttles, try once more after a brief pause.
30
+ 42101: { action: 'retry', reason: 'bot_rate_limited' },
31
+ // Frequency control variants occasionally observed.
32
+ 99991400: { action: 'retry', reason: 'bot_rate_limited' },
33
+ };
34
+
35
+ // HTTP-status / network-error patterns that warrant one retry.
36
+ // Axios-wrapped messages from @larksuiteoapi/node-sdk embed the http status
37
+ // into _safeSDKCall's rethrown message. We match those substrings.
38
+ const TRANSIENT_PATTERNS = [
39
+ /HTTP 5\d\d/i, // Any 5xx from upstream
40
+ /ECONNRESET/i,
41
+ /ETIMEDOUT/i,
42
+ /fetch timeout after/i, // from utils.fetchWithTimeout
43
+ /socket hang up/i,
44
+ ];
45
+
46
+ /**
47
+ * Classify an error thrown by a bot-API path.
48
+ * Input is either the Feishu code number (preferred) or the Error object —
49
+ * the code is extracted from the message if present.
50
+ *
51
+ * Output: { action: 'uat' | 'retry' | 'unknown', reason: string, code: number|null }
52
+ */
53
+ function classifyError(errOrCode) {
54
+ let code = null;
55
+ let msg = '';
56
+ if (typeof errOrCode === 'number') {
57
+ code = errOrCode;
58
+ } else if (errOrCode && typeof errOrCode === 'object') {
59
+ msg = errOrCode.message || String(errOrCode);
60
+ // _safeSDKCall formats as "label failed (HTTP N, code=XXX): ..." or "label failed (CODE): ..."
61
+ const m = msg.match(/code[=(]\s*(\d+)/i) || msg.match(/failed\s*\((\d+)\)/i);
62
+ if (m) code = parseInt(m[1], 10);
63
+ }
64
+
65
+ if (code != null && FAILURE_MAP[code]) {
66
+ return { ...FAILURE_MAP[code], code };
67
+ }
68
+ for (const re of TRANSIENT_PATTERNS) {
69
+ if (re.test(msg)) return { action: 'retry', reason: 'bot_network_error', code };
70
+ }
71
+ return { action: 'unknown', reason: 'bot_unknown_error', code };
72
+ }
73
+
74
+ module.exports = {
75
+ classifyError,
76
+ FAILURE_MAP,
77
+ TRANSIENT_PATTERNS,
78
+ };