feishu-user-plugin 1.3.2 → 1.3.3
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/CHANGELOG.md +16 -0
- package/README.md +5 -4
- package/package.json +2 -2
- package/skills/feishu-user-plugin/SKILL.md +3 -3
- package/skills/feishu-user-plugin/references/CLAUDE.md +17 -4
- package/src/client.js +4 -4
- package/src/index.js +79 -12
- package/src/official.js +113 -10
- package/src/utils.js +13 -0
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "feishu-user-plugin",
|
|
3
|
-
"version": "1.3.
|
|
4
|
-
"description": "All-in-one Feishu plugin for Claude Code — send messages as yourself (incl. real @-mentions), read chats, manage docs/tables/wiki.
|
|
3
|
+
"version": "1.3.3",
|
|
4
|
+
"description": "All-in-one Feishu plugin for Claude Code — send messages as yourself (incl. real @-mentions), read chats, manage docs/tables/wiki. 67 tools + 9 skills, 3 auth layers.",
|
|
5
5
|
"author": {
|
|
6
6
|
"name": "EthanQC"
|
|
7
7
|
},
|
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,22 @@ All notable changes to this project will be documented in this file.
|
|
|
4
4
|
|
|
5
5
|
The format is based on [Keep a Changelog](https://keepachangelog.com/), and this project adheres to [Semantic Versioning](https://semver.org/).
|
|
6
6
|
|
|
7
|
+
## [1.3.3] - 2026-04-20
|
|
8
|
+
|
|
9
|
+
### Fixed
|
|
10
|
+
- **MCP mid-session disconnect (root fix)**: All raw `fetch` calls to Feishu now go through `fetchWithTimeout` (AbortController, 30s default). A stalled connection used to hang a tool handler indefinitely; the MCP client would time out and some clients tore down the stdio transport — observed as "MCP 中途掉线" on v1.3.2. This was the real cause, not just the v1.3.1 stdout pollution.
|
|
11
|
+
- **stdout pollution (defense-in-depth)**: `src/index.js` now globally redirects `console.log` / `console.info` to stderr at startup, before any other `require`. Any current or future dependency that accidentally writes to stdout can no longer corrupt the JSON-RPC channel. (v1.3.1's Lark-SDK-specific logger override stays as-is.)
|
|
12
|
+
- **`(as user)` label lied for docs/bitable/folder creation**: `create_doc` / `create_bitable` / `create_folder` previously labeled every successful call `(as user)` whenever `LARK_USER_ACCESS_TOKEN` was set, even when the UAT call actually failed and silently fell back to app identity. `_asUserOrApp` now threads a real `_viaUser` flag through; failures show `(as app — UAT unavailable or failed; <resource> owned by the app, not you)`.
|
|
13
|
+
|
|
14
|
+
### Added
|
|
15
|
+
- **APP_ID startup validation**: MCP server probes `/auth/v3/app_access_token/internal` at boot. Invalid `LARK_APP_ID` / `LARK_APP_SECRET` (wrong-tenant, stale, or hallucinated by an autoinstall) now produce a clear stderr error pointing at the team-skills install prompt. Non-blocking — users running cookie-only workflows are unaffected.
|
|
16
|
+
- **`get_login_status` shows app identity**: Now returns the actual `app_id` plus fetched app name, so users can immediately spot "this isn't my team's app" scenarios.
|
|
17
|
+
- **`download_image` tool**: Download an image embedded in a message by `message_id` + `image_key`, returned as MCP image content so the model can see the pixels (not just the key string). Tries UAT first (works for any chat the user is in); falls back to app token (requires the bot to be in the chat).
|
|
18
|
+
|
|
19
|
+
### Changed
|
|
20
|
+
- Tool count 66 → **67** (added `download_image`).
|
|
21
|
+
- README tool badge corrected from 76 → 67 (previous 76 was stale and never matched the actual export).
|
|
22
|
+
|
|
7
23
|
## [1.1.3] - 2026-03-11
|
|
8
24
|
|
|
9
25
|
### Fixed
|
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 -- 67 tools, 9 skills, 3 auth layers for messaging, docs, bitable, calendar, tasks, drive, and more.**
|
|
10
10
|
|
|
11
11
|
The only MCP server that lets you send messages as your **personal identity** (not a bot), while also integrating the full official Feishu API. Works with Claude Code, Cursor, Windsurf, OpenClaw, and any MCP-compatible client.
|
|
12
12
|
|
|
@@ -337,7 +337,7 @@ Add to `~/.codeium/windsurf/mcp_config.json`:
|
|
|
337
337
|
}
|
|
338
338
|
```
|
|
339
339
|
|
|
340
|
-
## Tools (
|
|
340
|
+
## Tools (67 total)
|
|
341
341
|
|
|
342
342
|
### User Identity -- Messaging (8 tools, cookie auth)
|
|
343
343
|
|
|
@@ -390,6 +390,7 @@ Add to `~/.codeium/windsurf/mcp_config.json`:
|
|
|
390
390
|
| `add_members` | Add users to a group |
|
|
391
391
|
| `remove_members` | Remove users from a group |
|
|
392
392
|
| `upload_image` / `upload_file` | Upload image/file, returns key for sending |
|
|
393
|
+
| `download_image` | Download an image from a message by message_id + image_key, returned as MCP image content so the model can see the pixels |
|
|
393
394
|
|
|
394
395
|
### Official API -- Documents (7 tools)
|
|
395
396
|
|
|
@@ -508,7 +509,7 @@ feishu-user-plugin/
|
|
|
508
509
|
│ ├── SKILL.md # Main skill definition (trigger, tools, auth)
|
|
509
510
|
│ └── references/ # 8 skill reference docs + CLAUDE.md
|
|
510
511
|
├── src/
|
|
511
|
-
│ ├── index.js # MCP server entry point (
|
|
512
|
+
│ ├── index.js # MCP server entry point (67 tools)
|
|
512
513
|
│ ├── client.js # User identity client (Protobuf gateway)
|
|
513
514
|
│ ├── official.js # Official API client (REST, UAT)
|
|
514
515
|
│ ├── utils.js # ID generators, cookie parser
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "feishu-user-plugin",
|
|
3
|
-
"version": "1.3.
|
|
4
|
-
"description": "All-in-one Feishu plugin for Claude Code & Codex — messaging, docs, bitable, wiki, drive.
|
|
3
|
+
"version": "1.3.3",
|
|
4
|
+
"description": "All-in-one Feishu plugin for Claude Code & Codex — messaging, docs, bitable, wiki, drive. 67 tools + 9 skills, 3 auth layers.",
|
|
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.
|
|
4
|
-
description: "All-in-one Feishu plugin — send messages as yourself, read group/P2P chats, manage docs/tables/wiki. Replaces and extends the official Feishu MCP."
|
|
5
|
-
allowed-tools: send_to_user, send_to_group, send_as_user, send_image_as_user, send_file_as_user, send_post_as_user, send_sticker_as_user, send_audio_as_user, search_contacts, create_p2p_chat, get_chat_info, get_user_info, get_login_status, read_p2p_messages, list_user_chats, list_chats, read_messages, reply_message, forward_message, search_docs, read_doc, create_doc, list_bitable_tables, list_bitable_fields, search_bitable_records, create_bitable_record, update_bitable_record, list_wiki_spaces, search_wiki, list_wiki_nodes, list_files, create_folder, find_user
|
|
3
|
+
version: "1.3.3"
|
|
4
|
+
description: "All-in-one Feishu plugin — send messages as yourself, read group/P2P chats, manage docs/tables/wiki, download images. Replaces and extends the official Feishu MCP."
|
|
5
|
+
allowed-tools: send_to_user, send_to_group, send_as_user, send_image_as_user, send_file_as_user, send_post_as_user, send_sticker_as_user, send_audio_as_user, search_contacts, create_p2p_chat, get_chat_info, get_user_info, get_login_status, read_p2p_messages, list_user_chats, list_chats, read_messages, reply_message, forward_message, search_docs, read_doc, create_doc, list_bitable_tables, list_bitable_fields, search_bitable_records, create_bitable_record, update_bitable_record, list_wiki_spaces, search_wiki, list_wiki_nodes, list_files, create_folder, find_user, download_image
|
|
6
6
|
user_invocable: true
|
|
7
7
|
---
|
|
8
8
|
|
|
@@ -6,7 +6,7 @@ All-in-one Feishu plugin for Claude Code with three auth layers:
|
|
|
6
6
|
- **Official API** (app credentials): Read group messages, docs, tables, wiki, drive, contacts, upload files
|
|
7
7
|
- **User OAuth UAT** (user_access_token): Read P2P chat history, list all user's chats
|
|
8
8
|
|
|
9
|
-
## Tool Categories (
|
|
9
|
+
## Tool Categories (67 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.
|
|
@@ -52,6 +52,7 @@ All-in-one Feishu plugin for Claude Code with three auth layers:
|
|
|
52
52
|
- `list_files` / `create_folder` — Drive
|
|
53
53
|
- `copy_file` / `move_file` / `delete_file` — Drive file operations (copy, move, delete)
|
|
54
54
|
- `upload_image` / `upload_file` — Upload image/file, returns key for send_image/send_file
|
|
55
|
+
- `download_image` — Download an image from a message (needs message_id + image_key from read_messages) and return it as MCP image content so the model can **see the pixels**, not just the key. Tries UAT first, falls back to app token (app path requires the bot to be in the chat).
|
|
55
56
|
- `find_user` — Contact lookup by email/mobile
|
|
56
57
|
|
|
57
58
|
## Usage Patterns
|
|
@@ -233,9 +234,21 @@ Tell user to restart Claude Code. Only ONE restart should be needed.
|
|
|
233
234
|
## Troubleshooting Guide
|
|
234
235
|
|
|
235
236
|
### If MCP disconnects mid-session
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
237
|
+
Two known root causes, both fixed in v1.3.3:
|
|
238
|
+
|
|
239
|
+
1. **stdout pollution** (partial fix in v1.3.1, fully closed in v1.3.3):
|
|
240
|
+
- `@larksuiteoapi/node-sdk`'s `defaultLogger.error` uses `console.log` (stdout). MCP uses stdout for JSON-RPC, so any stray write corrupts the transport and disconnects the client.
|
|
241
|
+
- v1.3.1 replaced the SDK's logger. v1.3.3 also globally redirects `console.log` / `console.info` → `console.error` at the top of `src/index.js` as defense-in-depth against ANY future dependency leaking to stdout.
|
|
242
|
+
|
|
243
|
+
2. **unbounded fetch hangs** (fixed in v1.3.3):
|
|
244
|
+
- All raw `fetch` calls to `feishu.cn` / `internal-api-lark-api.feishu.cn` used to have no timeout. A stalled connection (ECONNRESET, slow DNS, upstream hang) would block a tool handler indefinitely; the MCP client times out the request, which some clients handle by tearing down the stdio transport — observed as "mid-session disconnect".
|
|
245
|
+
- Fix: `utils.js::fetchWithTimeout` with `AbortController`, 30s default. All `client.js` + `official.js` fetches go through it.
|
|
246
|
+
- If still happening: check for any `console.log` calls in server code (only `console.error` is safe), and grep for raw `await fetch(` — every one must go through `fetchWithTimeout`.
|
|
247
|
+
|
|
248
|
+
### If Official API tools return 401 / "token invalid" every time
|
|
249
|
+
- **Likely cause**: `LARK_APP_ID` is wrong or stale. Observed in production: Claude Code auto-installed the plugin and guessed/copied a wrong APP_ID that doesn't match the team's real app (e.g. from an unrelated app, from someone else's machine, or hallucinated).
|
|
250
|
+
- **Diagnosis**: `get_login_status` now reports `App credentials: INVALID — app_id=<x> rejected by Feishu (<code>: <msg>)`. MCP startup logs `[feishu-user-plugin] ERROR: LARK_APP_ID=<x> was REJECTED by Feishu` on stderr when this happens.
|
|
251
|
+
- **Fix**: Re-run the canonical install prompt from `team-skills/plugins/feishu-user-plugin/README.md` which contains the correct APP_ID/SECRET, and restart Claude Code.
|
|
239
252
|
|
|
240
253
|
### If MCP tools are not available
|
|
241
254
|
1. Check `~/.claude.json` — config must be in **top-level** `mcpServers`, not inside `projects[*]`
|
package/src/client.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
const path = require('path');
|
|
2
2
|
const protobuf = require('protobufjs');
|
|
3
|
-
const { generateRequestId, generateCid, parseCookie, formatCookie } = require('./utils');
|
|
3
|
+
const { generateRequestId, generateCid, parseCookie, formatCookie, fetchWithTimeout } = require('./utils');
|
|
4
4
|
|
|
5
5
|
const GATEWAY_URL = 'https://internal-api-lark-api.feishu.cn/im/gateway/';
|
|
6
6
|
const CSRF_URL = 'https://internal-api-lark-api.feishu.cn/accounts/csrf';
|
|
@@ -47,7 +47,7 @@ class LarkUserClient {
|
|
|
47
47
|
// --- Auth ---
|
|
48
48
|
|
|
49
49
|
async _getCsrfToken() {
|
|
50
|
-
const res = await
|
|
50
|
+
const res = await fetchWithTimeout(`${CSRF_URL}?_t=${Date.now()}`, {
|
|
51
51
|
method: 'POST',
|
|
52
52
|
headers: {
|
|
53
53
|
...this._jsonHeaders(),
|
|
@@ -68,7 +68,7 @@ class LarkUserClient {
|
|
|
68
68
|
}
|
|
69
69
|
|
|
70
70
|
async _getUserInfo() {
|
|
71
|
-
const res = await
|
|
71
|
+
const res = await fetchWithTimeout(`${USER_INFO_URL}?app_id=12&_t=${Date.now()}`, {
|
|
72
72
|
headers: {
|
|
73
73
|
...this._jsonHeaders(),
|
|
74
74
|
'x-csrf-token': this.csrfToken || '',
|
|
@@ -179,7 +179,7 @@ class LarkUserClient {
|
|
|
179
179
|
cid: generateRequestId(),
|
|
180
180
|
payload: reqBuf,
|
|
181
181
|
});
|
|
182
|
-
const res = await
|
|
182
|
+
const res = await fetchWithTimeout(GATEWAY_URL, {
|
|
183
183
|
method: 'POST',
|
|
184
184
|
headers: this._protoHeaders(cmd, cmdVersion),
|
|
185
185
|
body: packetBuf,
|
package/src/index.js
CHANGED
|
@@ -1,4 +1,14 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
+
// MCP stdio protocol uses stdout for JSON-RPC. ANY accidental stdout write from
|
|
3
|
+
// this process or its dependencies will corrupt the transport and disconnect the
|
|
4
|
+
// client. v1.3.1 patched the Lark SDK's defaultLogger via a custom logger, but
|
|
5
|
+
// that only covers one dependency. This global redirect is defense-in-depth:
|
|
6
|
+
// any present or future module that calls console.log (or console.info) goes to
|
|
7
|
+
// stderr instead, so MCP stdio stays clean no matter what.
|
|
8
|
+
// Do this BEFORE any other require() so even early log calls are captured.
|
|
9
|
+
console.log = (...args) => console.error(...args);
|
|
10
|
+
console.info = (...args) => console.error(...args);
|
|
11
|
+
|
|
2
12
|
const { Server } = require('@modelcontextprotocol/sdk/server/index.js');
|
|
3
13
|
const { StdioServerTransport } = require('@modelcontextprotocol/sdk/server/stdio.js');
|
|
4
14
|
const {
|
|
@@ -992,6 +1002,20 @@ const TOOLS = [
|
|
|
992
1002
|
},
|
|
993
1003
|
},
|
|
994
1004
|
|
|
1005
|
+
// ========== Message Resources (Image/File Download) ==========
|
|
1006
|
+
{
|
|
1007
|
+
name: 'download_image',
|
|
1008
|
+
description: '[User Identity / Official API] Download an image embedded in a message so the model can actually see it. Pass the message_id and image_key returned by read_messages / read_p2p_messages. Tries user identity first (works for any chat the user sees), falls back to app identity (requires the bot to be in the same chat).',
|
|
1009
|
+
inputSchema: {
|
|
1010
|
+
type: 'object',
|
|
1011
|
+
properties: {
|
|
1012
|
+
message_id: { type: 'string', description: 'The message_id (om_xxx) that contains the image — from read_messages / read_p2p_messages' },
|
|
1013
|
+
image_key: { type: 'string', description: 'The image_key from the message content (img_xxx)' },
|
|
1014
|
+
},
|
|
1015
|
+
required: ['message_id', 'image_key'],
|
|
1016
|
+
},
|
|
1017
|
+
},
|
|
1018
|
+
|
|
995
1019
|
];
|
|
996
1020
|
|
|
997
1021
|
// --- Server ---
|
|
@@ -1139,9 +1163,20 @@ async function handleTool(name, args) {
|
|
|
1139
1163
|
parts.push(` ${status.message}`);
|
|
1140
1164
|
} catch (e) { parts.push(`Cookie: ${e.message}`); }
|
|
1141
1165
|
const hasApp = !!(process.env.LARK_APP_ID && process.env.LARK_APP_SECRET);
|
|
1142
|
-
|
|
1143
|
-
|
|
1144
|
-
|
|
1166
|
+
if (!hasApp) {
|
|
1167
|
+
parts.push(`App credentials: Not set`);
|
|
1168
|
+
} else {
|
|
1169
|
+
const official = getOfficialClient();
|
|
1170
|
+
const probe = await official.verifyApp();
|
|
1171
|
+
if (probe.valid) {
|
|
1172
|
+
const nameBit = probe.appName ? ` "${probe.appName}"` : '';
|
|
1173
|
+
parts.push(`App credentials: Valid — app_id=${probe.appId}${nameBit}`);
|
|
1174
|
+
} else {
|
|
1175
|
+
parts.push(`App credentials: INVALID — app_id=${probe.appId} rejected by Feishu (${probe.error})`);
|
|
1176
|
+
parts.push(` → Likely wrong/stale APP_ID. Re-run the install prompt from team-skills/plugins/feishu-user-plugin/README.md to get the correct credentials.`);
|
|
1177
|
+
}
|
|
1178
|
+
parts.push(`User access token: ${official.hasUAT ? 'Configured (P2P reading enabled)' : 'Not set (optional — needed for P2P chat reading. Run OAuth flow to obtain, see README for details)'}`);
|
|
1179
|
+
}
|
|
1145
1180
|
return text(parts.join('\n'));
|
|
1146
1181
|
}
|
|
1147
1182
|
|
|
@@ -1236,17 +1271,16 @@ async function handleTool(name, args) {
|
|
|
1236
1271
|
case 'get_doc_blocks':
|
|
1237
1272
|
return json(await getOfficialClient().getDocBlocks(args.document_id));
|
|
1238
1273
|
case 'create_doc': {
|
|
1239
|
-
const
|
|
1240
|
-
const ownership =
|
|
1241
|
-
return text(`Document created${ownership}: ${
|
|
1274
|
+
const r = await getOfficialClient().createDoc(args.title, args.folder_id);
|
|
1275
|
+
const ownership = r.viaUser ? ' (as user)' : ' (as app — UAT unavailable or failed; document owned by the app, not you)';
|
|
1276
|
+
return text(`Document created${ownership}: ${r.documentId}`);
|
|
1242
1277
|
}
|
|
1243
1278
|
|
|
1244
1279
|
// --- Official API: Bitable ---
|
|
1245
1280
|
|
|
1246
1281
|
case 'create_bitable': {
|
|
1247
|
-
const
|
|
1248
|
-
const ownership =
|
|
1249
|
-
const r = await official.createBitable(args.name, args.folder_id);
|
|
1282
|
+
const r = await getOfficialClient().createBitable(args.name, args.folder_id);
|
|
1283
|
+
const ownership = r.viaUser ? ' (as user)' : ' (as app — UAT unavailable or failed; bitable owned by the app, not you)';
|
|
1250
1284
|
return text(`Bitable created${ownership}: ${r.appToken}\nURL: ${r.url || ''}`);
|
|
1251
1285
|
}
|
|
1252
1286
|
case 'list_bitable_tables':
|
|
@@ -1298,9 +1332,9 @@ async function handleTool(name, args) {
|
|
|
1298
1332
|
case 'list_files':
|
|
1299
1333
|
return json(await getOfficialClient().listFiles(args.folder_token));
|
|
1300
1334
|
case 'create_folder': {
|
|
1301
|
-
const
|
|
1302
|
-
const ownership =
|
|
1303
|
-
return text(`Folder created${ownership}: ${
|
|
1335
|
+
const r = await getOfficialClient().createFolder(args.name, args.parent_token);
|
|
1336
|
+
const ownership = r.viaUser ? ' (as user)' : ' (as app — UAT unavailable or failed; folder owned by the app, not you)';
|
|
1337
|
+
return text(`Folder created${ownership}: ${r.token}`);
|
|
1304
1338
|
}
|
|
1305
1339
|
|
|
1306
1340
|
// --- Official API: Contact ---
|
|
@@ -1393,6 +1427,18 @@ async function handleTool(name, args) {
|
|
|
1393
1427
|
case 'delete_file':
|
|
1394
1428
|
return text(`File deleted: task=${(await getOfficialClient().deleteFile(args.file_token, args.type)).taskId}`);
|
|
1395
1429
|
|
|
1430
|
+
case 'download_image': {
|
|
1431
|
+
const r = await getOfficialClient().downloadMessageResource(args.message_id, args.image_key, 'image');
|
|
1432
|
+
// Return as MCP image content so the model sees the pixels directly.
|
|
1433
|
+
// Also include a tiny text preamble so the via-identity + size are visible in transcript.
|
|
1434
|
+
return {
|
|
1435
|
+
content: [
|
|
1436
|
+
{ type: 'text', text: `Image downloaded (${r.viaUser ? 'as user' : 'as app'}, ${r.bytes} bytes, ${r.mimeType}):` },
|
|
1437
|
+
{ type: 'image', data: r.base64, mimeType: r.mimeType },
|
|
1438
|
+
],
|
|
1439
|
+
};
|
|
1440
|
+
}
|
|
1441
|
+
|
|
1396
1442
|
default:
|
|
1397
1443
|
return text(`Unknown tool: ${name}`);
|
|
1398
1444
|
}
|
|
@@ -1422,6 +1468,27 @@ async function main() {
|
|
|
1422
1468
|
if (!hasCookie) console.error('[feishu-user-plugin] WARNING: LARK_COOKIE not set — user identity tools (send_to_user, etc.) will fail');
|
|
1423
1469
|
if (!hasApp) console.error('[feishu-user-plugin] WARNING: LARK_APP_ID/SECRET not set — official API tools (read_messages, docs, etc.) will fail');
|
|
1424
1470
|
if (!hasUAT) console.error('[feishu-user-plugin] WARNING: LARK_USER_ACCESS_TOKEN not set — P2P chat reading (read_p2p_messages) will fail');
|
|
1471
|
+
|
|
1472
|
+
// Validate APP_ID/SECRET against Feishu before serving any tool calls.
|
|
1473
|
+
// Catches the "Claude filled in a wrong/stale APP_ID during install" failure mode
|
|
1474
|
+
// that otherwise surfaces as cryptic 401s on every Official API call (looks like
|
|
1475
|
+
// "MCP 掉线" to the user). Non-blocking — we warn but still serve, because the
|
|
1476
|
+
// user may only need user-identity (cookie) tools.
|
|
1477
|
+
if (hasApp) {
|
|
1478
|
+
try {
|
|
1479
|
+
const probe = await getOfficialClient().verifyApp();
|
|
1480
|
+
if (probe.valid) {
|
|
1481
|
+
const nameBit = probe.appName ? ` "${probe.appName}"` : '';
|
|
1482
|
+
console.error(`[feishu-user-plugin] App verified: ${probe.appId}${nameBit}`);
|
|
1483
|
+
} else {
|
|
1484
|
+
console.error(`[feishu-user-plugin] ERROR: LARK_APP_ID=${probe.appId} was REJECTED by Feishu (${probe.error}).`);
|
|
1485
|
+
console.error('[feishu-user-plugin] → Every Official API tool call will fail. Likely wrong/stale APP_ID.');
|
|
1486
|
+
console.error('[feishu-user-plugin] → Re-run the install prompt from team-skills/plugins/feishu-user-plugin/README.md to get the correct credentials.');
|
|
1487
|
+
}
|
|
1488
|
+
} catch (e) {
|
|
1489
|
+
console.error(`[feishu-user-plugin] WARNING: Could not verify APP_ID (${e.message}); network issue or cold start. Proceeding anyway.`);
|
|
1490
|
+
}
|
|
1491
|
+
}
|
|
1425
1492
|
}
|
|
1426
1493
|
|
|
1427
1494
|
main().catch(console.error);
|
package/src/official.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
const lark = require('@larksuiteoapi/node-sdk');
|
|
2
|
+
const { fetchWithTimeout } = require('./utils');
|
|
2
3
|
|
|
3
4
|
// Redirect all Lark SDK logs to stderr.
|
|
4
5
|
// The SDK's defaultLogger.error uses console.log (stdout), which corrupts
|
|
@@ -39,6 +40,50 @@ class LarkOfficialClient {
|
|
|
39
40
|
return !!this._uat;
|
|
40
41
|
}
|
|
41
42
|
|
|
43
|
+
// Fetches (and caches) an app_access_token directly via the internal endpoint.
|
|
44
|
+
// Avoids relying on SDK-internal token-manager APIs that may change across versions.
|
|
45
|
+
async _getAppToken() {
|
|
46
|
+
const now = Math.floor(Date.now() / 1000);
|
|
47
|
+
if (this._appToken && this._appTokenExpires > now + 60) return this._appToken;
|
|
48
|
+
const res = await fetchWithTimeout('https://open.feishu.cn/open-apis/auth/v3/app_access_token/internal', {
|
|
49
|
+
method: 'POST',
|
|
50
|
+
headers: { 'content-type': 'application/json' },
|
|
51
|
+
body: JSON.stringify({ app_id: this.appId, app_secret: this.appSecret }),
|
|
52
|
+
timeoutMs: 10000,
|
|
53
|
+
});
|
|
54
|
+
const data = await res.json();
|
|
55
|
+
if (data.code !== 0 || !data.app_access_token) {
|
|
56
|
+
throw new Error(`app_access_token failed: ${data.code}: ${data.msg || 'unknown'}`);
|
|
57
|
+
}
|
|
58
|
+
this._appToken = data.app_access_token;
|
|
59
|
+
this._appTokenExpires = now + (typeof data.expire === 'number' ? data.expire : 7200);
|
|
60
|
+
return this._appToken;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// Probe APP_ID/SECRET validity by requesting a tenant access token.
|
|
64
|
+
// Catches the common "user's Claude filled in a wrong/stale APP_ID" failure mode
|
|
65
|
+
// (observed in production: 周宇's machine ran with an APP_ID nobody recognized,
|
|
66
|
+
// causing all Official API calls to 401 with cryptic messages that looked like
|
|
67
|
+
// MCP "掉线" to the user). Returns { valid, appId, appName?, error? }.
|
|
68
|
+
async verifyApp() {
|
|
69
|
+
try {
|
|
70
|
+
const token = await this._getAppToken();
|
|
71
|
+
// Try to fetch app display name (best-effort; requires application scope)
|
|
72
|
+
let appName = null;
|
|
73
|
+
try {
|
|
74
|
+
const infoRes = await fetchWithTimeout(`https://open.feishu.cn/open-apis/application/v6/applications/${this.appId}?lang=zh_cn`, {
|
|
75
|
+
headers: { 'Authorization': `Bearer ${token}` },
|
|
76
|
+
timeoutMs: 10000,
|
|
77
|
+
});
|
|
78
|
+
const info = await infoRes.json();
|
|
79
|
+
if (info.code === 0) appName = info.data?.app?.app_name || null;
|
|
80
|
+
} catch (_) { /* name is best-effort; valid creds still matter most */ }
|
|
81
|
+
return { valid: true, appId: this.appId, appName };
|
|
82
|
+
} catch (e) {
|
|
83
|
+
return { valid: false, appId: this.appId, error: e.message };
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
42
87
|
async _getValidUAT() {
|
|
43
88
|
if (!this._uat) throw new Error('No user_access_token. Run: npx feishu-user-plugin oauth');
|
|
44
89
|
|
|
@@ -53,7 +98,7 @@ class LarkOfficialClient {
|
|
|
53
98
|
async _refreshUAT() {
|
|
54
99
|
if (!this._uatRefresh) throw new Error('UAT expired and no refresh token. Run: npx feishu-user-plugin oauth');
|
|
55
100
|
|
|
56
|
-
const res = await
|
|
101
|
+
const res = await fetchWithTimeout('https://open.feishu.cn/open-apis/authen/v2/oauth/token', {
|
|
57
102
|
method: 'POST',
|
|
58
103
|
headers: { 'content-type': 'application/json' },
|
|
59
104
|
body: JSON.stringify({
|
|
@@ -112,31 +157,38 @@ class LarkOfficialClient {
|
|
|
112
157
|
headers['content-type'] = 'application/json';
|
|
113
158
|
init.body = JSON.stringify(body);
|
|
114
159
|
}
|
|
115
|
-
const res = await
|
|
160
|
+
const res = await fetchWithTimeout(url, init);
|
|
116
161
|
return res.json();
|
|
117
162
|
});
|
|
118
163
|
}
|
|
119
164
|
|
|
120
165
|
// Try UAT first (for resources likely owned by the user), fall back to app SDK on failure.
|
|
121
|
-
// Returns SDK-shaped {code, msg, data}.
|
|
166
|
+
// Returns SDK-shaped {code, msg, data, _viaUser}. _viaUser is true iff the UAT call succeeded;
|
|
167
|
+
// callers can surface this to distinguish "created by user" vs "created by app" for resources
|
|
168
|
+
// whose ownership matters (docs, bitables, folders).
|
|
122
169
|
async _asUserOrApp({ uatPath, method = 'GET', body, query, sdkFn, label }) {
|
|
123
170
|
if (this.hasUAT) {
|
|
124
171
|
try {
|
|
125
172
|
const data = await this._uatREST(method, uatPath, { body, query });
|
|
126
|
-
if (data.code === 0)
|
|
173
|
+
if (data.code === 0) {
|
|
174
|
+
data._viaUser = true;
|
|
175
|
+
return data;
|
|
176
|
+
}
|
|
127
177
|
console.error(`[feishu-user-plugin] ${label} as user failed (${data.code}: ${data.msg}), retrying as app`);
|
|
128
178
|
} catch (err) {
|
|
129
179
|
console.error(`[feishu-user-plugin] ${label} as user threw (${err.message}), retrying as app`);
|
|
130
180
|
}
|
|
131
181
|
}
|
|
132
|
-
|
|
182
|
+
const appData = await this._safeSDKCall(sdkFn, label);
|
|
183
|
+
if (appData && typeof appData === 'object') appData._viaUser = false;
|
|
184
|
+
return appData;
|
|
133
185
|
}
|
|
134
186
|
|
|
135
187
|
async listChatsAsUser({ pageSize = 20, pageToken } = {}) {
|
|
136
188
|
const params = new URLSearchParams({ page_size: String(pageSize) });
|
|
137
189
|
if (pageToken) params.set('page_token', pageToken);
|
|
138
190
|
const data = await this._withUAT(async (uat) => {
|
|
139
|
-
const res = await
|
|
191
|
+
const res = await fetchWithTimeout(`https://open.feishu.cn/open-apis/im/v1/chats?${params}`, {
|
|
140
192
|
headers: { 'Authorization': `Bearer ${uat}` },
|
|
141
193
|
});
|
|
142
194
|
return res.json();
|
|
@@ -158,7 +210,7 @@ class LarkOfficialClient {
|
|
|
158
210
|
if (endTime) params.set('end_time', endTime);
|
|
159
211
|
if (pageToken) params.set('page_token', pageToken);
|
|
160
212
|
const data = await this._withUAT(async (uat) => {
|
|
161
|
-
const res = await
|
|
213
|
+
const res = await fetchWithTimeout(`https://open.feishu.cn/open-apis/im/v1/messages?${params}`, {
|
|
162
214
|
headers: { 'Authorization': `Bearer ${uat}` },
|
|
163
215
|
});
|
|
164
216
|
return res.json();
|
|
@@ -198,6 +250,57 @@ class LarkOfficialClient {
|
|
|
198
250
|
return this._formatMessage(res.data);
|
|
199
251
|
}
|
|
200
252
|
|
|
253
|
+
// Download a resource (image/file) attached to a message.
|
|
254
|
+
// Tries UAT first (works for any chat the user is in), falls back to app token
|
|
255
|
+
// (requires the bot to be in the same chat — Feishu restriction).
|
|
256
|
+
// resourceType: 'image' | 'file'. Returns { base64, mimeType, viaUser }.
|
|
257
|
+
async downloadMessageResource(messageId, fileKey, resourceType = 'image') {
|
|
258
|
+
const path = `/open-apis/im/v1/messages/${encodeURIComponent(messageId)}/resources/${encodeURIComponent(fileKey)}?type=${encodeURIComponent(resourceType)}`;
|
|
259
|
+
const url = 'https://open.feishu.cn' + path;
|
|
260
|
+
|
|
261
|
+
// Attempt 1: user identity
|
|
262
|
+
if (this.hasUAT) {
|
|
263
|
+
try {
|
|
264
|
+
const uat = await this._getValidUAT();
|
|
265
|
+
const res = await fetchWithTimeout(url, {
|
|
266
|
+
headers: { 'Authorization': `Bearer ${uat}` },
|
|
267
|
+
timeoutMs: 60000,
|
|
268
|
+
});
|
|
269
|
+
if (res.ok && !res.headers.get('content-type')?.includes('application/json')) {
|
|
270
|
+
const buf = Buffer.from(await res.arrayBuffer());
|
|
271
|
+
return {
|
|
272
|
+
base64: buf.toString('base64'),
|
|
273
|
+
mimeType: res.headers.get('content-type') || 'application/octet-stream',
|
|
274
|
+
bytes: buf.length,
|
|
275
|
+
viaUser: true,
|
|
276
|
+
};
|
|
277
|
+
}
|
|
278
|
+
const errJson = await res.json().catch(() => null);
|
|
279
|
+
console.error(`[feishu-user-plugin] downloadMessageResource as user failed: ${errJson?.code}: ${errJson?.msg || res.statusText}, retrying as app`);
|
|
280
|
+
} catch (e) {
|
|
281
|
+
console.error(`[feishu-user-plugin] downloadMessageResource as user threw (${e.message}), retrying as app`);
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
// Attempt 2: app identity
|
|
286
|
+
const token = await this._getAppToken();
|
|
287
|
+
const res = await fetchWithTimeout(url, {
|
|
288
|
+
headers: { 'Authorization': `Bearer ${token}` },
|
|
289
|
+
timeoutMs: 60000,
|
|
290
|
+
});
|
|
291
|
+
if (!res.ok || res.headers.get('content-type')?.includes('application/json')) {
|
|
292
|
+
const errJson = await res.json().catch(() => null);
|
|
293
|
+
throw new Error(`downloadMessageResource failed: ${errJson?.code}: ${errJson?.msg || res.statusText}. Note: app identity requires the bot to be in the same chat.`);
|
|
294
|
+
}
|
|
295
|
+
const buf = Buffer.from(await res.arrayBuffer());
|
|
296
|
+
return {
|
|
297
|
+
base64: buf.toString('base64'),
|
|
298
|
+
mimeType: res.headers.get('content-type') || 'application/octet-stream',
|
|
299
|
+
bytes: buf.length,
|
|
300
|
+
viaUser: false,
|
|
301
|
+
};
|
|
302
|
+
}
|
|
303
|
+
|
|
201
304
|
async replyMessage(messageId, text, msgType = 'text') {
|
|
202
305
|
const content = msgType === 'text' ? JSON.stringify({ text }) : text;
|
|
203
306
|
const res = await this._safeSDKCall(
|
|
@@ -419,7 +522,7 @@ class LarkOfficialClient {
|
|
|
419
522
|
sdkFn: () => this.client.docx.document.create({ data: { title, folder_token: folderId || '' } }),
|
|
420
523
|
label: 'createDoc',
|
|
421
524
|
});
|
|
422
|
-
return { documentId: res.data.document?.document_id };
|
|
525
|
+
return { documentId: res.data.document?.document_id, viaUser: !!res._viaUser };
|
|
423
526
|
}
|
|
424
527
|
|
|
425
528
|
async getDocBlocks(documentId) {
|
|
@@ -499,7 +602,7 @@ class LarkOfficialClient {
|
|
|
499
602
|
sdkFn: () => this.client.bitable.app.create({ data }),
|
|
500
603
|
label: 'createBitable',
|
|
501
604
|
});
|
|
502
|
-
return { appToken: res.data.app?.app_token, name: res.data.app?.name, url: res.data.app?.url };
|
|
605
|
+
return { appToken: res.data.app?.app_token, name: res.data.app?.name, url: res.data.app?.url, viaUser: !!res._viaUser };
|
|
503
606
|
}
|
|
504
607
|
|
|
505
608
|
async listBitableTables(appToken) {
|
|
@@ -785,7 +888,7 @@ class LarkOfficialClient {
|
|
|
785
888
|
sdkFn: () => this.client.drive.file.createFolder({ data: body }),
|
|
786
889
|
label: 'createFolder',
|
|
787
890
|
});
|
|
788
|
-
return { token: res.data.token };
|
|
891
|
+
return { token: res.data.token, viaUser: !!res._viaUser };
|
|
789
892
|
}
|
|
790
893
|
|
|
791
894
|
// --- Drive: File Operations ---
|
package/src/utils.js
CHANGED
|
@@ -31,9 +31,22 @@ function formatCookie(cookieObj) {
|
|
|
31
31
|
.join('; ');
|
|
32
32
|
}
|
|
33
33
|
|
|
34
|
+
// Wraps global fetch with an AbortController-based timeout. A stalled network
|
|
35
|
+
// connection to feishu.cn can otherwise block an MCP tool handler indefinitely,
|
|
36
|
+
// causing the client to time out and (in some clients) tear down the stdio
|
|
37
|
+
// transport — observed as "MCP 中途掉线" by v1.3.2 users.
|
|
38
|
+
// Default 30s; pass `timeoutMs` in init to override per-call.
|
|
39
|
+
function fetchWithTimeout(url, init = {}) {
|
|
40
|
+
const { timeoutMs = 30000, ...rest } = init;
|
|
41
|
+
const controller = new AbortController();
|
|
42
|
+
const timer = setTimeout(() => controller.abort(new Error(`fetch timeout after ${timeoutMs}ms: ${url}`)), timeoutMs);
|
|
43
|
+
return fetch(url, { ...rest, signal: rest.signal || controller.signal }).finally(() => clearTimeout(timer));
|
|
44
|
+
}
|
|
45
|
+
|
|
34
46
|
module.exports = {
|
|
35
47
|
generateRequestId,
|
|
36
48
|
generateCid,
|
|
37
49
|
parseCookie,
|
|
38
50
|
formatCookie,
|
|
51
|
+
fetchWithTimeout,
|
|
39
52
|
};
|