feishu-user-plugin 1.3.5 → 1.3.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "feishu-user-plugin",
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.",
3
+ "version": "1.3.6",
4
+ "description": "All-in-one Feishu plugin for Claude Code — send messages as yourself (incl. batch_send and real @-mentions), read chats (auto-expanded merge_forward), manage docs (image + file blocks) / bitable (with attachment upload) / wiki / drive (file upload + wiki attach) / OKR / calendar / multi-profile. 81 tools + 9 skills, 3 auth layers. v1.3.6: upload completeness, sheets:spreadsheet scope, batch_send, multi-profile, send_card_as_user (bot-default).",
5
5
  "author": {
6
6
  "name": "EthanQC"
7
7
  },
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-74-orange.svg)](#tools)
6
+ [![Tools](https://img.shields.io/badge/Tools-81-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 -- 75 tools, 9 skills, 3 auth layers for messaging, docs, bitable, calendar, tasks, drive, OKR, and more.**
9
+ **All-in-one Feishu/Lark MCP Server -- 81 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,9 +337,9 @@ Add to `~/.codeium/windsurf/mcp_config.json`:
337
337
  }
338
338
  ```
339
339
 
340
- ## Tools (75 total)
340
+ ## Tools (81 total)
341
341
 
342
- ### User Identity -- Messaging (8 tools, cookie auth)
342
+ ### User Identity -- Messaging (10 tools, cookie auth)
343
343
 
344
344
  | Tool | Description |
345
345
  |------|-------------|
@@ -351,6 +351,8 @@ Add to `~/.codeium/windsurf/mcp_config.json`:
351
351
  | `send_post_as_user` | Send rich text with title + formatted paragraphs |
352
352
  | `send_sticker_as_user` | Send sticker/emoji |
353
353
  | `send_audio_as_user` | Send audio message |
354
+ | `batch_send` | Fan-out send to multiple targets in one call (text / image / file / post). v1.3.6 |
355
+ | `send_card_as_user` | Send a Feishu interactive card. v1.3.6 default routes through bot identity; user-identity is reserved for v1.3.7. |
354
356
 
355
357
  ### User Identity -- Contacts & Info (5 tools, cookie auth)
356
358
 
@@ -415,11 +417,11 @@ All docx / bitable tools' `document_id` / `app_token` parameter also accepts a W
415
417
  | `read_doc` | Read raw text content |
416
418
  | `get_doc_blocks` | Get structured block tree |
417
419
  | `create_doc` | Create a new document |
418
- | `create_doc_block` | Insert content blocks into a document |
419
- | `update_doc_block` | Update a specific block |
420
+ | `create_doc_block` | Insert content blocks: generic `children`, `image_path` / `image_token` (image block), `file_path` / `file_token` (file attachment block, v1.3.6) |
421
+ | `update_doc_block` | Update a specific block: generic `update_body`, `image_token`, or `file_token` (v1.3.6) |
420
422
  | `delete_doc_blocks` | Delete a range of blocks |
421
423
 
422
- ### Official API -- Bitable (17 tools)
424
+ ### Official API -- Bitable (18 tools)
423
425
 
424
426
  | Tool | Description |
425
427
  |------|-------------|
@@ -430,6 +432,7 @@ All docx / bitable tools' `document_id` / `app_token` parameter also accepts a W
430
432
  | `search_bitable_records` / `get_bitable_record` | Query records |
431
433
  | `create_bitable_record` / `update_bitable_record` / `delete_bitable_record` | Single record CRUD |
432
434
  | `batch_create_bitable_records` / `batch_update_bitable_records` / `batch_delete_bitable_records` | Batch operations (max 500/call) |
435
+ | `upload_bitable_attachment` | Upload a file into a Bitable Attachment-type field. Returns `file_token` to write into the field as `[{file_token}]`. v1.3.6 |
433
436
 
434
437
  ### Official API -- Calendar (5 tools)
435
438
 
@@ -451,7 +454,7 @@ All docx / bitable tools' `document_id` / `app_token` parameter also accepts a W
451
454
  | `update_task` | Update a task |
452
455
  | `complete_task` | Mark task as complete |
453
456
 
454
- ### Official API -- Drive (5 tools)
457
+ ### Official API -- Drive (6 tools)
455
458
 
456
459
  | Tool | Description |
457
460
  |------|-------------|
@@ -460,6 +463,7 @@ All docx / bitable tools' `document_id` / `app_token` parameter also accepts a W
460
463
  | `copy_file` | Copy a file |
461
464
  | `move_file` | Move a file |
462
465
  | `delete_file` | Delete a file/folder |
466
+ | `upload_drive_file` | Upload a local file into a Drive folder (`drive/v1/files/upload_all`). Optional `wiki_space_id` attaches the upload as a Wiki node atomically. v1.3.6 |
463
467
 
464
468
  ### Official API -- Wiki & Contacts (4 tools)
465
469
 
@@ -468,6 +472,13 @@ All docx / bitable tools' `document_id` / `app_token` parameter also accepts a W
468
472
  | `list_wiki_spaces` / `search_wiki` / `list_wiki_nodes` | Wiki spaces, search, browse |
469
473
  | `find_user` | Find user by email or mobile number |
470
474
 
475
+ ### Plugin -- Profiles (2 tools, v1.3.6)
476
+
477
+ | Tool | Description |
478
+ |------|-------------|
479
+ | `list_profiles` | List available identity profiles (default + extras from `LARK_PROFILES_JSON`) and the active one |
480
+ | `switch_profile` | Hot-swap active profile; cached client instances rebuild against new credentials |
481
+
471
482
  ## Claude Code Slash Commands (9 skills)
472
483
 
473
484
  This plugin includes 9 built-in skills in `skills/feishu-user-plugin/`:
@@ -526,7 +537,7 @@ feishu-user-plugin/
526
537
  │ ├── SKILL.md # Main skill definition (trigger, tools, auth)
527
538
  │ └── references/ # 8 skill reference docs + CLAUDE.md
528
539
  ├── src/
529
- │ ├── index.js # MCP server entry point (75 tools)
540
+ │ ├── index.js # MCP server entry point (81 tools)
530
541
  │ ├── client.js # User identity client (Protobuf gateway)
531
542
  │ ├── official.js # Official API client (REST, UAT)
532
543
  │ ├── 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.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.",
3
+ "version": "1.3.6",
4
+ "description": "All-in-one Feishu plugin for Claude Code & Codex — messaging (with merge_forward expansion + batch_send), docs (image + file blocks read/write), bitable (with attachment upload), wiki, drive (file upload + wiki attach), OKR, calendar, multi-profile. 81 tools + 9 skills, 3 auth layers. v1.3.6: upload completeness, sheets:spreadsheet scope, batch_send, multi-profile, send_card_as_user (bot-default).",
5
5
  "main": "src/index.js",
6
6
  "bin": {
7
7
  "feishu-user-plugin": "src/cli.js"
@@ -1,8 +1,8 @@
1
1
  ---
2
2
  name: feishu-user-plugin
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
3
+ version: "1.3.6"
4
+ description: "All-in-one Feishu plugin — send messages as yourself (incl. batch_send), read group/P2P chats (auto-expands merge_forward), manage docs/tables/wiki/drive (image + file blocks, drive uploads, bitable attachments), OKR, calendar, multi-profile. v1.3.6: upload completeness, sheets:spreadsheet scope, batch_send, multi-profile, send_card_as_user (bot-default)."
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, batch_send, send_card_as_user, search_contacts, create_p2p_chat, get_chat_info, get_user_info, get_login_status, list_profiles, switch_profile, read_p2p_messages, list_user_chats, list_chats, read_messages, reply_message, forward_message, search_docs, read_doc, create_doc, create_doc_block, update_doc_block, list_bitable_tables, list_bitable_fields, search_bitable_records, batch_create_bitable_records, batch_update_bitable_records, upload_bitable_attachment, list_wiki_spaces, search_wiki, list_wiki_nodes, get_wiki_node, list_files, create_folder, upload_drive_file, 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,11 +6,12 @@ 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 (75 tools)
9
+ ## Tool Categories (81 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.
13
13
  - `send_to_group` — Search group + send text (one step). Returns candidates if multiple matches.
14
+ - `batch_send` — Fan-out send to multiple targets in one call (text/image/file/post). Each target {type, id, content, via?} dispatches sequentially with throttling, returns per-target ok/error.
14
15
  - `send_as_user` — Send text to any chat by ID, supports reply threading (root_id/parent_id)
15
16
  - `send_image_as_user` — Send image (requires image_key from `upload_image`)
16
17
  - `send_file_as_user` — Send file (requires file_key from `upload_file`)
@@ -52,6 +53,9 @@ All-in-one Feishu plugin for Claude Code with three auth layers:
52
53
  - `list_files` / `create_folder` — Drive
53
54
  - `copy_file` / `move_file` / `delete_file` — Drive file operations (copy, move, delete)
54
55
  - `upload_image` / `upload_file` — Upload image/file, returns key for send_image/send_file
56
+ - `upload_drive_file` — Upload a local file into a Drive folder (`drive/v1/files/upload_all`, `parent_type=explorer`). Returns `file_token` + `url`. If `wiki_space_id` is provided, the upload is followed by `attachToWiki(obj_type=file)` so the file lands as a Wiki node atomically. UAT-first with bot fallback.
57
+ - `upload_bitable_attachment` — Upload a local file as a Bitable attachment (`drive/v1/medias/upload_all` with `parent_type=bitable_image` or `bitable_file`). Returns `file_token` to write into an Attachment-type field via `batch_create/update_bitable_records` as `[{file_token: "..."}]`.
58
+ - `send_card_as_user` — Send a Feishu interactive card. **v1.3.6 default routes through bot identity** (the `as_user` suffix is reserved for the v1.3.7 reverse-engineered cookie path; default flips when that lands). Pass `card` JSON; `via="user"` returns an explicit deferred error in v1.3.6.
55
59
  - `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
60
  - `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
61
  - `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.
@@ -126,10 +130,23 @@ Write — `create_doc_block` now has image shortcuts:
126
130
  - Create doc with content → `create_doc` → `create_doc_block` (use document_id as parent_block_id for root)
127
131
  - Edit existing block → `get_doc_blocks` to find block_id → `update_doc_block`
128
132
  - Delete blocks → `delete_doc_blocks` with start/end index range
133
+ - Insert image → `create_doc_block` with `image_path` (local file) or `image_token` (already uploaded). Three-step flow handled internally.
134
+ - Insert file attachment (PDF/zip/xlsx/...) → `create_doc_block` with `file_path` or `file_token`. Feishu auto-wraps the FILE block (block_type=23) inside a VIEW container (block_type=33); the plugin walks into the inner file block automatically before the `replace_file` PATCH so the upload + attach succeed.
135
+ - Replace existing image/file in a block → `update_doc_block` with `image_token` / `file_token`.
129
136
 
130
137
  ### Diagnostics
131
138
  - Diagnose issues → `get_login_status` first
132
139
 
140
+ ### Profiles (v1.3.6)
141
+ Multi-account / multi-tenant support without restarting the MCP server:
142
+ - `list_profiles` — see all profiles + the active one. Default profile uses top-level env vars; extras come from `LARK_PROFILES_JSON`.
143
+ - `switch_profile(name)` — hot-swap credentials. Cached client instances are invalidated so the next call rebuilds against the new profile.
144
+
145
+ To register more profiles, set `LARK_PROFILES_JSON` in the MCP env:
146
+ ```json
147
+ {"alt": {"LARK_COOKIE":"...","LARK_APP_ID":"...","LARK_APP_SECRET":"...","LARK_USER_ACCESS_TOKEN":"...","LARK_USER_REFRESH_TOKEN":"..."}}
148
+ ```
149
+
133
150
  ## Auth & Session
134
151
  - **LARK_COOKIE**: Required for user identity tools. Session auto-refreshed every 4h via heartbeat and persisted to config.
135
152
  - **LARK_APP_ID + LARK_APP_SECRET**: Required for official API tools.
@@ -541,10 +558,10 @@ The v1.3.4 tools require additional scopes on the app + UAT:
541
558
  |---------|-------------------------------------------|
542
559
  | OKR read | `okr:okr:readonly`, `okr:period:read` |
543
560
  | 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) |
561
+ | Docx/Bitable/Drive media upload (`uploadMedia`, `upload_drive_file`, `upload_bitable_attachment`, `create_doc_block` image/file) | `drive:drive`, `drive:file:upload`, `docs:document.media:upload`, `sheets:spreadsheet` (only for sheet uploads) |
545
562
  | Wiki attach (`move_docs_to_wiki`) | `wiki:wiki` (edit scope, the readonly one is insufficient) |
546
563
 
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.
564
+ If a tool returns `access_denied` or error code `99991672` (scope not granted), the scope is missing on either the app or the UAT. Re-run `npx feishu-user-plugin oauth` so the UAT picks up the latest scope list (defined in `src/oauth.js`).
548
565
 
549
566
  ## Known Limitations
550
567
  - CARD message type (type=14) not yet implemented — complex JSON schema
package/src/doc-blocks.js CHANGED
@@ -1,10 +1,8 @@
1
1
  // Helpers for constructing docx block payloads.
2
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.
3
+ // v1.3.4 added image block builders. v1.3.6 adds file block builders so the
4
+ // create_doc_block / update_doc_block tools can attach arbitrary file
5
+ // attachments (PDF / zip / etc.) the same way they handle images.
8
6
 
9
7
  // docx v1 block_type enum (relevant subset).
10
8
  // Docs: https://open.feishu.cn/document/server-docs/docs/docs/docx-v1/document-block/create
@@ -63,8 +61,25 @@ function buildReplaceImagePayload(imageToken) {
63
61
  return { replace_image: { token: imageToken } };
64
62
  }
65
63
 
64
+ // File-block flow mirrors the image-block flow but uses block_type=23 (FILE)
65
+ // and parent_type=docx_file when uploading the binary, and replace_file in
66
+ // the PATCH body. See official.js::createDocBlockWithFile.
67
+
68
+ /** Empty file block placeholder for step 1 of file attachment flow. */
69
+ function buildEmptyFileBlock() {
70
+ return { block_type: BLOCK_TYPE.FILE, file: {} };
71
+ }
72
+
73
+ /** Patch body that swaps an empty file block's content with an uploaded file token. */
74
+ function buildReplaceFilePayload(fileToken) {
75
+ if (!fileToken) throw new Error('buildReplaceFilePayload: fileToken is required');
76
+ return { replace_file: { token: fileToken } };
77
+ }
78
+
66
79
  module.exports = {
67
80
  BLOCK_TYPE,
68
81
  buildEmptyImageBlock,
69
82
  buildReplaceImagePayload,
83
+ buildEmptyFileBlock,
84
+ buildReplaceFilePayload,
70
85
  };
package/src/index.js CHANGED
@@ -113,17 +113,53 @@ class ChatIdMapper {
113
113
  }
114
114
  }
115
115
 
116
- // --- Client Singletons ---
116
+ // --- Client Singletons + Profiles ---
117
117
 
118
118
  let userClient = null;
119
119
  let officialClient = null;
120
120
  const chatIdMapper = new ChatIdMapper();
121
121
 
122
+ // Profile system (v1.3.6).
123
+ // Default behaviour is identical to pre-1.3.6: LARK_COOKIE / LARK_APP_ID / etc.
124
+ // from process.env act as profile "default". To register more profiles, set
125
+ // LARK_PROFILES_JSON in the MCP env to a JSON object:
126
+ // { "alt": { "LARK_COOKIE": "...", "LARK_APP_ID": "...", ... }, ... }
127
+ // Then call switch_profile to change which credential set is active.
128
+ let currentProfile = 'default';
129
+
130
+ function loadProfileMap() {
131
+ const raw = process.env.LARK_PROFILES_JSON;
132
+ if (!raw) return {};
133
+ try {
134
+ const parsed = JSON.parse(raw);
135
+ if (parsed && typeof parsed === 'object') return parsed;
136
+ } catch (e) {
137
+ console.error(`[feishu-user-plugin] LARK_PROFILES_JSON parse failed: ${e.message}`);
138
+ }
139
+ return {};
140
+ }
141
+
142
+ function profileEnv(name) {
143
+ if (name === 'default') {
144
+ return {
145
+ LARK_COOKIE: process.env.LARK_COOKIE,
146
+ LARK_APP_ID: process.env.LARK_APP_ID,
147
+ LARK_APP_SECRET: process.env.LARK_APP_SECRET,
148
+ LARK_USER_ACCESS_TOKEN: process.env.LARK_USER_ACCESS_TOKEN,
149
+ LARK_USER_REFRESH_TOKEN: process.env.LARK_USER_REFRESH_TOKEN,
150
+ };
151
+ }
152
+ const profiles = loadProfileMap();
153
+ if (!profiles[name]) throw new Error(`Profile "${name}" not found. Available: ${['default', ...Object.keys(profiles)].join(', ')}`);
154
+ return profiles[name];
155
+ }
156
+
122
157
  async function getUserClient() {
123
158
  if (userClient) return userClient;
124
- const cookie = process.env.LARK_COOKIE;
159
+ const env = profileEnv(currentProfile);
160
+ const cookie = env.LARK_COOKIE;
125
161
  if (!cookie) throw new Error(
126
- 'LARK_COOKIE not set. To fix:\n' +
162
+ `LARK_COOKIE not set for profile "${currentProfile}". To fix:\n` +
127
163
  '1. Open https://www.feishu.cn/messenger/ and log in\n' +
128
164
  '2. DevTools → Network tab → Disable cache → Reload → Click first request → Request Headers → Cookie → Copy value\n' +
129
165
  ' (Do NOT use document.cookie or Application→Cookies — they miss HttpOnly cookies like session/sl_session)\n' +
@@ -137,21 +173,52 @@ async function getUserClient() {
137
173
 
138
174
  function getOfficialClient() {
139
175
  if (officialClient) return officialClient;
140
- const appId = process.env.LARK_APP_ID;
141
- const appSecret = process.env.LARK_APP_SECRET;
176
+ const env = profileEnv(currentProfile);
177
+ const appId = env.LARK_APP_ID;
178
+ const appSecret = env.LARK_APP_SECRET;
142
179
  if (!appId || !appSecret) throw new Error(
143
- 'LARK_APP_ID and LARK_APP_SECRET not set.\n' +
180
+ `LARK_APP_ID and LARK_APP_SECRET not set for profile "${currentProfile}".\n` +
144
181
  'For team members: these should be pre-filled in your .mcp.json. Check that the config was copied correctly from the team-skills README.\n' +
145
182
  'For external users: create a Custom App at https://open.feishu.cn/app, get the App ID and App Secret, add them to your .mcp.json env.'
146
183
  );
184
+ // Honor profile-specific UAT env if present (LarkOfficialClient.loadUAT uses
185
+ // process.env directly; we patch the env temporarily for non-default profiles)
186
+ const prevUAT = process.env.LARK_USER_ACCESS_TOKEN;
187
+ const prevRT = process.env.LARK_USER_REFRESH_TOKEN;
188
+ if (currentProfile !== 'default') {
189
+ if (env.LARK_USER_ACCESS_TOKEN) process.env.LARK_USER_ACCESS_TOKEN = env.LARK_USER_ACCESS_TOKEN;
190
+ if (env.LARK_USER_REFRESH_TOKEN) process.env.LARK_USER_REFRESH_TOKEN = env.LARK_USER_REFRESH_TOKEN;
191
+ }
147
192
  officialClient = new LarkOfficialClient(appId, appSecret);
148
193
  officialClient.loadUAT();
194
+ if (currentProfile !== 'default') {
195
+ process.env.LARK_USER_ACCESS_TOKEN = prevUAT;
196
+ process.env.LARK_USER_REFRESH_TOKEN = prevRT;
197
+ }
149
198
  return officialClient;
150
199
  }
151
200
 
152
201
  // --- Tool Definitions ---
153
202
 
154
203
  const TOOLS = [
204
+ // ========== Profile management (v1.3.6) ==========
205
+ {
206
+ name: 'list_profiles',
207
+ description: '[Plugin] List all available identity profiles (sets of LARK_COOKIE/APP_ID/APP_SECRET/UAT). The "default" profile uses the top-level env vars; additional profiles come from LARK_PROFILES_JSON. Marks the currently active profile.',
208
+ inputSchema: { type: 'object', properties: {} },
209
+ },
210
+ {
211
+ name: 'switch_profile',
212
+ description: '[Plugin] Switch the active identity profile. Subsequent tool calls use the new profile\'s credentials. Cached client instances are reset so the next call rebuilds against the new creds.',
213
+ inputSchema: {
214
+ type: 'object',
215
+ properties: {
216
+ name: { type: 'string', description: 'Profile name. "default" for top-level env vars; any key from LARK_PROFILES_JSON otherwise.' },
217
+ },
218
+ required: ['name'],
219
+ },
220
+ },
221
+
155
222
  // ========== User Identity — Send Messages ==========
156
223
  {
157
224
  name: 'send_as_user',
@@ -206,6 +273,22 @@ const TOOLS = [
206
273
  required: ['group_name', 'text'],
207
274
  },
208
275
  },
276
+ {
277
+ name: 'batch_send',
278
+ description: '[User Identity / Official API] Send the same or different content to multiple targets in one call. Each target dispatches sequentially with a small delay (anti-rate-limit) and reports per-target success/error. Identity is the cookie user (user-identity sends) unless target.via=bot. Use for broadcast / fan-out scenarios.',
279
+ inputSchema: {
280
+ type: 'object',
281
+ properties: {
282
+ targets: {
283
+ type: 'array',
284
+ description: 'Array of targets. Each entry: { type: "user"|"group"|"chat", id: <user_name | group_name | chat_id>, content: { kind: "text"|"image"|"file"|"post", ... } }. For kind="text": { text }. For "image": { image_key }. For "file": { file_key, file_name }. For "post": { title, paragraphs }. Optional per-target: via="bot" routes through send_message_as_bot (chat_id required).',
285
+ items: { type: 'object' },
286
+ },
287
+ delay_ms: { type: 'number', description: 'Delay between sends in milliseconds (default 200, increase for risky volumes).' },
288
+ },
289
+ required: ['targets'],
290
+ },
291
+ },
209
292
  {
210
293
  name: 'send_image_as_user',
211
294
  description: '[User Identity] Send an image as the logged-in user. Requires image_key (upload via Official API first).',
@@ -689,6 +772,33 @@ const TOOLS = [
689
772
  required: ['file_path'],
690
773
  },
691
774
  },
775
+ {
776
+ name: 'upload_drive_file',
777
+ description: '[Official API] Upload a file from disk to a Feishu Drive folder (drive/v1/files/upload_all, parent_type=explorer). Returns file_token + url. If wiki_space_id is provided, the uploaded file is then attached to that Wiki space via move_docs_to_wiki (obj_type=file). UAT-first with app fallback.',
778
+ inputSchema: {
779
+ type: 'object',
780
+ properties: {
781
+ file_path: { type: 'string', description: 'Absolute path to the file on disk' },
782
+ folder_token: { type: 'string', description: 'Destination folder token. Use list_files to find one, or pass the user "我的空间" root token.' },
783
+ wiki_space_id: { type: 'string', description: 'Optional. If set, also attach the uploaded file to this Wiki space.' },
784
+ wiki_parent_node_token: { type: 'string', description: 'Optional. Parent node under which to attach in the Wiki space.' },
785
+ },
786
+ required: ['file_path', 'folder_token'],
787
+ },
788
+ },
789
+ {
790
+ name: 'upload_bitable_attachment',
791
+ description: '[Official API] Upload a file as a Bitable attachment (drive/v1/medias/upload_all with parent_type=bitable_image or bitable_file). Returns file_token suitable for writing into a Bitable Attachment-type field via batch_create/update_bitable_records (the field value should be [{file_token}]).',
792
+ inputSchema: {
793
+ type: 'object',
794
+ properties: {
795
+ app_token: { type: 'string', description: 'Bitable app token (the bascn... or basc... id)' },
796
+ file_path: { type: 'string', description: 'Absolute path to the file on disk' },
797
+ kind: { type: 'string', enum: ['image', 'file'], description: 'Whether the attachment is an image (bitable_image) or a generic file (bitable_file). Default: file.' },
798
+ },
799
+ required: ['app_token', 'file_path'],
800
+ },
801
+ },
692
802
 
693
803
  // ========== Contact — Official API ==========
694
804
  {
@@ -717,6 +827,19 @@ const TOOLS = [
717
827
  required: ['chat_id', 'msg_type', 'content'],
718
828
  },
719
829
  },
830
+ {
831
+ name: 'send_card_as_user',
832
+ description: '[v1.3.6: bot-routed default] Send an interactive card to a chat. **As of v1.3.6, identity defaults to BOT** because user-identity card sending requires reverse-engineering the Feishu web protobuf and is deferred to v1.3.7. The tool name keeps the "as_user" suffix so callers don\'t have to migrate when v1.3.7 lands; once user-identity is implemented the default flips. Pass `card` as a JSON object (Feishu card schema). To force bot explicitly set via="bot".',
833
+ inputSchema: {
834
+ type: 'object',
835
+ properties: {
836
+ chat_id: { type: 'string', description: 'Target chat_id (oc_xxx) or open_id' },
837
+ card: { description: 'Feishu card JSON. See https://open.feishu.cn/cardkit for the schema; build cards visually then paste the resulting JSON here.' },
838
+ via: { type: 'string', enum: ['bot', 'user'], description: 'Identity to send as. Default "bot". "user" returns an explicit not-yet-implemented error in v1.3.6.' },
839
+ },
840
+ required: ['chat_id', 'card'],
841
+ },
842
+ },
720
843
  {
721
844
  name: 'delete_message',
722
845
  description: '[Official API] Recall/delete a message (bot can only delete its own messages).',
@@ -837,15 +960,17 @@ const TOOLS = [
837
960
  // ========== Docs — Block Editing ==========
838
961
  {
839
962
  name: 'create_doc_block',
840
- description: '[Official API] Insert content blocks into a document. Three modes:\n (A) Generic — pass `children` array (e.g. [{block_type:2, text:{...}}]) for text/heading/list/etc.\n (B) Image from local file — pass `image_path` (absolute path); the plugin creates an image block, uploads the file to drive, and patches the block with the token. Returns block_id + image_token.\n (C) Image from uploaded token — pass `image_token` (from a previous uploadDocMedia or docx image block) to reuse an already-uploaded image.\n`document_id` accepts native document_id, wiki node token, or Feishu URL.',
963
+ description: '[Official API] Insert content blocks into a document. Five modes:\n (A) Generic — pass `children` array (e.g. [{block_type:2, text:{...}}]) for text/heading/list/etc.\n (B) Image from local file — pass `image_path` (absolute path); the plugin creates an image block, uploads the file to drive, and patches the block with the token. Returns block_id + image_token.\n (C) Image from uploaded token — pass `image_token` to reuse an already-uploaded image.\n (D) File attachment from local file pass `file_path`; the plugin creates a file block (block_type=23), uploads via parent_type=docx_file, and patches with replace_file.\n (E) File from uploaded token — pass `file_token` to reuse an already-uploaded file.\n`document_id` accepts native document_id, wiki node token, or Feishu URL.',
841
964
  inputSchema: {
842
965
  type: 'object',
843
966
  properties: {
844
967
  document_id: { type: 'string', description: 'Document ID, wiki node token, or Feishu URL' },
845
968
  parent_block_id: { type: 'string', description: 'Parent block ID (use document_id for root)' },
846
969
  children: { type: 'array', description: 'Generic block objects — mode A. E.g. [{block_type:2, text:{elements:[{text_run:{content:"Hello"}}]}}]', items: { type: 'object' } },
847
- image_path: { type: 'string', description: 'Local image path — mode B (mutually exclusive with children / image_token)' },
848
- image_token: { type: 'string', description: 'Pre-uploaded docx image token — mode C (mutually exclusive with children / image_path)' },
970
+ image_path: { type: 'string', description: 'Local image path — mode B (mutually exclusive with other modes)' },
971
+ image_token: { type: 'string', description: 'Pre-uploaded docx image token — mode C (mutually exclusive with other modes)' },
972
+ file_path: { type: 'string', description: 'Local file path for an attachment block — mode D (mutually exclusive with other modes)' },
973
+ file_token: { type: 'string', description: 'Pre-uploaded docx file token — mode E (mutually exclusive with other modes)' },
849
974
  index: { type: 'number', description: 'Insert position (optional, appends to end if omitted)' },
850
975
  },
851
976
  required: ['document_id', 'parent_block_id'],
@@ -853,7 +978,7 @@ const TOOLS = [
853
978
  },
854
979
  {
855
980
  name: 'update_doc_block',
856
- description: '[Official API] Update a specific block in a document. Generic mode: pass update_body. Image-replace mode: pass image_token to swap the picture in an existing image block. document_id accepts native ID, wiki node token, or Feishu URL.',
981
+ description: '[Official API] Update a specific block in a document. Generic mode: pass update_body. Image-replace mode: pass image_token to swap the picture in an existing image block. File-replace mode: pass file_token to swap an existing file block. document_id accepts native ID, wiki node token, or Feishu URL.',
857
982
  inputSchema: {
858
983
  type: 'object',
859
984
  properties: {
@@ -861,6 +986,7 @@ const TOOLS = [
861
986
  block_id: { type: 'string', description: 'Block ID to update' },
862
987
  update_body: { type: 'object', description: 'Generic update payload. E.g. {update_text_elements:{elements:[{text_run:{content:"new text"}}]}}' },
863
988
  image_token: { type: 'string', description: 'Pre-uploaded image token — if provided, update_body is ignored and the block is patched with {replace_image:{token}}' },
989
+ file_token: { type: 'string', description: 'Pre-uploaded file token — patches the block with {replace_file:{token}}' },
864
990
  },
865
991
  required: ['document_id', 'block_id'],
866
992
  },
@@ -1177,6 +1303,25 @@ async function resolveDocId(input) {
1177
1303
  async function handleTool(name, args) {
1178
1304
 
1179
1305
  switch (name) {
1306
+ // --- Profile management (v1.3.6) ---
1307
+
1308
+ case 'list_profiles': {
1309
+ const profiles = loadProfileMap();
1310
+ const all = ['default', ...Object.keys(profiles)];
1311
+ return json({ active: currentProfile, profiles: all });
1312
+ }
1313
+ case 'switch_profile': {
1314
+ const target = args.name;
1315
+ const profiles = loadProfileMap();
1316
+ const all = ['default', ...Object.keys(profiles)];
1317
+ if (!all.includes(target)) return text(`Profile "${target}" not found. Available: ${all.join(', ')}. To add more, set LARK_PROFILES_JSON in your MCP env.`);
1318
+ currentProfile = target;
1319
+ // Invalidate cached client instances so the next call uses the new creds
1320
+ userClient = null;
1321
+ officialClient = null;
1322
+ return text(`Switched to profile: ${target}`);
1323
+ }
1324
+
1180
1325
  // --- User Identity: Text Messaging ---
1181
1326
 
1182
1327
  case 'send_as_user': {
@@ -1212,6 +1357,56 @@ async function handleTool(name, args) {
1212
1357
  const r = await c.sendMessage(group.id, args.text, { ats: args.ats });
1213
1358
  return sendResult(r, `Text sent to group "${group.title}" (${group.id})`);
1214
1359
  }
1360
+ case 'batch_send': {
1361
+ if (!Array.isArray(args.targets) || args.targets.length === 0) return text('batch_send: targets must be a non-empty array');
1362
+ const delay = typeof args.delay_ms === 'number' ? args.delay_ms : 200;
1363
+ const userClient = await getUserClient();
1364
+ const officialClient = getOfficialClient();
1365
+ const results = [];
1366
+ for (let i = 0; i < args.targets.length; i++) {
1367
+ const t = args.targets[i];
1368
+ try {
1369
+ if (!t.content || !t.content.kind) throw new Error('content.kind is required');
1370
+ // Resolve chat id from name when applicable
1371
+ let chatId = t.id;
1372
+ if (t.type === 'user' || t.type === 'group') {
1373
+ const matches = await userClient.search(t.id);
1374
+ const want = matches.filter(m => m.type === t.type);
1375
+ if (want.length === 0) throw new Error(`No ${t.type} matches "${t.id}"`);
1376
+ if (want.length > 1) throw new Error(`Ambiguous ${t.type} "${t.id}" (${want.length} matches). Use type="chat" with explicit chat_id.`);
1377
+ const picked = want[0];
1378
+ chatId = t.type === 'user' ? await userClient.createChat(picked.id) : picked.id;
1379
+ if (!chatId) throw new Error(`Could not resolve chat for ${t.type} ${picked.title}`);
1380
+ }
1381
+ let r;
1382
+ if (t.via === 'bot') {
1383
+ const c = t.content;
1384
+ const payload = c.kind === 'text' ? { text: c.text }
1385
+ : c.kind === 'post' ? { post: { zh_cn: { title: c.title || '', content: c.paragraphs || [] } } }
1386
+ : c.kind === 'image' ? { image_key: c.image_key }
1387
+ : c.kind === 'interactive' ? c.card
1388
+ : null;
1389
+ if (!payload) throw new Error(`bot path does not support content.kind=${c.kind}`);
1390
+ const msgType = c.kind === 'interactive' ? 'interactive' : c.kind;
1391
+ r = await officialClient.sendMessageAsBot(chatId, msgType, payload);
1392
+ results.push({ ok: true, target: t, messageId: r.messageId, via: 'bot' });
1393
+ } else {
1394
+ const c = t.content;
1395
+ if (c.kind === 'text') r = await userClient.sendMessage(chatId, c.text, { ats: c.ats });
1396
+ else if (c.kind === 'image') r = await userClient.sendImage(chatId, c.image_key);
1397
+ else if (c.kind === 'file') r = await userClient.sendFile(chatId, c.file_key, c.file_name);
1398
+ else if (c.kind === 'post') r = await userClient.sendPost(chatId, c.title, c.paragraphs);
1399
+ else throw new Error(`unknown content.kind=${c.kind}`);
1400
+ results.push({ ok: true, target: t, messageId: r.messageId, via: 'user' });
1401
+ }
1402
+ } catch (e) {
1403
+ results.push({ ok: false, target: t, error: e.message });
1404
+ }
1405
+ if (i < args.targets.length - 1 && delay > 0) await new Promise(r => setTimeout(r, delay));
1406
+ }
1407
+ const okCount = results.filter(r => r.ok).length;
1408
+ return json({ summary: `${okCount}/${results.length} sent`, results });
1409
+ }
1215
1410
 
1216
1411
  // --- User Identity: Rich Message Types ---
1217
1412
 
@@ -1510,9 +1705,38 @@ async function handleTool(name, args) {
1510
1705
  const r = await getOfficialClient().uploadFile(args.file_path, args.file_type, args.file_name);
1511
1706
  return text(`File uploaded: ${r.fileKey}\nUse this file_key with send_file_as_user to send it.`);
1512
1707
  }
1708
+ case 'upload_drive_file': {
1709
+ const official = getOfficialClient();
1710
+ const up = await official.uploadDriveFile(args.file_path, args.folder_token);
1711
+ const out = { fileToken: up.fileToken, viaUser: up.viaUser, url: `https://feishu.cn/file/${up.fileToken}` };
1712
+ if (args.wiki_space_id) {
1713
+ try {
1714
+ const node = await official.attachToWiki(args.wiki_space_id, 'file', up.fileToken, args.wiki_parent_node_token);
1715
+ out.wikiNodeToken = node.node_token || null;
1716
+ out.wikiAttachTaskId = node.task_id || null;
1717
+ } catch (e) {
1718
+ out.wikiAttachError = e.message;
1719
+ }
1720
+ }
1721
+ return json(out);
1722
+ }
1723
+ case 'upload_bitable_attachment': {
1724
+ const kind = args.kind === 'image' ? 'bitable_image' : 'bitable_file';
1725
+ const appToken = await resolveDocId(args.app_token);
1726
+ const up = await getOfficialClient().uploadMedia(args.file_path, appToken, kind);
1727
+ return json({ fileToken: up.fileToken, viaUser: up.viaUser, parentType: kind, hint: `Pass [{ file_token: "${up.fileToken}" }] as the value of an Attachment-type Bitable field.` });
1728
+ }
1513
1729
 
1514
1730
  // --- Official API: Bot Send / Edit / Delete ---
1515
1731
 
1732
+ case 'send_card_as_user': {
1733
+ const via = args.via || 'bot';
1734
+ if (via === 'user') {
1735
+ return text('send_card_as_user via="user" is not implemented in v1.3.6 — user-identity card sending requires reverse-engineering the Feishu web protobuf and is scheduled for v1.3.7. Use via="bot" (default) for now.');
1736
+ }
1737
+ const r = await getOfficialClient().sendMessageAsBot(args.chat_id, 'interactive', args.card);
1738
+ return text(`Card sent (${via}): ${r.messageId}`);
1739
+ }
1516
1740
  case 'send_message_as_bot': {
1517
1741
  const r = await getOfficialClient().sendMessageAsBot(args.chat_id, args.msg_type, args.content);
1518
1742
  return text(`Message sent (bot): ${r.messageId}`);
@@ -1555,10 +1779,9 @@ async function handleTool(name, args) {
1555
1779
  case 'create_doc_block': {
1556
1780
  const official = getOfficialClient();
1557
1781
  const docId = await resolveDocId(args.document_id);
1558
- // Image shortcut: if image_path or image_token is provided, orchestrate the
1559
- // 3-step docx image creation. Mutually exclusive with children.
1782
+ const modes = [args.children, args.image_path, args.image_token, args.file_path, args.file_token].filter(Boolean);
1783
+ if (modes.length > 1) return text('create_doc_block: pass exactly ONE of children / image_path / image_token / file_path / file_token.');
1560
1784
  if (args.image_path || args.image_token) {
1561
- if (args.children) return text('create_doc_block: pass children OR image_path OR image_token, not both.');
1562
1785
  const r = await official.createDocBlockWithImage(docId, args.parent_block_id, {
1563
1786
  imagePath: args.image_path,
1564
1787
  imageToken: args.image_token,
@@ -1566,19 +1789,29 @@ async function handleTool(name, args) {
1566
1789
  });
1567
1790
  return json(r);
1568
1791
  }
1569
- if (!args.children) return text('create_doc_block: children (generic blocks), image_path, or image_token is required.');
1792
+ if (args.file_path || args.file_token) {
1793
+ const r = await official.createDocBlockWithFile(docId, args.parent_block_id, {
1794
+ filePath: args.file_path,
1795
+ fileToken: args.file_token,
1796
+ index: args.index,
1797
+ });
1798
+ return json(r);
1799
+ }
1800
+ if (!args.children) return text('create_doc_block: children, image_path, image_token, file_path, or file_token is required.');
1570
1801
  return json(await official.createDocBlock(docId, args.parent_block_id, args.children, args.index));
1571
1802
  }
1572
1803
  case 'update_doc_block': {
1573
1804
  const official = getOfficialClient();
1574
1805
  const docId = await resolveDocId(args.document_id);
1575
- if (args.image_token && args.update_body) {
1576
- return text('update_doc_block: pass image_token OR update_body, not both.');
1577
- }
1806
+ const modes = [args.update_body, args.image_token, args.file_token].filter(Boolean);
1807
+ if (modes.length > 1) return text('update_doc_block: pass exactly ONE of update_body / image_token / file_token.');
1578
1808
  if (args.image_token) {
1579
1809
  return json(await official.updateDocBlockImage(docId, args.block_id, args.image_token));
1580
1810
  }
1581
- if (!args.update_body) return text('update_doc_block: update_body or image_token is required.');
1811
+ if (args.file_token) {
1812
+ return json(await official.updateDocBlockFile(docId, args.block_id, args.file_token));
1813
+ }
1814
+ if (!args.update_body) return text('update_doc_block: update_body, image_token, or file_token is required.');
1582
1815
  return json(await official.updateDocBlock(docId, args.block_id, args.update_body));
1583
1816
  }
1584
1817
  case 'delete_doc_blocks':
package/src/oauth.js CHANGED
@@ -28,7 +28,10 @@ const REDIRECT_URI = `http://127.0.0.1:${PORT}/callback`;
28
28
  // calendar:* for list_calendars / list_calendar_events / get_calendar_event
29
29
  // wiki:wiki write access for move_docs_to_wiki (attach docs/bitables to wiki)
30
30
  // docs:document.media:(upload|download) for docx image read/write
31
- const SCOPES = 'offline_access auth:user.id:read im:message im:message:readonly im:chat im:chat:readonly contact:user.base:readonly contact:user.id:readonly docx:document drive:drive bitable:app wiki:wiki:readonly wiki:wiki okr:okr:readonly okr:okr.period:readonly okr:okr.content:readonly calendar:calendar:readonly calendar:calendar.event:read docs:document.media:download docs:document.media:upload';
31
+ // v1.3.6 additions:
32
+ // sheets:spreadsheet for sheet_image / sheet_file media uploads
33
+ // drive:file:upload narrower scope for drive/v1/files/upload_all (independent of drive:drive)
34
+ const SCOPES = 'offline_access auth:user.id:read im:message im:message:readonly im:chat im:chat:readonly contact:user.base:readonly contact:user.id:readonly docx:document drive:drive drive:file:upload bitable:app wiki:wiki:readonly wiki:wiki okr:okr:readonly okr:okr.period:readonly okr:okr.content:readonly calendar:calendar:readonly calendar:calendar.event:read docs:document.media:download docs:document.media:upload sheets:spreadsheet';
32
35
 
33
36
  if (!APP_ID || !APP_SECRET) {
34
37
  console.error('Missing LARK_APP_ID or LARK_APP_SECRET.');
package/src/official.js CHANGED
@@ -1,7 +1,7 @@
1
1
  const lark = require('@larksuiteoapi/node-sdk');
2
2
  const { fetchWithTimeout } = require('./utils');
3
3
  const { classifyError } = require('./error-codes');
4
- const { buildEmptyImageBlock, buildReplaceImagePayload } = require('./doc-blocks');
4
+ const { buildEmptyImageBlock, buildReplaceImagePayload, buildEmptyFileBlock, buildReplaceFilePayload } = require('./doc-blocks');
5
5
 
6
6
  // Redirect all Lark SDK logs to stderr.
7
7
  // The SDK's defaultLogger.error uses console.log (stdout), which corrupts
@@ -1447,17 +1447,36 @@ class LarkOfficialClient {
1447
1447
 
1448
1448
  // --- Docx Image Write (v1.3.4) ---
1449
1449
 
1450
- // Upload binary media (typically an image) to Feishu's drive layer so it can
1451
- // be attached to a docx block. Returns the media's file_token, which is what
1452
- // the image block's `replace_image.token` expects.
1450
+ // Upload binary media to Feishu's drive layer so it can be attached to a
1451
+ // docx block, sheet cell, bitable attachment field, etc. Returns the
1452
+ // media's file_token, which is what the host block's replace_*.token
1453
+ // (or bitable attachment field value) expects.
1453
1454
  //
1454
- // parentType = 'docx_image' for doc-embedded images (most common).
1455
- // parentNode = the block_id of the image placeholder (NOT the document_id).
1456
- async uploadDocMedia(filePath, parentNode, parentType = 'docx_image') {
1455
+ // parentType {
1456
+ // docx_image, docx_file,
1457
+ // sheet_image, sheet_file,
1458
+ // bitable_image, bitable_file,
1459
+ // doc_image, doc_file, // legacy doc (pre-docx)
1460
+ // ccm_import_open, // import-task host
1461
+ // vc_virtual_background // VC bg, grayscale-only
1462
+ // }
1463
+ // parentNode = the block_id (docx) / spreadsheet_token (sheet) / app_token
1464
+ // (bitable) / doc_token (legacy) — depends on parentType.
1465
+ async uploadMedia(filePath, parentNode, parentType = 'docx_image') {
1457
1466
  const fs = require('fs');
1458
1467
  const path = require('path');
1459
- if (!filePath) throw new Error('uploadDocMedia: filePath is required');
1460
- if (!parentNode) throw new Error('uploadDocMedia: parentNode (block_id) is required');
1468
+ if (!filePath) throw new Error('uploadMedia: filePath is required');
1469
+ if (!parentNode) throw new Error('uploadMedia: parentNode is required');
1470
+ const ALLOWED = new Set([
1471
+ 'docx_image', 'docx_file',
1472
+ 'sheet_image', 'sheet_file',
1473
+ 'bitable_image', 'bitable_file',
1474
+ 'doc_image', 'doc_file',
1475
+ 'ccm_import_open', 'vc_virtual_background',
1476
+ ]);
1477
+ if (!ALLOWED.has(parentType)) {
1478
+ throw new Error(`uploadMedia: unsupported parent_type "${parentType}". Allowed: ${[...ALLOWED].join(', ')}`);
1479
+ }
1461
1480
 
1462
1481
  const stat = fs.statSync(filePath);
1463
1482
  const fileName = path.basename(filePath);
@@ -1465,12 +1484,22 @@ class LarkOfficialClient {
1465
1484
 
1466
1485
  // Best-effort content-type from extension. Feishu doesn't require it but
1467
1486
  // some CDNs behind the API key off it; the Blob default is text/plain
1468
- // which would look wrong for binary images.
1487
+ // which would look wrong for binary attachments.
1469
1488
  const ext = path.extname(fileName).toLowerCase();
1470
1489
  const mimeMap = {
1490
+ // image
1471
1491
  '.png': 'image/png', '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg',
1472
1492
  '.gif': 'image/gif', '.webp': 'image/webp', '.svg': 'image/svg+xml',
1473
1493
  '.bmp': 'image/bmp', '.tiff': 'image/tiff', '.ico': 'image/x-icon',
1494
+ // doc / archive
1495
+ '.pdf': 'application/pdf', '.zip': 'application/zip',
1496
+ '.doc': 'application/msword',
1497
+ '.docx': 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
1498
+ '.xls': 'application/vnd.ms-excel',
1499
+ '.xlsx': 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
1500
+ '.ppt': 'application/vnd.ms-powerpoint',
1501
+ '.pptx': 'application/vnd.openxmlformats-officedocument.presentationml.presentation',
1502
+ '.txt': 'text/plain', '.md': 'text/markdown', '.csv': 'text/csv', '.json': 'application/json',
1474
1503
  };
1475
1504
  const contentType = mimeMap[ext] || 'application/octet-stream';
1476
1505
 
@@ -1490,22 +1519,84 @@ class LarkOfficialClient {
1490
1519
  return res.json();
1491
1520
  };
1492
1521
 
1493
- // User identity first — docx_image usually belongs to a user-owned doc.
1522
+ // User identity first — host resources are usually user-owned.
1523
+ if (this.hasUAT) {
1524
+ try {
1525
+ const data = await this._withUAT(doUpload);
1526
+ if (data.code === 0 && data.data?.file_token) {
1527
+ return { fileToken: data.data.file_token, viaUser: true };
1528
+ }
1529
+ console.error(`[feishu-user-plugin] uploadMedia (${parentType}) as user failed (${data.code}: ${data.msg}), retrying as app`);
1530
+ } catch (e) {
1531
+ console.error(`[feishu-user-plugin] uploadMedia (${parentType}) as user threw (${e.message}), retrying as app`);
1532
+ }
1533
+ }
1534
+ const appToken = await this._getAppToken();
1535
+ const data = await doUpload(appToken);
1536
+ if (data.code !== 0 || !data.data?.file_token) {
1537
+ throw new Error(`uploadMedia (${parentType}) failed: ${data.code}: ${data.msg || 'no file_token returned'}`);
1538
+ }
1539
+ return { fileToken: data.data.file_token, viaUser: false };
1540
+ }
1541
+
1542
+ // Backwards-compat alias — old name from v1.3.4.
1543
+ async uploadDocMedia(filePath, parentNode, parentType = 'docx_image') {
1544
+ return this.uploadMedia(filePath, parentNode, parentType);
1545
+ }
1546
+
1547
+ // Upload a file to a drive folder (NOT for embedding in a doc — that's
1548
+ // uploadMedia). Uses drive/v1/files/upload_all with parent_type=explorer.
1549
+ // Returns { fileToken, viaUser } where fileToken is the cloud-doc file id.
1550
+ async uploadDriveFile(filePath, folderToken) {
1551
+ const fs = require('fs');
1552
+ const path = require('path');
1553
+ if (!filePath) throw new Error('uploadDriveFile: filePath is required');
1554
+ if (!folderToken) throw new Error('uploadDriveFile: folderToken is required (use the destination folder token; for "my space" root call list_files first to get it)');
1555
+
1556
+ const stat = fs.statSync(filePath);
1557
+ const fileName = path.basename(filePath);
1558
+ const buf = fs.readFileSync(filePath);
1559
+ const ext = path.extname(fileName).toLowerCase();
1560
+ const mimeMap = {
1561
+ '.pdf': 'application/pdf', '.zip': 'application/zip',
1562
+ '.png': 'image/png', '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg',
1563
+ '.txt': 'text/plain', '.md': 'text/markdown', '.csv': 'text/csv', '.json': 'application/json',
1564
+ '.docx': 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
1565
+ '.xlsx': 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
1566
+ };
1567
+ const contentType = mimeMap[ext] || 'application/octet-stream';
1568
+
1569
+ const doUpload = async (bearer) => {
1570
+ const form = new FormData();
1571
+ form.append('file_name', fileName);
1572
+ form.append('parent_type', 'explorer');
1573
+ form.append('parent_node', folderToken);
1574
+ form.append('size', String(stat.size));
1575
+ form.append('file', new Blob([buf], { type: contentType }), fileName);
1576
+ const res = await fetchWithTimeout('https://open.feishu.cn/open-apis/drive/v1/files/upload_all', {
1577
+ method: 'POST',
1578
+ headers: { 'Authorization': `Bearer ${bearer}` },
1579
+ body: form,
1580
+ timeoutMs: 120000,
1581
+ });
1582
+ return res.json();
1583
+ };
1584
+
1494
1585
  if (this.hasUAT) {
1495
1586
  try {
1496
1587
  const data = await this._withUAT(doUpload);
1497
1588
  if (data.code === 0 && data.data?.file_token) {
1498
1589
  return { fileToken: data.data.file_token, viaUser: true };
1499
1590
  }
1500
- console.error(`[feishu-user-plugin] uploadDocMedia as user failed (${data.code}: ${data.msg}), retrying as app`);
1591
+ console.error(`[feishu-user-plugin] uploadDriveFile as user failed (${data.code}: ${data.msg}), retrying as app`);
1501
1592
  } catch (e) {
1502
- console.error(`[feishu-user-plugin] uploadDocMedia as user threw (${e.message}), retrying as app`);
1593
+ console.error(`[feishu-user-plugin] uploadDriveFile as user threw (${e.message}), retrying as app`);
1503
1594
  }
1504
1595
  }
1505
1596
  const appToken = await this._getAppToken();
1506
1597
  const data = await doUpload(appToken);
1507
1598
  if (data.code !== 0 || !data.data?.file_token) {
1508
- throw new Error(`uploadDocMedia failed: ${data.code}: ${data.msg || 'no file_token returned'}`);
1599
+ throw new Error(`uploadDriveFile failed: ${data.code}: ${data.msg || 'no file_token returned'}`);
1509
1600
  }
1510
1601
  return { fileToken: data.data.file_token, viaUser: false };
1511
1602
  }
@@ -1544,7 +1635,7 @@ class LarkOfficialClient {
1544
1635
  let viaUser = !!created._viaUser;
1545
1636
  let fallbackWarning = created._fallbackWarning || null;
1546
1637
  if (!finalToken) {
1547
- const uploaded = await this.uploadDocMedia(imagePath, blockId, 'docx_image');
1638
+ const uploaded = await this.uploadMedia(imagePath, blockId, 'docx_image');
1548
1639
  finalToken = uploaded.fileToken;
1549
1640
  viaUser = viaUser && uploaded.viaUser; // true iff both steps went via user
1550
1641
  }
@@ -1567,7 +1658,7 @@ class LarkOfficialClient {
1567
1658
 
1568
1659
  // Replace an existing image block's media token (e.g. swap the picture in an
1569
1660
  // already-created image block). Expects an uploaded media token — use
1570
- // uploadDocMedia or create_doc_block's image_path shortcut to obtain one.
1661
+ // uploadMedia or create_doc_block's image_path shortcut to obtain one.
1571
1662
  async updateDocBlockImage(documentId, blockId, imageToken) {
1572
1663
  const patch = buildReplaceImagePayload(imageToken);
1573
1664
  await this._asUserOrApp({
@@ -1583,6 +1674,125 @@ class LarkOfficialClient {
1583
1674
  return { blockId, imageToken };
1584
1675
  }
1585
1676
 
1677
+ // Create a file-attachment block in a docx, mirroring createDocBlockWithImage:
1678
+ // 1) create empty file placeholder block
1679
+ // 2) upload the binary via uploadMedia(parent_type=docx_file)
1680
+ // 3) PATCH with replace_file.token to attach
1681
+ // Returns { blockId, fileToken, viaUser, fallbackWarning }.
1682
+ async createDocBlockWithFile(documentId, parentBlockId, { filePath, fileToken, index } = {}) {
1683
+ if (!filePath && !fileToken) {
1684
+ throw new Error('createDocBlockWithFile: either filePath or fileToken is required');
1685
+ }
1686
+ const placeholder = buildEmptyFileBlock();
1687
+ const createBody = { children: [placeholder] };
1688
+ if (index !== undefined) createBody.index = index;
1689
+ const created = await this._asUserOrApp({
1690
+ uatPath: `/open-apis/docx/v1/documents/${documentId}/blocks/${parentBlockId}/children`,
1691
+ method: 'POST',
1692
+ body: createBody,
1693
+ sdkFn: () => this.client.docx.documentBlockChildren.create({
1694
+ path: { document_id: documentId, block_id: parentBlockId },
1695
+ data: createBody,
1696
+ }),
1697
+ label: 'createDocBlockWithFile.placeholder',
1698
+ });
1699
+ // Feishu auto-wraps a FILE block (block_type=23) in a VIEW block
1700
+ // (block_type=33) — the create response returns the OUTER view block.
1701
+ // We need the inner file block's id for both the media upload (parent_node)
1702
+ // and the replace_file PATCH. Walk children to find it; fall back to a
1703
+ // get_doc_blocks lookup if the response didn't materialize the descendant.
1704
+ const newBlock = (created.data.children || [])[0];
1705
+ const outerBlockId = newBlock?.block_id;
1706
+ if (!outerBlockId) throw new Error(`createDocBlockWithFile: placeholder creation returned no block_id: ${JSON.stringify(created.data).slice(0, 400)}`);
1707
+ // Feishu auto-wraps a FILE block (23) in a VIEW block (33). The create
1708
+ // response's outer block is the view; we need to find the inner file
1709
+ // block for both the media upload (parent_node) and the replace_file PATCH.
1710
+ let blockId = outerBlockId;
1711
+ if (newBlock.block_type !== 23) {
1712
+ const inner = await this._findFileChildOf(documentId, outerBlockId, newBlock.children);
1713
+ if (!inner) throw new Error(`createDocBlockWithFile: could not locate inner FILE block under view ${outerBlockId}`);
1714
+ blockId = inner;
1715
+ }
1716
+
1717
+ let finalToken = fileToken;
1718
+ let viaUser = !!created._viaUser;
1719
+ let fallbackWarning = created._fallbackWarning || null;
1720
+ if (!finalToken) {
1721
+ const uploaded = await this.uploadMedia(filePath, blockId, 'docx_file');
1722
+ finalToken = uploaded.fileToken;
1723
+ viaUser = viaUser && uploaded.viaUser;
1724
+ }
1725
+
1726
+ const patch = buildReplaceFilePayload(finalToken);
1727
+ await this._asUserOrApp({
1728
+ uatPath: `/open-apis/docx/v1/documents/${documentId}/blocks/${blockId}`,
1729
+ method: 'PATCH',
1730
+ body: patch,
1731
+ sdkFn: () => this.client.docx.documentBlock.patch({
1732
+ path: { document_id: documentId, block_id: blockId },
1733
+ data: patch,
1734
+ }),
1735
+ label: 'createDocBlockWithFile.replaceFile',
1736
+ });
1737
+
1738
+ return { blockId, viewBlockId: outerBlockId !== blockId ? outerBlockId : undefined, fileToken: finalToken, viaUser, fallbackWarning };
1739
+ }
1740
+
1741
+ // Helper for createDocBlockWithFile — given a view block id and the children
1742
+ // array surfaced by the create response (just IDs in docx v1), find the
1743
+ // FILE child (block_type=23). If no children list was returned, fall back
1744
+ // to listing the doc and walking by parent_id.
1745
+ async _findFileChildOf(documentId, viewBlockId, childIds) {
1746
+ if (Array.isArray(childIds) && childIds.length > 0) {
1747
+ // childIds[0] is most likely the file block — verify with a get
1748
+ for (const childId of childIds) {
1749
+ try {
1750
+ const res = await this._asUserOrApp({
1751
+ uatPath: `/open-apis/docx/v1/documents/${documentId}/blocks/${childId}`,
1752
+ method: 'GET',
1753
+ sdkFn: () => this.client.docx.documentBlock.get({ path: { document_id: documentId, block_id: childId } }),
1754
+ label: '_findFileChildOf.get',
1755
+ });
1756
+ if (res?.data?.block?.block_type === 23) return childId;
1757
+ } catch (_) { /* fall through */ }
1758
+ }
1759
+ // None matched directly; return the first as best-effort
1760
+ return childIds[0];
1761
+ }
1762
+ // Fallback: list all blocks and find a 23 whose parent_id is the view block
1763
+ try {
1764
+ const res = await this._asUserOrApp({
1765
+ uatPath: `/open-apis/docx/v1/documents/${documentId}/blocks`,
1766
+ method: 'GET',
1767
+ sdkFn: () => this.client.docx.documentBlock.list({ path: { document_id: documentId } }),
1768
+ label: '_findFileChildOf.list',
1769
+ });
1770
+ const items = res?.data?.items || [];
1771
+ const match = items.find(b => b.block_type === 23 && b.parent_id === viewBlockId);
1772
+ return match?.block_id || null;
1773
+ } catch (_) {
1774
+ return null;
1775
+ }
1776
+ }
1777
+
1778
+ // Replace an existing file block's media token. Expects an already-uploaded
1779
+ // file token (use uploadMedia with parent_type=docx_file, or
1780
+ // create_doc_block's file_path shortcut).
1781
+ async updateDocBlockFile(documentId, blockId, fileToken) {
1782
+ const patch = buildReplaceFilePayload(fileToken);
1783
+ await this._asUserOrApp({
1784
+ uatPath: `/open-apis/docx/v1/documents/${documentId}/blocks/${blockId}`,
1785
+ method: 'PATCH',
1786
+ body: patch,
1787
+ sdkFn: () => this.client.docx.documentBlock.patch({
1788
+ path: { document_id: documentId, block_id: blockId },
1789
+ data: patch,
1790
+ }),
1791
+ label: 'updateDocBlockFile',
1792
+ });
1793
+ return { blockId, fileToken };
1794
+ }
1795
+
1586
1796
  // --- Wiki attach (v1.3.4) ---
1587
1797
 
1588
1798
  // Move an existing drive resource (docx / bitable / sheet / ...) into a Wiki