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.
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "feishu-user-plugin",
3
- "version": "1.2.0",
4
- "description": "All-in-one Feishu plugin for Claude Code — send messages as yourself, read chats, manage docs/tables/wiki. 33 tools + 9 skills, 3 auth layers.",
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: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](LICENSE)
4
4
  [![Node.js](https://img.shields.io/badge/Node.js-%3E%3D18-green.svg)](https://nodejs.org)
5
5
  [![MCP](https://img.shields.io/badge/MCP-Compatible-purple.svg)](https://modelcontextprotocol.io)
6
- [![Tools](https://img.shields.io/badge/Tools-76-orange.svg)](#tools)
6
+ [![Tools](https://img.shields.io/badge/Tools-67-orange.svg)](#tools)
7
7
  [![PRs Welcome](https://img.shields.io/badge/PRs-welcome-brightgreen.svg)](CONTRIBUTING.md)
8
8
 
9
- **All-in-one Feishu/Lark MCP Server -- 76 tools, 9 skills, 3 auth layers for messaging, docs, bitable, calendar, tasks, drive, and more.**
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 (76 total)
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 (76 tools)
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.1",
4
- "description": "All-in-one Feishu plugin for Claude Code & Codex — messaging, docs, bitable, wiki, drive. 66 tools + 9 skills, 3 auth layers.",
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.1.3"
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 (66 tools)
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
- - `create_doc` / `create_bitable` / `create_folder` **UAT-first**: creates resources as the user (not the app) when UAT with write scopes is available. Falls back to app token.
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 with links, @mentions)
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
- - **Root cause** (fixed in v1.3.1): `@larksuiteoapi/node-sdk`'s `defaultLogger.error` uses `console.log` (stdout). MCP protocol uses stdout for JSON-RPC, so SDK error logs corrupt the transport and cause immediate disconnect.
234
- - **Fix**: Custom logger redirects all SDK output to stderr. Already applied in `src/official.js`.
235
- - If still happening: check for any `console.log` calls in server code (only `console.error` is safe in MCP servers).
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
- After publishing, sync plugin assets to team-skills:
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
- # From the feishu-user-plugin repo:
317
- cp -r skills/ /path/to/team-skills/plugins/feishu-user-plugin/skills/
318
- cp .claude-plugin/plugin.json /path/to/team-skills/plugins/feishu-user-plugin/.claude-plugin/
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
- # 2. 手动更新 team-skills README(工具数、功能列表、更新日志)
347
- # 3. 提交并推送两个仓库
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. Commit and push both repos
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 fetch(`${CSRF_URL}?_t=${Date.now()}`, {
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 fetch(`${USER_INFO_URL}?app_id=12&_t=${Date.now()}`, {
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 fetch(GATEWAY_URL, {
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 elemId = generateCid();
205
- const textPropBuf = this._encode('TextProperty', { content: text });
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: [elemId],
258
+ elementIds,
209
259
  innerText: text,
210
- elements: { dictionary: { [elemId]: { tag: 1, property: textPropBuf } } },
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 propBuf = this._encode('TextProperty', { content: elem.text });
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
- const propBuf = this._encode('TextProperty', { content: elem.userId });
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
- // Link element: content stores the URL, display text goes through innerText
259
- const propBuf = this._encode('TextProperty', { content: elem.href || elem.text || '' });
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 = paragraphs.map(p => p.map(e => e.text || '').join('')).join('\n');
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: { elementIds, innerText, elements: { dictionary } },
346
+ richText,
276
347
  }, opts);
277
348
  }
278
349