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.
- package/.claude-plugin/plugin.json +2 -2
- package/README.md +20 -9
- package/package.json +2 -2
- package/skills/feishu-user-plugin/SKILL.md +3 -3
- package/skills/feishu-user-plugin/references/CLAUDE.md +20 -3
- package/src/doc-blocks.js +20 -5
- package/src/index.js +251 -18
- package/src/oauth.js +4 -1
- package/src/official.js +226 -16
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "feishu-user-plugin",
|
|
3
|
-
"version": "1.3.
|
|
4
|
-
"description": "All-in-one Feishu plugin for Claude Code — send messages as yourself (incl. real @-mentions), read chats (
|
|
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)
|
|
4
4
|
[](https://nodejs.org)
|
|
5
5
|
[](https://modelcontextprotocol.io)
|
|
6
|
-
[](#tools)
|
|
7
7
|
[](CONTRIBUTING.md)
|
|
8
8
|
|
|
9
|
-
**All-in-one Feishu/Lark MCP Server --
|
|
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 (
|
|
340
|
+
## Tools (81 total)
|
|
341
341
|
|
|
342
|
-
### User Identity -- Messaging (
|
|
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
|
|
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 (
|
|
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 (
|
|
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 (
|
|
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.
|
|
4
|
-
"description": "All-in-one Feishu plugin for Claude Code & Codex — messaging (with merge_forward expansion), docs (
|
|
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.
|
|
4
|
-
description: "All-in-one Feishu plugin — send messages as yourself, read group/P2P chats (auto-expands merge_forward), manage docs/tables/wiki (
|
|
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,
|
|
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 (
|
|
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
|
|
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),
|
|
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
|
-
//
|
|
4
|
-
// create_doc_block / update_doc_block
|
|
5
|
-
// (
|
|
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
|
|
159
|
+
const env = profileEnv(currentProfile);
|
|
160
|
+
const cookie = env.LARK_COOKIE;
|
|
125
161
|
if (!cookie) throw new Error(
|
|
126
|
-
|
|
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
|
|
141
|
-
const
|
|
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
|
-
|
|
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.
|
|
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
|
|
848
|
-
image_token: { type: 'string', description: 'Pre-uploaded docx image token — mode C (mutually exclusive with
|
|
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
|
-
|
|
1559
|
-
|
|
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 (
|
|
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
|
-
|
|
1576
|
-
|
|
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 (
|
|
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
|
-
|
|
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
|
|
1451
|
-
//
|
|
1452
|
-
// the
|
|
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
|
|
1455
|
-
//
|
|
1456
|
-
|
|
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('
|
|
1460
|
-
if (!parentNode) throw new Error('
|
|
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
|
|
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 —
|
|
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]
|
|
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]
|
|
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(`
|
|
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.
|
|
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
|
-
//
|
|
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
|