feishu-user-plugin 1.3.1 → 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/proto/lark.proto +27 -0
- package/skills/feishu-user-plugin/SKILL.md +3 -3
- package/skills/feishu-user-plugin/references/CLAUDE.md +50 -15
- package/src/client.js +85 -14
- package/src/index.js +104 -42
- package/src/official.js +340 -166
- package/src/utils.js +13 -0
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "feishu-user-plugin",
|
|
3
|
-
"version": "1.
|
|
4
|
-
"description": "All-in-one Feishu plugin for Claude Code — send messages as yourself, 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"
|
package/proto/lark.proto
CHANGED
|
@@ -140,6 +140,19 @@ message RichText {
|
|
|
140
140
|
repeated string elementIds = 1;
|
|
141
141
|
optional string innerText = 2;
|
|
142
142
|
optional RichTextElements elements = 3;
|
|
143
|
+
// Index fields: each "*Ids" is a list of elemIds (pointing into the dictionary)
|
|
144
|
+
// that the server needs to register as special-type elements. Discovered from
|
|
145
|
+
// Feishu Web bundle — atIds=6 is what makes @-mentions actually notify.
|
|
146
|
+
repeated string imageIds = 5;
|
|
147
|
+
repeated string atIds = 6;
|
|
148
|
+
repeated string anchorIds = 7;
|
|
149
|
+
repeated string i18nIds = 8;
|
|
150
|
+
repeated string mediaIds = 9;
|
|
151
|
+
repeated string docsIds = 10;
|
|
152
|
+
repeated string interactiveIds = 11;
|
|
153
|
+
repeated string mentionIds = 12;
|
|
154
|
+
optional int32 version = 13;
|
|
155
|
+
repeated string atUserGroupIds = 14;
|
|
143
156
|
}
|
|
144
157
|
|
|
145
158
|
message RichTextElements {
|
|
@@ -167,6 +180,20 @@ message TextProperty {
|
|
|
167
180
|
optional string content = 1;
|
|
168
181
|
}
|
|
169
182
|
|
|
183
|
+
// For AT (@-mention) elements. Both fields are required in the real schema;
|
|
184
|
+
// `content` is marked deprecated but still required on the wire.
|
|
185
|
+
message AtProperty {
|
|
186
|
+
optional string userId = 1;
|
|
187
|
+
optional string content = 2;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
// For A (hyperlink) elements.
|
|
191
|
+
message AnchorProperty {
|
|
192
|
+
optional string href = 1;
|
|
193
|
+
optional string content = 2;
|
|
194
|
+
optional string textContent = 3;
|
|
195
|
+
}
|
|
196
|
+
|
|
170
197
|
// --- Chat Operations ---
|
|
171
198
|
|
|
172
199
|
// Create P2P chat (cmd=13)
|
|
@@ -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.
|
|
@@ -14,7 +14,8 @@ All-in-one Feishu plugin for Claude Code with three auth layers:
|
|
|
14
14
|
- `send_as_user` — Send text to any chat by ID, supports reply threading (root_id/parent_id)
|
|
15
15
|
- `send_image_as_user` — Send image (requires image_key from `upload_image`)
|
|
16
16
|
- `send_file_as_user` — Send file (requires file_key from `upload_file`)
|
|
17
|
-
- `send_post_as_user` — Send rich text with title + formatted paragraphs
|
|
17
|
+
- `send_post_as_user` — Send rich text with title + formatted paragraphs. Elements: `{tag:"text"}`, `{tag:"a",href,text}`, `{tag:"at",userId,name}`. **@-mentions trigger real notifications** (fixed by registering AT element IDs in RichText.atIds field 6 — reverse-engineered from Feishu Web bundle's AtProperty + RichText schemas).
|
|
18
|
+
- `send_as_user` / `send_to_user` / `send_to_group` — plain text sends now accept optional `ats: [{userId, name}]`; the text must contain the `@<name>` marker for each entry. The marker is spliced into a real AT element so the mentioned user is notified. Identity is the cookie user (not bot).
|
|
18
19
|
- `send_sticker_as_user` — Send sticker/emoji
|
|
19
20
|
- `send_audio_as_user` — Send audio message
|
|
20
21
|
|
|
@@ -28,7 +29,7 @@ All-in-one Feishu plugin for Claude Code with three auth layers:
|
|
|
28
29
|
### User OAuth UAT Tools (P2P chat reading + user-identity creation)
|
|
29
30
|
- `read_p2p_messages` — Read P2P (direct message) chat history. chat_id accepts both numeric IDs (from create_p2p_chat) and oc_xxx format. Returns newest messages first by default.
|
|
30
31
|
- `list_user_chats` — List group chats the user is in. Note: API only returns groups, not P2P. For P2P, use: `search_contacts` → `create_p2p_chat` → `read_p2p_messages`.
|
|
31
|
-
-
|
|
32
|
+
- **All docx + bitable + drive create/read/write tools are UAT-first**: when UAT is configured, every operation (create/edit/delete doc blocks, bitable tables/fields/views/records, drive folders) tries the user's token first and falls back to app token on failure. This keeps resources consistently owned by the user and avoids 403 errors when the app can't access user-created resources. Read-only tools (e.g. `read_doc`, `get_doc_blocks`, `list_bitable_tables`) are also UAT-first so user-owned resources remain readable.
|
|
32
33
|
|
|
33
34
|
### Official API Tools (app credentials)
|
|
34
35
|
- `list_chats` / `read_messages` — Chat history (read_messages accepts chat name, oc_ ID, or numeric ID; auto-resolves via bot's group list → im.chat.search → search_contacts). **Auto-falls back to UAT for external groups the bot cannot access.** Returns newest messages first by default. Messages include sender names.
|
|
@@ -51,6 +52,7 @@ All-in-one Feishu plugin for Claude Code with three auth layers:
|
|
|
51
52
|
- `list_files` / `create_folder` — Drive
|
|
52
53
|
- `copy_file` / `move_file` / `delete_file` — Drive file operations (copy, move, delete)
|
|
53
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).
|
|
54
56
|
- `find_user` — Contact lookup by email/mobile
|
|
55
57
|
|
|
56
58
|
## Usage Patterns
|
|
@@ -59,7 +61,9 @@ All-in-one Feishu plugin for Claude Code with three auth layers:
|
|
|
59
61
|
- Send text as yourself → `send_to_user` or `send_to_group`
|
|
60
62
|
- Send image → `upload_image` → `send_image_as_user`
|
|
61
63
|
- Send file → `upload_file` → `send_file_as_user`
|
|
62
|
-
- Send rich content → `send_post_as_user` (formatted text
|
|
64
|
+
- Send rich content → `send_post_as_user` (formatted text + links + real @-mentions via `{tag:"at",userId,name}`)
|
|
65
|
+
- Send text with @-mentions (plain text) → `send_as_user` / `send_to_user` / `send_to_group` with `ats:[{userId,name}]` + text containing `@<name>` markers
|
|
66
|
+
- Bot-identity @-mention alternative → `send_message_as_bot` with `<at user_id="ou_xxx">Name</at>` inline in content text
|
|
63
67
|
- Reply as user in thread → `send_as_user` with root_id
|
|
64
68
|
- Reply as bot → `reply_message` (official API)
|
|
65
69
|
|
|
@@ -230,9 +234,21 @@ Tell user to restart Claude Code. Only ONE restart should be needed.
|
|
|
230
234
|
## Troubleshooting Guide
|
|
231
235
|
|
|
232
236
|
### If MCP disconnects mid-session
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
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.
|
|
236
252
|
|
|
237
253
|
### If MCP tools are not available
|
|
238
254
|
1. Check `~/.claude.json` — config must be in **top-level** `mcpServers`, not inside `projects[*]`
|
|
@@ -310,12 +326,29 @@ NPM_TOKEN is stored as a GitHub repo secret.
|
|
|
310
326
|
|
|
311
327
|
### Syncing to team-skills
|
|
312
328
|
|
|
313
|
-
|
|
329
|
+
**IMPORTANT: team-skills 仓库禁止直接推送 main。所有变更必须走 PR。**
|
|
330
|
+
|
|
331
|
+
team-skills 推送规范:
|
|
332
|
+
1. **创建 feature branch**: `git checkout -b fix/feishu-xxx` 或 `sync/feishu-v1.x.x`
|
|
333
|
+
2. **提交变更并推送 branch**: `git push -u origin <branch-name>`
|
|
334
|
+
3. **创建 PR 并设置 auto-merge**: `gh pr create --title "..." --body "..."` 然后 `gh pr merge <number> --auto --merge`
|
|
335
|
+
4. **CI 通过后自动合并**: validate workflow 检查三方版本一致性,通过即自动 merge,无需手动操作
|
|
336
|
+
5. **如 CI 失败**: 修复后 push 到同一 branch,CI 会重跑,通过后自动合并
|
|
314
337
|
|
|
338
|
+
三方版本一致性规则:
|
|
339
|
+
- `plugins/feishu-user-plugin/.claude-plugin/plugin.json` 的 `version`
|
|
340
|
+
- `plugins/feishu-user-plugin/skills/feishu-user-plugin/SKILL.md` frontmatter 的 `version`
|
|
341
|
+
- `plugins/feishu-user-plugin/README.md` 更新日志里第一个 `### vX.Y.Z` 标题
|
|
342
|
+
- 这三个版本号必须相同,否则 CI 会失败。每次 npm 发包后,team-skills 的版本号也要同步更新。
|
|
343
|
+
|
|
344
|
+
同步内容(每次发版后执行):
|
|
315
345
|
```bash
|
|
316
|
-
#
|
|
317
|
-
cp
|
|
318
|
-
cp
|
|
346
|
+
# 1. 同步 skills + plugin.json
|
|
347
|
+
cp CLAUDE.md skills/feishu-user-plugin/references/CLAUDE.md
|
|
348
|
+
cp -r skills/ /Users/abble/team-skills/plugins/feishu-user-plugin/skills/
|
|
349
|
+
cp .claude-plugin/plugin.json /Users/abble/team-skills/plugins/feishu-user-plugin/.claude-plugin/
|
|
350
|
+
# 2. 手动更新 team-skills 的 README.md(工具数、更新日志)和 SKILL.md(version + allowed-tools)
|
|
351
|
+
# 3. 走 PR 流程推送
|
|
319
352
|
# Do NOT copy .mcp.json — team-skills plugin should not have one
|
|
320
353
|
```
|
|
321
354
|
|
|
@@ -340,11 +373,12 @@ When making ANY code change (new tools, bug fixes, features), update ALL of thes
|
|
|
340
373
|
|
|
341
374
|
**同步命令(每次发版后执行):**
|
|
342
375
|
```bash
|
|
343
|
-
# 1. 同步 skills
|
|
376
|
+
# 1. 同步 skills + plugin.json
|
|
344
377
|
cp CLAUDE.md skills/feishu-user-plugin/references/CLAUDE.md
|
|
345
378
|
cp -r skills/ /Users/abble/team-skills/plugins/feishu-user-plugin/skills/
|
|
346
|
-
|
|
347
|
-
#
|
|
379
|
+
cp .claude-plugin/plugin.json /Users/abble/team-skills/plugins/feishu-user-plugin/.claude-plugin/
|
|
380
|
+
# 2. 手动更新 team-skills README(工具数、功能列表、更新日志)+ SKILL.md(version + allowed-tools)
|
|
381
|
+
# 3. 走 PR 流程推送 team-skills(禁止直接推 main)
|
|
348
382
|
```
|
|
349
383
|
|
|
350
384
|
### Keeping ROADMAP.md up to date
|
|
@@ -392,7 +426,8 @@ Steps:
|
|
|
392
426
|
1. Copy CLAUDE.md to skill reference: `cp CLAUDE.md skills/feishu-user-plugin/references/CLAUDE.md`
|
|
393
427
|
2. Sync to team-skills repo: `cp -r skills/ /Users/abble/team-skills/plugins/feishu-user-plugin/skills/`
|
|
394
428
|
3. Also sync plugin.json: `cp .claude-plugin/plugin.json /Users/abble/team-skills/plugins/feishu-user-plugin/.claude-plugin/`
|
|
395
|
-
4.
|
|
429
|
+
4. Update SKILL.md version + allowed-tools, README.md changelog + tool count
|
|
430
|
+
5. **走 PR 流程**(创建 branch → push → PR → 等 CI 通过 → merge),禁止直接推 main
|
|
396
431
|
|
|
397
432
|
### Testing a tool
|
|
398
433
|
- For Official API tools: can test directly via MCP tool call or standalone script using `readCredentials()` from `src/config.js`
|
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,
|
|
@@ -200,14 +200,65 @@ class LarkUserClient {
|
|
|
200
200
|
|
|
201
201
|
// --- Send Text Message ---
|
|
202
202
|
|
|
203
|
+
// Supports inline @mentions via the `ats` param:
|
|
204
|
+
// ats: [{ userId: 'ou_xxx', name: 'Alice' }]
|
|
205
|
+
// The text should contain the mention markers (defaults to `@Alice` substrings,
|
|
206
|
+
// matched in order). If `text` already contains the @Name substrings, they're
|
|
207
|
+
// found in order and spliced into rich-text AT elements.
|
|
203
208
|
async sendMessage(chatId, text, opts = {}) {
|
|
204
|
-
const
|
|
205
|
-
|
|
209
|
+
const { ats } = opts;
|
|
210
|
+
if (!Array.isArray(ats) || ats.length === 0) {
|
|
211
|
+
// Fast path: plain text, single TEXT element.
|
|
212
|
+
const elemId = generateCid();
|
|
213
|
+
const textPropBuf = this._encode('TextProperty', { content: text });
|
|
214
|
+
return this._sendMsg(MsgType.TEXT, chatId, {
|
|
215
|
+
richText: {
|
|
216
|
+
elementIds: [elemId],
|
|
217
|
+
innerText: text,
|
|
218
|
+
elements: { dictionary: { [elemId]: { tag: 1, property: textPropBuf } } },
|
|
219
|
+
},
|
|
220
|
+
}, opts);
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
// Build rich-text segments: split `text` by each at's display marker and
|
|
224
|
+
// weave AT elements in between text elements. Each `ats[i]` is consumed
|
|
225
|
+
// in order from the remaining text.
|
|
226
|
+
const elementIds = [];
|
|
227
|
+
const atIds = [];
|
|
228
|
+
const dictionary = {};
|
|
229
|
+
let remaining = text;
|
|
230
|
+
for (const at of ats) {
|
|
231
|
+
if (!at.userId) throw new Error('sendMessage: each at entry requires userId');
|
|
232
|
+
const display = at.marker || (at.name ? '@' + at.name : '@' + at.userId);
|
|
233
|
+
const idx = remaining.indexOf(display);
|
|
234
|
+
if (idx === -1) throw new Error(`sendMessage: marker "${display}" not found in text`);
|
|
235
|
+
const before = remaining.slice(0, idx);
|
|
236
|
+
if (before) {
|
|
237
|
+
const id = generateCid();
|
|
238
|
+
elementIds.push(id);
|
|
239
|
+
dictionary[id] = { tag: 1, property: this._encode('TextProperty', { content: before }) };
|
|
240
|
+
}
|
|
241
|
+
const atId = generateCid();
|
|
242
|
+
elementIds.push(atId);
|
|
243
|
+
atIds.push(atId);
|
|
244
|
+
dictionary[atId] = {
|
|
245
|
+
tag: 5,
|
|
246
|
+
property: this._encode('AtProperty', { userId: at.userId, content: display }),
|
|
247
|
+
};
|
|
248
|
+
remaining = remaining.slice(idx + display.length);
|
|
249
|
+
}
|
|
250
|
+
if (remaining) {
|
|
251
|
+
const id = generateCid();
|
|
252
|
+
elementIds.push(id);
|
|
253
|
+
dictionary[id] = { tag: 1, property: this._encode('TextProperty', { content: remaining }) };
|
|
254
|
+
}
|
|
255
|
+
|
|
206
256
|
return this._sendMsg(MsgType.TEXT, chatId, {
|
|
207
257
|
richText: {
|
|
208
|
-
elementIds
|
|
258
|
+
elementIds,
|
|
209
259
|
innerText: text,
|
|
210
|
-
elements: { dictionary
|
|
260
|
+
elements: { dictionary },
|
|
261
|
+
atIds,
|
|
211
262
|
},
|
|
212
263
|
}, opts);
|
|
213
264
|
}
|
|
@@ -240,26 +291,43 @@ class LarkUserClient {
|
|
|
240
291
|
|
|
241
292
|
async sendPost(chatId, title, paragraphs, opts = {}) {
|
|
242
293
|
const elementIds = [];
|
|
294
|
+
const atIds = [];
|
|
295
|
+
const anchorIds = [];
|
|
243
296
|
const dictionary = {};
|
|
297
|
+
const paraTexts = [];
|
|
244
298
|
|
|
245
299
|
for (let i = 0; i < paragraphs.length; i++) {
|
|
246
300
|
const para = paragraphs[i];
|
|
301
|
+
const paraTextParts = [];
|
|
247
302
|
for (const elem of para) {
|
|
248
303
|
const elemId = generateCid();
|
|
249
304
|
elementIds.push(elemId);
|
|
250
305
|
|
|
251
306
|
if (elem.tag === 'text') {
|
|
252
|
-
const
|
|
307
|
+
const t = elem.text || '';
|
|
308
|
+
const propBuf = this._encode('TextProperty', { content: t });
|
|
253
309
|
dictionary[elemId] = { tag: 1, property: propBuf };
|
|
310
|
+
paraTextParts.push(t);
|
|
254
311
|
} else if (elem.tag === 'at') {
|
|
255
|
-
|
|
312
|
+
if (!elem.userId) throw new Error('sendPost: {tag:"at"} requires userId');
|
|
313
|
+
const displayName = elem.name || elem.userName || elem.text || elem.userId;
|
|
314
|
+
const display = displayName.startsWith('@') ? displayName : `@${displayName}`;
|
|
315
|
+
const propBuf = this._encode('AtProperty', { userId: elem.userId, content: display });
|
|
256
316
|
dictionary[elemId] = { tag: 5, property: propBuf };
|
|
317
|
+
atIds.push(elemId);
|
|
318
|
+
paraTextParts.push(display);
|
|
257
319
|
} else if (elem.tag === 'a') {
|
|
258
|
-
|
|
259
|
-
const
|
|
320
|
+
const href = elem.href || '';
|
|
321
|
+
const label = elem.text || href;
|
|
322
|
+
const propBuf = this._encode('AnchorProperty', { href, content: label, textContent: label });
|
|
260
323
|
dictionary[elemId] = { tag: 6, property: propBuf };
|
|
324
|
+
anchorIds.push(elemId);
|
|
325
|
+
paraTextParts.push(label);
|
|
326
|
+
} else {
|
|
327
|
+
throw new Error(`sendPost: unknown element tag "${elem.tag}" (supported: text, at, a)`);
|
|
261
328
|
}
|
|
262
329
|
}
|
|
330
|
+
paraTexts.push(paraTextParts.join(''));
|
|
263
331
|
// Insert newline element between paragraphs
|
|
264
332
|
if (i < paragraphs.length - 1) {
|
|
265
333
|
const nlId = generateCid();
|
|
@@ -269,10 +337,13 @@ class LarkUserClient {
|
|
|
269
337
|
}
|
|
270
338
|
}
|
|
271
339
|
|
|
272
|
-
const innerText =
|
|
340
|
+
const innerText = paraTexts.join('\n');
|
|
341
|
+
const richText = { elementIds, innerText, elements: { dictionary } };
|
|
342
|
+
if (atIds.length > 0) richText.atIds = atIds;
|
|
343
|
+
if (anchorIds.length > 0) richText.anchorIds = anchorIds;
|
|
273
344
|
return this._sendMsg(MsgType.POST, chatId, {
|
|
274
345
|
title: title || '',
|
|
275
|
-
richText
|
|
346
|
+
richText,
|
|
276
347
|
}, opts);
|
|
277
348
|
}
|
|
278
349
|
|