feishu-user-plugin 1.3.0 → 1.3.2

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.
@@ -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 (76 tools)
9
+ ## Tool Categories (66 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
 
@@ -25,9 +26,10 @@ All-in-one Feishu plugin for Claude Code with three auth layers:
25
26
  - `get_user_info` — User display name lookup (official API first, cookie cache fallback)
26
27
  - `get_login_status` — Check cookie, app, and UAT status
27
28
 
28
- ### User OAuth UAT Tools (P2P chat reading)
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`.
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.
31
33
 
32
34
  ### Official API Tools (app credentials)
33
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.
@@ -35,26 +37,22 @@ All-in-one Feishu plugin for Claude Code with three auth layers:
35
37
  - `reply_message` / `forward_message` — Message operations (as bot)
36
38
  - `delete_message` / `update_message` — Recall or edit bot's own messages
37
39
  - `add_reaction` / `delete_reaction` — Emoji reactions on messages
38
- - `pin_message` / `unpin_message` Pin/unpin messages in chat
40
+ - `pin_message` Pin or unpin a message (pinned=true/false)
39
41
  - `create_group` / `update_group` — Create and manage group chats
40
- - `list_members` / `add_members` / `remove_members` — Group membership management
42
+ - `list_members` / `manage_members` — Group membership (manage_members: action=add/remove)
41
43
  - `search_docs` / `read_doc` / `get_doc_blocks` / `create_doc` — Document operations
42
44
  - `create_doc_block` / `update_doc_block` / `delete_doc_blocks` — Document content editing (insert/update/delete blocks)
43
- - `create_bitable` Create a new Bitable (multi-dimensional table) app
44
- - `list_bitable_tables` / `create_bitable_table` — Table management
45
+ - `create_bitable` / `get_bitable_meta` / `copy_bitable` Bitable app management (create, get info, copy)
46
+ - `list_bitable_tables` / `create_bitable_table` / `update_bitable_table` / `delete_bitable_table` — Table management (CRUD + rename)
45
47
  - `list_bitable_fields` / `create_bitable_field` / `update_bitable_field` / `delete_bitable_field` — Field (column) management
46
- - `list_bitable_views` — List views in a table
47
- - `search_bitable_records` — Query records with filter/sort
48
- - `create_bitable_record` / `update_bitable_record` / `delete_bitable_record` — Single record CRUD
49
- - `batch_create_bitable_records` / `batch_update_bitable_records` / `batch_delete_bitable_records` — Batch operations (max 500/call)
48
+ - `list_bitable_views` / `create_bitable_view` / `delete_bitable_view` View management (grid, kanban, gallery, form, gantt, calendar)
49
+ - `search_bitable_records` / `get_bitable_record` — Query records
50
+ - `batch_create_bitable_records` / `batch_update_bitable_records` / `batch_delete_bitable_records` — Record CRUD (single or batch, max 500/call)
50
51
  - `list_wiki_spaces` / `search_wiki` / `list_wiki_nodes` — Wiki
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
54
55
  - `find_user` — Contact lookup by email/mobile
55
- - `list_calendars` / `create_calendar_event` / `list_calendar_events` / `delete_calendar_event` — Calendar management
56
- - `get_freebusy` — Check user availability
57
- - `create_task` / `get_task` / `list_tasks` / `update_task` / `complete_task` — Task management
58
56
 
59
57
  ## Usage Patterns
60
58
 
@@ -62,7 +60,9 @@ All-in-one Feishu plugin for Claude Code with three auth layers:
62
60
  - Send text as yourself → `send_to_user` or `send_to_group`
63
61
  - Send image → `upload_image` → `send_image_as_user`
64
62
  - Send file → `upload_file` → `send_file_as_user`
65
- - Send rich content → `send_post_as_user` (formatted text with links, @mentions)
63
+ - Send rich content → `send_post_as_user` (formatted text + links + real @-mentions via `{tag:"at",userId,name}`)
64
+ - Send text with @-mentions (plain text) → `send_as_user` / `send_to_user` / `send_to_group` with `ats:[{userId,name}]` + text containing `@<name>` markers
65
+ - Bot-identity @-mention alternative → `send_message_as_bot` with `<at user_id="ou_xxx">Name</at>` inline in content text
66
66
  - Reply as user in thread → `send_as_user` with root_id
67
67
  - Reply as bot → `reply_message` (official API)
68
68
 
@@ -73,14 +73,18 @@ All-in-one Feishu plugin for Claude Code with three auth layers:
73
73
 
74
74
  ### Bitable (Multi-dimensional Tables)
75
75
  - Create a bitable from scratch → `create_bitable` → `create_bitable_table` → `create_bitable_field`
76
+ - Get bitable info → `get_bitable_meta`
77
+ - Copy a bitable → `copy_bitable` with name and optional folder
76
78
  - Query data → `list_bitable_tables` → `list_bitable_fields` → `search_bitable_records`
77
- - Single record CRUD → `create_bitable_record` / `update_bitable_record` / `delete_bitable_record`
78
- - Bulk operations → `batch_create_bitable_records` / `batch_update_bitable_records` / `batch_delete_bitable_records` (max 500/call)
79
+ - Rename table → `update_bitable_table` with new name
80
+ - Read single record → `get_bitable_record`
81
+ - Create/update/delete records → `batch_create_bitable_records` / `batch_update_bitable_records` / `batch_delete_bitable_records` (works for single or up to 500)
79
82
  - Manage fields → `create_bitable_field` / `update_bitable_field` (requires type param) / `delete_bitable_field`
83
+ - Manage views → `create_bitable_view` (type: grid/kanban/gallery/form/gantt/calendar) / `delete_bitable_view`
80
84
 
81
85
  ### Group Management
82
86
  - Create a group → `create_group` with name and optional member open_ids
83
- - Add/remove members → `add_members` / `remove_members` with chat_id + user open_ids
87
+ - Add/remove members → `manage_members` with chat_id + member_ids + action (add/remove)
84
88
  - List members → `list_members`
85
89
 
86
90
  ### Document Editing
@@ -88,15 +92,6 @@ All-in-one Feishu plugin for Claude Code with three auth layers:
88
92
  - Edit existing block → `get_doc_blocks` to find block_id → `update_doc_block`
89
93
  - Delete blocks → `delete_doc_blocks` with start/end index range
90
94
 
91
- ### Calendar
92
- - View schedule → `list_calendars` → `list_calendar_events`
93
- - Create event → `create_calendar_event` with calendar_id, summary, start/end time
94
- - Check availability → `get_freebusy` with user open_ids and time range
95
-
96
- ### Tasks
97
- - Create task → `create_task` with summary, optional description/due
98
- - Track tasks → `list_tasks` → `update_task` / `complete_task`
99
-
100
95
  ### Diagnostics
101
96
  - Diagnose issues → `get_login_status` first
102
97
 
@@ -237,10 +232,16 @@ Tell user to restart Claude Code. Only ONE restart should be needed.
237
232
 
238
233
  ## Troubleshooting Guide
239
234
 
235
+ ### If MCP disconnects mid-session
236
+ - **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.
237
+ - **Fix**: Custom logger redirects all SDK output to stderr. Already applied in `src/official.js`.
238
+ - If still happening: check for any `console.log` calls in server code (only `console.error` is safe in MCP servers).
239
+
240
240
  ### If MCP tools are not available
241
241
  1. Check `~/.claude.json` — config must be in **top-level** `mcpServers`, not inside `projects[*]`
242
- 2. Restart Claude Code after config changes
243
- 3. After restart, tools may take a few seconds to register — if first call fails with "No such tool", wait and retry once
242
+ 2. For Codex: check `~/.codex/config.toml` has `[mcp_servers.feishu-user-plugin]` section
243
+ 3. Restart Claude Code / Codex after config changes
244
+ 4. After restart, tools may take a few seconds to register — if first call fails with "No such tool", wait and retry once
244
245
 
245
246
  ### If cookie authentication fails
246
247
  - `document.cookie` in browser console CANNOT access HttpOnly cookies (`session`, `sl_session`)
@@ -282,9 +283,17 @@ Tell user to restart Claude Code. Only ONE restart should be needed.
282
283
  - **team-skills plugin**: Skills + CLAUDE.md only (no .mcp.json). For internal team members.
283
284
 
284
285
  ### Config management
285
- - `src/config.js`: Unified config module. Discovers config in `~/.claude.json` (top-level + project-level) and `.mcp.json`.
286
- - `setup` always writes to `~/.claude.json` top-level `mcpServers` (global).
287
- - `persistToConfig()` finds the correct config entry and writes back (used by heartbeat + UAT refresh).
286
+ - `src/config.js`: Unified config module. Discovers config in `~/.claude.json` (top-level + project-level), `.mcp.json`, and `~/.codex/config.toml`.
287
+ - `setup` writes to `~/.claude.json` (default) or `~/.codex/config.toml` (with `--client codex`), or both (`--client both`).
288
+ - `persistToConfig()` finds the correct config entry and writes back atomically (used by heartbeat + UAT refresh).
289
+ - All config writes use atomic write (tmp file + rename) to prevent race conditions with Claude Code.
290
+
291
+ ### Multi-client support
292
+ - **Claude Code**: JSON config in `~/.claude.json` mcpServers
293
+ - **Codex**: TOML config in `~/.codex/config.toml` mcp_servers
294
+ - Setup: `npx feishu-user-plugin setup --client codex` or `--client both`
295
+ - MCP server code is identical for both clients — only config format differs
296
+ - Codex does not support Claude Code slash commands (skills) — only MCP tools are available
288
297
 
289
298
  ## Development & Publishing
290
299
 
@@ -304,17 +313,61 @@ NPM_TOKEN is stored as a GitHub repo secret.
304
313
 
305
314
  ### Syncing to team-skills
306
315
 
307
- After publishing, sync plugin assets to team-skills:
316
+ **IMPORTANT: team-skills 仓库禁止直接推送 main。所有变更必须走 PR。**
317
+
318
+ team-skills 推送规范:
319
+ 1. **创建 feature branch**: `git checkout -b fix/feishu-xxx` 或 `sync/feishu-v1.x.x`
320
+ 2. **提交变更并推送 branch**: `git push -u origin <branch-name>`
321
+ 3. **创建 PR 并设置 auto-merge**: `gh pr create --title "..." --body "..."` 然后 `gh pr merge <number> --auto --merge`
322
+ 4. **CI 通过后自动合并**: validate workflow 检查三方版本一致性,通过即自动 merge,无需手动操作
323
+ 5. **如 CI 失败**: 修复后 push 到同一 branch,CI 会重跑,通过后自动合并
308
324
 
325
+ 三方版本一致性规则:
326
+ - `plugins/feishu-user-plugin/.claude-plugin/plugin.json` 的 `version`
327
+ - `plugins/feishu-user-plugin/skills/feishu-user-plugin/SKILL.md` frontmatter 的 `version`
328
+ - `plugins/feishu-user-plugin/README.md` 更新日志里第一个 `### vX.Y.Z` 标题
329
+ - 这三个版本号必须相同,否则 CI 会失败。每次 npm 发包后,team-skills 的版本号也要同步更新。
330
+
331
+ 同步内容(每次发版后执行):
309
332
  ```bash
310
- # From the feishu-user-plugin repo:
311
- cp -r skills/ /path/to/team-skills/plugins/feishu-user-plugin/skills/
312
- cp .claude-plugin/plugin.json /path/to/team-skills/plugins/feishu-user-plugin/.claude-plugin/
333
+ # 1. 同步 skills + plugin.json
334
+ cp CLAUDE.md skills/feishu-user-plugin/references/CLAUDE.md
335
+ cp -r skills/ /Users/abble/team-skills/plugins/feishu-user-plugin/skills/
336
+ cp .claude-plugin/plugin.json /Users/abble/team-skills/plugins/feishu-user-plugin/.claude-plugin/
337
+ # 2. 手动更新 team-skills 的 README.md(工具数、更新日志)和 SKILL.md(version + allowed-tools)
338
+ # 3. 走 PR 流程推送
313
339
  # Do NOT copy .mcp.json — team-skills plugin should not have one
314
340
  ```
315
341
 
316
342
  ## Development Workflow
317
343
 
344
+ ### Keeping all docs in sync
345
+ When making ANY code change (new tools, bug fixes, features), update ALL of these:
346
+
347
+ **本仓库内:**
348
+ - `CLAUDE.md` — tool count, tool list, usage patterns, known limitations
349
+ - `README.md` — tool count (badge + heading + tool table), feature highlights, OpenClaw/Claude Code config examples
350
+ - `ROADMAP.md` — check off completed items, add new findings
351
+ - `package.json` — version, description (tool count)
352
+ - `skills/feishu-user-plugin/references/CLAUDE.md` — always copy from root: `cp CLAUDE.md skills/feishu-user-plugin/references/CLAUDE.md`
353
+ - `prompts/openclaw-setup.md` — if OpenClaw 相关配置变了要更新
354
+
355
+ **team-skills 仓库 (`/Users/abble/team-skills/plugins/feishu-user-plugin/`):**
356
+ - `skills/` — 同步技能文件: `cp -r skills/ /Users/abble/team-skills/plugins/feishu-user-plugin/skills/`
357
+ - `README.md` — team-skills 有自己的 README(含团队 APP_ID/SECRET),需要同步更新:工具数量、功能列表、更新日志、安装 prompt
358
+ - 两个 README 都必须包含 Claude Code 安装 prompt 和 OpenClaw 安装 prompt
359
+ - team-skills README 的安装 prompt 包含团队共享的 APP_ID/SECRET(hardcoded),本仓库 README 用占位符
360
+
361
+ **同步命令(每次发版后执行):**
362
+ ```bash
363
+ # 1. 同步 skills + plugin.json
364
+ cp CLAUDE.md skills/feishu-user-plugin/references/CLAUDE.md
365
+ cp -r skills/ /Users/abble/team-skills/plugins/feishu-user-plugin/skills/
366
+ cp .claude-plugin/plugin.json /Users/abble/team-skills/plugins/feishu-user-plugin/.claude-plugin/
367
+ # 2. 手动更新 team-skills README(工具数、功能列表、更新日志)+ SKILL.md(version + allowed-tools)
368
+ # 3. 走 PR 流程推送 team-skills(禁止直接推 main)
369
+ ```
370
+
318
371
  ### Keeping ROADMAP.md up to date
319
372
  - When completing a feature or fixing a bug, check the corresponding item in ROADMAP.md as `[x]` done
320
373
  - When discovering new bugs, limitations, or feature ideas during development, add them to the appropriate section in ROADMAP.md
@@ -341,16 +394,27 @@ cp .claude-plugin/plugin.json /path/to/team-skills/plugins/feishu-user-plugin/.c
341
394
  - `chore:` dependencies, CI, config changes
342
395
 
343
396
  ### Publishing
344
- 1. Update `version` in `package.json`
345
- 2. `git add <files> && git commit -m "v1.x.x: description"`
346
- 3. `git tag v1.x.x && git push && git push --tags`
347
- 4. GitHub Actions auto-publishes to npm. Users get the new version on next Claude Code restart.
397
+ **IMPORTANT: Version number must ALWAYS be confirmed with the user before publishing.**
398
+ Any operation involving `npm version`, modifying `package.json` version, `git tag v*`, or `git push --tags` requires explicit user confirmation of the target version number. Do not auto-decide version numbers.
399
+
400
+ Three-layer version safety:
401
+ 1. **Claude rule** (this section): Ask user to confirm version before any publish-related operation
402
+ 2. **Local gate** (`prepublishOnly`): Interactive confirmation when running `npm publish` locally (skipped in CI)
403
+ 3. **CI gate** (`.github/workflows/publish.yml`): Tag must match `package.json` version or publish fails
404
+
405
+ Steps:
406
+ 1. Confirm target version with user
407
+ 2. Update `version` in `package.json`
408
+ 3. `git add <files> && git commit -m "v1.x.x: description"`
409
+ 4. `git tag v1.x.x && git push && git push --tags`
410
+ 5. GitHub Actions verifies tag matches package.json, then auto-publishes to npm
348
411
 
349
412
  ### Syncing to team-skills (after any CLAUDE.md or skills change)
350
413
  1. Copy CLAUDE.md to skill reference: `cp CLAUDE.md skills/feishu-user-plugin/references/CLAUDE.md`
351
414
  2. Sync to team-skills repo: `cp -r skills/ /Users/abble/team-skills/plugins/feishu-user-plugin/skills/`
352
415
  3. Also sync plugin.json: `cp .claude-plugin/plugin.json /Users/abble/team-skills/plugins/feishu-user-plugin/.claude-plugin/`
353
- 4. Commit and push both repos
416
+ 4. Update SKILL.md version + allowed-tools, README.md changelog + tool count
417
+ 5. **走 PR 流程**(创建 branch → push → PR → 等 CI 通过 → merge),禁止直接推 main
354
418
 
355
419
  ### Testing a tool
356
420
  - For Official API tools: can test directly via MCP tool call or standalone script using `readCredentials()` from `src/config.js`
package/src/cli.js CHANGED
@@ -41,23 +41,28 @@ function printHelp() {
41
41
  feishu-user-plugin — All-in-one Feishu MCP Server
42
42
 
43
43
  Commands:
44
- (default) Start MCP server (used by Claude Code)
44
+ (default) Start MCP server (used by Claude Code / Codex)
45
45
  setup Interactive setup wizard — writes MCP config
46
46
  oauth Run OAuth flow to obtain user_access_token
47
47
  status Check authentication status
48
48
  keepalive Refresh cookie + UAT to prevent expiration (for cron jobs)
49
49
  help Show this help
50
50
 
51
- Quick Start (team members):
51
+ Setup options:
52
+ --app-id <id> App ID (non-interactive mode)
53
+ --app-secret <s> App Secret (non-interactive mode)
54
+ --cookie <c> Cookie string (optional)
55
+ --client <target> Config target: claude (default), codex, or both
56
+
57
+ Quick Start (Claude Code):
52
58
  1. npx feishu-user-plugin setup
53
59
  2. Follow the prompts to configure credentials
54
60
  3. Restart Claude Code
55
61
 
56
- Quick Start (external users):
57
- 1. Create a Feishu app at https://open.feishu.cn/app
58
- 2. npx feishu-user-plugin setup
59
- 3. npx feishu-user-plugin oauth
60
- 4. Restart Claude Code
62
+ Quick Start (Codex):
63
+ 1. npx feishu-user-plugin setup --client codex
64
+ 2. Follow the prompts to configure credentials
65
+ 3. Restart Codex
61
66
 
62
67
  Auto-renewal (optional):
63
68
  Add to crontab to keep tokens alive even when Claude Code is closed:
package/src/client.js CHANGED
@@ -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