feishu-user-plugin 1.3.7 → 1.3.9

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.
Files changed (54) hide show
  1. package/.claude-plugin/plugin.json +13 -3
  2. package/CHANGELOG.md +87 -0
  3. package/README.md +20 -4
  4. package/package.json +10 -6
  5. package/proto/lark.proto +10 -0
  6. package/scripts/capture-feishu-protobuf.js +86 -0
  7. package/scripts/check-changelog.js +31 -0
  8. package/scripts/check-docs-sync.js +41 -0
  9. package/scripts/check-tool-count.js +32 -7
  10. package/scripts/decode-feishu-protobuf.js +115 -0
  11. package/scripts/explore-card-protobuf.js +144 -0
  12. package/scripts/explore-image-minimize.js +163 -0
  13. package/scripts/generate-release-artifacts.js +318 -0
  14. package/scripts/probe-feishu-docx.js +203 -0
  15. package/scripts/sync-server-json.js +71 -0
  16. package/scripts/sync-team-skills.sh +109 -7
  17. package/scripts/test-wiki-attach-fallback.js +71 -0
  18. package/scripts/test-ws-events.js +84 -0
  19. package/skills/feishu-user-plugin/SKILL.md +77 -5
  20. package/skills/feishu-user-plugin/references/CLAUDE.md +208 -297
  21. package/src/auth/cookie.js +30 -0
  22. package/src/auth/credentials.js +85 -0
  23. package/src/auth/profile-router.js +248 -0
  24. package/src/auth/uat.js +231 -0
  25. package/src/cli.js +86 -42
  26. package/src/clients/official/base.js +12 -248
  27. package/src/clients/user.js +19 -31
  28. package/src/config.js +13 -8
  29. package/src/events/cursor.js +103 -0
  30. package/src/events/event-buffer.js +103 -0
  31. package/src/events/event-log.js +151 -0
  32. package/src/events/index.js +12 -0
  33. package/src/events/lockfile.js +126 -0
  34. package/src/events/owner.js +73 -0
  35. package/src/events/ws-server.js +156 -0
  36. package/src/oauth.js +48 -7
  37. package/src/resolver.js +10 -0
  38. package/src/server.js +285 -3
  39. package/src/setup.js +100 -11
  40. package/src/test-all.js +12 -9
  41. package/src/test-events-cursor.js +56 -0
  42. package/src/test-events-lockfile.js +36 -0
  43. package/src/test-events-log.js +67 -0
  44. package/src/test-events-owner.js +64 -0
  45. package/src/test-fixtures/doc-blocks/sample-1.json +1256 -0
  46. package/src/test-read-doc-markdown.js +61 -0
  47. package/src/test-switch-profile.js +171 -0
  48. package/src/tools/_registry.js +1 -0
  49. package/src/tools/diagnostics.js +10 -3
  50. package/src/tools/docs.js +93 -3
  51. package/src/tools/events.js +174 -0
  52. package/src/tools/messaging-bot.js +2 -3
  53. package/src/tools/messaging-user.js +23 -14
  54. package/src/tools/profile.js +43 -7
@@ -1,9 +1,19 @@
1
1
  {
2
2
  "name": "feishu-user-plugin",
3
- "version": "1.3.7",
4
- "description": "All-in-one Feishu plugin for Claude Code — send messages as yourself, read chats (auto-expanded merge_forward), manage docs / bitable / wiki (full CRUD) / drive / OKR (with progress writes) / calendar (read+write) / Tasks v2 / multi-profile. 80 tools + 9 prompts, 3 auth layers.",
3
+ "version": "1.3.9",
4
+ "description": "All-in-one Feishu plugin for Claude Code — send messages as yourself, read chats (auto-expanded merge_forward), manage docs / bitable / wiki (full CRUD) / drive / OKR (with progress writes) / calendar (read+write) / Tasks v2 / multi-profile auto-switch / real-time WS events. 82 tools + 9 prompts, 3 auth layers.",
5
5
  "author": {
6
6
  "name": "EthanQC"
7
7
  },
8
- "keywords": ["feishu", "lark", "claude-code", "plugin", "messaging", "im", "document", "bitable", "protobuf"]
8
+ "keywords": [
9
+ "feishu",
10
+ "lark",
11
+ "claude-code",
12
+ "plugin",
13
+ "messaging",
14
+ "im",
15
+ "document",
16
+ "bitable",
17
+ "protobuf"
18
+ ]
9
19
  }
package/CHANGELOG.md CHANGED
@@ -4,6 +4,93 @@ 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.9] - 2026-05-08
8
+
9
+ D 系列首项 ship:新增 `read_doc_markdown` 工具,用 `feishu-docx` 把 docx blocks 转换为 markdown 字符串输出,替代 `get_doc_blocks` 的结构化 JSON,给 RAG / digest / 摘要类调用省 ~60% token(实测 216 KB JSON vs 90 KB markdown)。A 系列主线 ship:WS 机器级 SSOT + active profile 跨进程同步 + setup CLI 4 行决策矩阵 + per-profile events 字段。工具数 83 → 84。
10
+
11
+ ### Added
12
+ - **`manage_ws_status(action=info|reconnect|claim|rotate|reconfig)` (A.1)**:5-action 工具,`info` 给 owner / WS / log / cursor / config 状态 dump;`reconnect` / `rotate` / `reconfig` 是 owner-only;`claim` 接管 stale lock,`force=true` 强夺活跃 owner。`rotate` 触发 `events.jsonl` → `events.jsonl.old` 轮转。`reconfig` 重读 `credentials.json::profiles[active].events` 并重新注册事件类型,无需重启。
13
+ - **WS 机器级 SSOT (A.1)**:单 owner 进程持 `~/.feishu-user-plugin/ws-owner.lock`(O_CREAT|O_EXCL,30 s stale),事件写 `events.jsonl`(append-only,10 MB soft / 20 MB hard cap,超限 rotate 成 `.old`),全局共享 `events.cursor.json` 保护以 cursor-specific lock 避免并发 drain 重读。多 harness 同事件不再重复;owner 死亡 / 锁过期后下一个 MCP 进程自动接管,event log 不丢。
14
+ - **Active profile 跨进程同步 (A.2)**:dispatcher 入口 stat `credentials.json` mtime;变化时重新读 `active`,与 in-memory `currentProfile` 不同即触发 `setActiveProfile`(invalidate `userClient` / `officialClient` 缓存)。成本 ~10μs/call(macOS stat)。`FEISHU_PLUGIN_PROFILE` env 退化为 bootstrap-only;`credentials.json::active` 为唯一跨进程权威来源。
15
+ - **setup CLI 4 行决策矩阵 (A.3)**:非交互模式自动判断 `fresh` / `auto-migrate` / `preserve` / `update` 四种路径;新增 `--force` flag 强制重写 + `--profile <name>` 指定目标 profile。`credentials.json` 已存在时默认 `--pointer-only`;首次安装自动 migrate,harness env 只写 `FEISHU_PLUGIN_PROFILE=default`,消除多 harness token diverge。
16
+ - **per-profile events 字段 (A.4)**:`credentials.json::profiles[*].events` 可选数组,缺省 `["im.message.receive_v1"]`;支持 `approval.instance.created_v4` / `calendar.calendar.event.changed_v4` 等。编辑后调 `manage_ws_status(action=reconfig)` 立即生效,不需重启。`FEISHU_PLUGIN_EXTRA_EVENTS` env 仅在首次 bootstrap 时写入,不覆盖已有字段。
17
+ - **`read_doc_markdown(document_id)` (D)**:返回 markdown 字符串而非结构化 JSON,省 ~60% token;依赖 `feishu-docx@^0.7.0`,后处理器 `_normaliseEmbeds` 位于 `src/tools/docs.js`。嵌入图片 / 文件以 `feishu://image_token/<TOKEN>` / `feishu://file_token/<TOKEN>` 占位符形式保留,配合 `download_doc_image` 取二进制内容。`document_id` 同样接受原生 token / wiki node token / 飞书 URL,分辨率逻辑与其它 doc 工具相同。
18
+
19
+ ### Fixed
20
+ - **`send_image_as_user` 不再报 HTTP 400 (B.1)**:v1.3.9 通过暴力探测 cookie protobuf gateway 拿到 IMAGE 最小有效字段集 — `Content.imageKey` (字段 2) + `Content.thumbnailKey` (字段 10) 即可发送成功;宽 / 高 / mime / size 全部可选。`proto/lark.proto` 加了 `imageWidth=4 / imageHeight=5 / mimeType=8 / fileSize=9 / thumbnailKey=10` 五个字段;`sendImage()` 默认 thumbnailKey = imageKey(飞书在缩略图未单独上传时接受同 key)。`scripts/explore-image-minimize.js` 留作未来字段验证起点。
21
+
22
+ ### Removed
23
+ - **`send_card_as_user(via="user")` 路径删除 (B.4)** — v1.3.9 通过 brute-force 确认 cookie protobuf gateway 的 `cmd=5 type=14 (CARD)` 路径在服务端 auth 层就被拒绝(任何字段组合都返回同一句 `richText and card type need for card message`,验证发生在 Content 解析之前)。结论是用户身份发卡片在 Feishu cookie auth tier 被服务端禁用,brute-force 不可解。`send_card_as_user` 工具保留,但 `via` 参数和 `via="user"` 代码分支彻底移除,工具固定走 bot (Official API)。`as_user` 后缀作历史命名保留,避免破坏调用方。`scripts/explore-card-protobuf.js` 留作参考,下次会话不需要重复 brute-force。
24
+
25
+ ### Test
26
+ - **`switch_profile` 多 profile e2e (F.1)**:验证原子 credentials.json 更新 + 进程内 cache 失效。位于 `src/test-switch-profile.js`,CI-friendly(dummy 凭证不联网)。
27
+
28
+ ### Test scenarios
29
+ - 调用 `read_doc_markdown(<docx_token>)`,确认返回 markdown 字符串而非 JSON;HTML 标签如 `<b>` `<em>` 已被转成 `**` `*` 等价物
30
+ - 包含 mention 链接 `[doc](wikcnXXX)` 的文档应保留原样,不被错判为 file token 占位符
31
+ - 启动 MCP 看 stderr 是否出现 `WS connected (profile=default)`;`~/.feishu-user-plugin/ws-owner.lock` 应存在
32
+ - 多 MCP 进程同时跑 → 仅一个看 `WS connected`,其它静默 → `manage_ws_status(action=info)` 看 `is_owner` 字段
33
+
34
+ ## [1.3.8] - 2026-05-05
35
+
36
+ 本次更新主线是多 profile 自动切换和 WebSocket 实时事件两块新能力,同时把 v1.3.7 推迟的 auth 模块拆分和凭证 pointer-only 模式补齐,并加固 CI 闸门(server.json 自动重生、SKILL.md allowed-tools 与 TOOLS 1:1 校验、CHANGELOG section 校验、文档三方同步校验)。工具数 80 → 82。
37
+
38
+ ### Added
39
+ - **多 profile 自动切换 (B)**:当 `~/.feishu-user-plugin/credentials.json` 配了 ≥2 profile,读取类工具(`read_*` / `list_*` / `get_*` / `search_*` / `download_*` 加 `manage_bitable_*` 的 read-action 变体)遇到 91403 / 1254301 / 1254000 / 99991672 / HTTP 403 时自动尝试其它 profile 重试。命中后 resourceKey → profile 写入 `profileHints`,下次直接走对的账号。写操作绝不自动切;显式 `via_profile="alt"` 单次锁定,`via_profile="auto"` 在写操作上手动允许。
40
+ - **新工具 manage_profile_hints**:`action=list|set|clear, resource_key?, profile?`,检查或编辑 profile 命中缓存。
41
+ - **WebSocket 实时事件 (C)**:MCP server 启动时后台连飞书 WSClient(仅 feishu.cn,Lark 国际版不支持),事件入 1000 容量 FIFO buffer。新工具 `get_new_events(event_type?, event_types?, chat_id?, since_seconds?, max_events=50, peek=false)` 拉取,默认 drain 语义;当前注册 `im.message.receive_v1`。
42
+ - **Cookie protobuf 工具链 (A.0)**:`scripts/decode-feishu-protobuf.js` 解码 + 报告未知字段;`scripts/capture-feishu-protobuf.js` 抓包 recipe;`docs/COOKIE-PROTOBUF-CAPTURES.md` 流程文档。下版本用这套真做 send_image / audio / sticker / card / search_messages 反向。
43
+ - **`FEISHU_PLUGIN_PROFILE` 启动 env (E.1)**:让 harness 各自指向不同 profile,启动时校验存在(拼错直接 exit 2,不静默 fall through)。
44
+ - **`setup --pointer-only` 模式 (E.2)**:harness env 只写 `FEISHU_PLUGIN_PROFILE=default`,真凭证全部留 `credentials.json`,消除 UAT 刷新后两端 diverge。
45
+
46
+ ### Changed
47
+ - **`src/auth/uat.js` + `src/auth/cookie.js` 拆分 (D.1, D.2)**:从 `clients/official/base.js` 和 `clients/user.js` 拆出来,client 实例上变 1-line delegate;状态字段保留在客户端实例。base.js 减约 200 行,关掉 v1.3.7 Phase B 的拆分欠账。
48
+ - **启动诊断更主动 (E.3)**:credentials.json + 旧 LARK_* env 双存在打 NOTE 提示 env 已被忽略;env-only 用户打 TIP 建议运行 `npx feishu-user-plugin migrate --confirm`。
49
+
50
+ ### Fixed
51
+ - **server.json 长期 drift**:长期停在 v1.2.0 / 33 tools 且包含已删工具。新增 `scripts/sync-server-json.js` 从 package.json + TOOLS 自动重生,prepublishOnly 与 CI 验证 drift;本版同步到 v1.3.8 / 82 tools。
52
+ - **`check-tool-count.js` 扩展**:除 README badge 之外同时校验 `SKILL.md::allowed-tools` 与 TOOLS 一致,避免 SKILL.md 单独 drift 漏掉。
53
+ - **G.1 wiki-attach 兜底回归脚本**:`scripts/test-wiki-attach-fallback.js` 把 `attachToWiki` monkey-patch 成抛 91403,验证 `upload_drive_file` 把失败透出来而不是默默上传到 drive root。POSIX skip 77 缺凭证时跳过。
54
+
55
+ ### Deferred to v1.3.9
56
+ - Cookie protobuf 实际抓包:`send_image_as_user` / `send_audio_as_user` / `send_sticker_as_user` / `send_card_as_user` 真用户身份 / `search_messages`。工具链已 ship(`scripts/decode-feishu-protobuf.js` 等),抓包 session 留下版本一并做。
57
+ - 机器级 SSOT 完整化:WebSocket 单 owner + 共享 events.jsonl + 单一 drain 游标;active profile 跨进程 stat 同步;setup 非交互模式自动 pointer-only。
58
+ - 本地 md → 飞书 wiki 同步、`read_doc_markdown` 工具、`src/config/` 目录化拆分。
59
+ - `switch_profile` 多 profile e2e(mock 第二 profile 测 setActiveProfile cache 失效路径)。
60
+ - 测试群 `oc_daaa6a50f2a97dc668aaf79ae4dc6e4e` 解散(卡 group owner 权限转让)。
61
+
62
+ ### Test scenarios
63
+ - 调用 `read_doc` 命中外部租户文档时观察 stderr 出现 `profile-router: default → alt on read_doc (code=91403)`,结果回到 alt profile 的内容
64
+ - 用 `send_to_user` 给自己发条文本后调 `get_new_events`,看到对应的 `im.message.receive_v1` 事件
65
+ - 跑 `npx feishu-user-plugin migrate --confirm` 后重启 MCP,启动 stderr 显示 `Auth: ... source: credentials.json profile=default`,所有工具调用照常
66
+
67
+ ## [1.3.7] - 2026-05-04
68
+
69
+ ### Added
70
+ - **Wiki write (5 tools)**: `create_wiki_node` / `update_wiki_node` / `move_wiki_node` / `copy_wiki_node` / `delete_wiki_node`. UAT-first. `create_wiki_node` builds doc/sheet/bitable/mindnote/file/docx/slides directly inside a wiki space, or `node_type=shortcut` for a pointer. `update_wiki_node` only patches `title` (Feishu wiki API doesn't accept content edits — those go through docx/bitable/sheet). `move`/`copy` accept `target_parent_token` + optional `target_space_id` for cross-space migration. `delete_wiki_node` calls `DELETE /wiki/v2/spaces/{id}/nodes/{token}` via raw REST (SDK doesn't type it) — only deletes the node pointer, not the underlying drive resource.
71
+ - **OKR progress writes (3 tools)**: `create_okr_progress_record` / `list_okr_progress_records` / `delete_okr_progress_record`. UAT-first. Requires `okr:okr.content:write` scope. `create` accepts a simplified `content_text` (auto-wrapped into Feishu's block schema) plus optional `source_title` / `source_url` / `progress_percent`. `list` extracts `{progress_id, target_id, target_type}` triples from `get_okrs` since Feishu has no native list endpoint.
72
+ - **Calendar write (5 tools)**: `create_calendar_event` / `update_calendar_event` / `delete_calendar_event` / `respond_calendar_event` / `get_freebusy`. UAT-first. Requires `calendar:calendar.event:write` scope. `start_time` / `end_time` are objects: `{timestamp:"<unix-seconds>", timezone?}` or `{date:"YYYY-MM-DD"}`. `delete` accepts `meeting_chat_id` to also dissolve the linked meeting chat. `respond` is the RSVP path.
73
+ - **Tasks v2 (7 tools, new domain)**: `list_tasks` / `get_task` / `create_task` / `update_task` / `complete_task` / `delete_task` / `manage_task_members`. UAT-first. Requires `task:task` scope. v2 uses `task_guid` instead of v1 numeric `task_id`. `update_task` requires explicit `update_fields=["summary","due","completed_at",...]` — Feishu only patches the listed fields. `complete_task(completed=true|false)` is a convenience wrapper.
74
+ - **MCP prompts (9)**: `/send` `/reply` `/digest` `/search` `/doc` `/table` `/wiki` `/drive` `/status`. Mirror the Claude Code skills via `prompts/list` + `prompts/get`, so Codex / Cursor / OpenClaw / Windsurf get the same guided UX. Reference bodies are read at server start from `skills/feishu-user-plugin/references/`.
75
+ - **Single-source credentials store**: `~/.feishu-user-plugin/credentials.json` (mode 0600, schema `docs/CREDENTIALS-FORMAT.md`). Multiple MCP processes (Claude Code + Codex sharing the file) see token rotations consistently — closes the "Codex still has the old UAT after a refresh in Claude Code" drift. Cookie heartbeat + UAT refresh persist back atomically. Opt-in: `npx feishu-user-plugin migrate` (dry-run) / `migrate --confirm` (writes). Env vars remain as backward-compat fallback. Server's `Auth:` startup line on stderr shows source (`credentials.json profile=default` vs `env vars (legacy)`).
76
+ - **Semi-automated regression**: `scripts/test-all-tools.js` walks every tool with representative payloads. `tests/baseline/` snapshots `tools-list.json` / `prompts-list.json` / `login-status-shape.json`; `npm run smoke` diffs against them, `npm run smoke:baseline` regenerates after intentional schema change. `docs/TESTING-METHODOLOGY.md` documents when to use unit / smoke / live MCP / `test-all-tools`.
77
+
78
+ ### Fixed
79
+ - **C1.4 — `send_*_as_user` silently dropped messages with `oc_xxx` chat IDs**: cookie protobuf gateway's `PutMessageRequest.chatId` only recognizes numeric IDs; an `oc_xxx` was treated as unknown and the server returned an empty packet. Now auto-resolves `oc_xxx` via `getChatInfo(name) → cookie search(name) → numeric` and caches the mapping. Covers `send_as_user` / `send_image_as_user` / `send_file_as_user` / `send_post_as_user` / `send_card_as_user` / `batch_send`. Numeric IDs pass through unchanged. Resolution failure throws a clear error.
80
+ - **`list_wiki_nodes` returned 131006 in spaces the bot wasn't invited to**: `list_wiki_spaces` was already UAT-first, but `list_wiki_nodes` was bot-only. Made `list_wiki_nodes` UAT-first to match.
81
+ - **C1.15 — `get_user_info` showed current user as external tenant**: `getUserById` previously hit contact API first (requires `contact:user.base:readonly`); some OAuth configs returned no permission for same-tenant queries and the user was wrongly downgraded. Now UAT-first, contact API as fallback.
82
+ - **`manage_drive_file(action=delete)` printed `task=undefined`**: `DELETE /drive/v1/files/{token}` is synchronous and returns no `task_id`. Switched to `File deleted ({type})` when no task_id, `File deletion queued: task=...` when one is returned.
83
+ - **`send_image_as_user` failed silently**: cookie protobuf gateway rejects the simple `{imageKey}` content payload (HTTP 400) because Feishu Web actually encodes images with extra metadata (dimensions, MIME, thumbnails) that aren't in `proto/lark.proto`. Now throws a clear error pointing to `send_message_as_bot(msg_type="image", payload={image_key:"..."})` as the workaround. Wire format reverse-engineering deferred to v1.3.8 (needs Chrome DevTools traffic capture).
84
+ - Documented common error codes in tool schemas: 9499 (`manage_members` missing `member_id_type`, default `open_id`), 1062501 / 1061002 (`manage_drive_file` missing `type`).
85
+
86
+ ### Changed
87
+ - **Phase A refactor**: 7,500-line `src/index.js` split into `src/tools/<domain>.js` (handlers + schemas) and `src/clients/official/<domain>.js` (API methods). `src/server.js` orchestrates registration; `src/tools/_registry.js` provides shared `ctx` (factories, profile state, `resolveDocId`). See `docs/REFACTOR-NOTES.md` for the file-responsibility matrix.
88
+ - **Tool consolidation (82 → 80)**: 21 bitable tools collapsed into 5 `manage_bitable_*` dispatchers (app / table / field / view / record, each with `action=list|create|update|delete|...`). 3 doc-block tools → `manage_doc_block(action=create|update|delete)`. 3 drive ops → `manage_drive_file(action=copy|move|delete)`. 2 download tools → `download_message_resource(kind=image|file)` + `download_doc_image`. Semantics unchanged; parameters collapsed onto an `action` field.
89
+ - **Writes default to UAT**: every `create`/`edit` for docx / bitable / drive / wiki / OKR / calendar / tasks runs through `_asUserOrApp` — UAT first, bot only as fallback. Forced bot fallback appends a ⚠ warning to the response (and points to `npx feishu-user-plugin oauth`) so the ownership shift surfaces immediately.
90
+ - **ID input normalization**: docx / bitable tools' `document_id` / `app_token` accept native token (`doccnXXX` / `docxXXX` / `bascnXXX`), wiki node token (`wikcnXXX` / `wikmXXX` / `wiknXXX`), and full Feishu URLs. Internally resolved via `getWikiNode` with a 10-minute cache.
91
+ - **Upload scope inventory**: `uploadMedia` / `upload_drive_file` / `upload_bitable_attachment` / `manage_doc_block(image_path|file_path)` collectively need `drive:drive`, `drive:file:upload`, `docs:document.media:upload`, and `sheets:spreadsheet` (sheet uploads only). Documented in CLAUDE.md and the OAuth scope table.
92
+ - **team-skills sync via PR**: post-merge hook in this repo now opens an auto-merging PR against team-skills instead of pushing to main. CI `validate.yml` enforces a version triangle across `plugin.json` / `SKILL.md` / `README.md` first `### vX.Y.Z` heading.
93
+
7
94
  ## [1.3.6] - 2026-05-03
8
95
 
9
96
  ### Added
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-80-orange.svg)](#tools)
6
+ [![Tools](https://img.shields.io/badge/Tools-84-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 -- 80 tools, 9 skills, 3 auth layers for messaging, docs, bitable, calendar, tasks, drive, OKR, and more.**
9
+ **All-in-one Feishu/Lark MCP Server -- 84 tools, 9 skills, 3 auth layers for messaging, docs, bitable, calendar, tasks, drive, OKR, and more.**
10
10
 
11
11
  The only MCP server that lets you send messages as your **personal identity** (not a bot), while also integrating the full official Feishu API. Works with Claude Code, Cursor, Windsurf, OpenClaw, and any MCP-compatible client.
12
12
 
@@ -21,6 +21,7 @@ The only MCP server that lets you send messages as your **personal identity** (n
21
21
  - **Calendar & Tasks** -- Create events, check free/busy, manage tasks.
22
22
  - **9 slash commands** for Claude Code -- `/send`, `/reply`, `/search`, `/digest`, `/doc`, `/table`, `/wiki`, `/drive`, `/status`
23
23
  - **Auto session management** -- Cookie heartbeat every 4h, UAT auto-refresh with token rotation.
24
+ - **Real-time events** (v1.3.9) -- Machine-level WS: one owner process writes to `~/.feishu-user-plugin/events.jsonl`, all harnesses drain from a shared cursor — every event delivered exactly once across all MCP processes. `manage_ws_status` to diagnose/control. WS auto-starts on boot.
24
25
  - **Multi-platform** -- Claude Code, Cursor, Windsurf, VS Code, OpenClaw.
25
26
 
26
27
  ## Why This Exists
@@ -350,7 +351,7 @@ Add to `~/.codeium/windsurf/mcp_config.json`:
350
351
  | `send_file_as_user` | Send file (requires `file_key` from `upload_file`) |
351
352
  | `send_post_as_user` | Send rich text with title + formatted paragraphs |
352
353
  | `batch_send` | Fan-out send to multiple targets in one call (text / image / file / post). v1.3.6 |
353
- | `send_card_as_user` | Send a Feishu interactive card. v1.3.6 default routes through bot identity; user-identity is reserved for v1.3.7. |
354
+ | `send_card_as_user` | Send a Feishu interactive card via bot identity (Official API). User-identity card sending is server-side disabled at the Feishu cookie auth tier — confirmed in v1.3.9. |
354
355
 
355
356
  ### User Identity -- Contacts & Info (5 tools, cookie auth)
356
357
 
@@ -474,12 +475,27 @@ Identifier is `task_guid` (not v1's numeric `task_id`). Requires `task:task` sco
474
475
  | `move_wiki_node` | Move a wiki node to a different parent or different space |
475
476
  | `copy_wiki_node` | Deep-copy a wiki node to a different location (optionally to a different space) |
476
477
 
477
- ### Plugin -- Profiles (2 tools, v1.3.6)
478
+ ### Plugin -- Profiles (3 tools, v1.3.6 + v1.3.8)
478
479
 
479
480
  | Tool | Description |
480
481
  |------|-------------|
481
482
  | `list_profiles` | List available identity profiles (default + extras from `LARK_PROFILES_JSON`) and the active one |
482
483
  | `switch_profile` | Hot-swap active profile; cached client instances rebuild against new credentials |
484
+ | `manage_profile_hints` | Inspect/set/clear the resourceKey → profile cache used by the v1.3.8 auto-switch middleware |
485
+
486
+ ### Multi-profile auto-switch (v1.3.8)
487
+
488
+ When `~/.feishu-user-plugin/credentials.json` has more than one profile, the plugin auto-switches between them on **read** paths when the active profile gets a permission-denied error. The winning profile is cached per resource so subsequent calls go straight to the right account.
489
+
490
+ **Whitelist** -- only `read_*`, `list_*`, `get_*`, `search_*`, `download_*` (and the read-action variants of `manage_bitable_*`) get auto-retry. Writes never auto-switch -- they fail loud so you don't accidentally create resources under the wrong account.
491
+
492
+ **Triggers** -- error codes 91403, 1254301, 1254000, 99991672, HTTP 403, plus message patterns `access_denied / permission_denied / docx_no_permission / no permission / forbidden`.
493
+
494
+ **Per-call override** -- pass `via_profile: "<name>"` in any tool call to pin to that profile (no auto-switch). Pass `via_profile: "auto"` to opt **into** auto-switch on a write call (escape hatch -- be careful).
495
+
496
+ **Cache management** -- `manage_profile_hints(action="list" | "set" | "clear", resource_key?, profile?)` lets you inspect or edit the cache.
497
+
498
+ Single-profile users (the vast majority): zero behaviour change -- the router short-circuits and `manage_profile_hints` is a no-op.
483
499
 
484
500
  ## Claude Code Slash Commands (9 skills)
485
501
 
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "feishu-user-plugin",
3
- "version": "1.3.7",
4
- "description": "All-in-one Feishu plugin for Claude Code & Codex — messaging (with merge_forward expansion + batch_send), docs (image + file blocks read/write), bitable, wiki (full CRUD), drive, OKR (with progress writes), calendar (read+write), Tasks v2, multi-profile. 80 tools + 9 prompts, 3 auth layers.",
3
+ "version": "1.3.9",
4
+ "description": "All-in-one Feishu plugin for Claude Code & Codex — messaging (incl. user-identity image send v1.3.9 + batch_send), docs (markdown read v1.3.9, image/file blocks), bitable, wiki, drive, OKR, calendar, Tasks v2, multi-profile (cross-process sync v1.3.9), machine-level shared WS events (v1.3.9). 84 tools + 9 prompts, 3 auth layers.",
5
5
  "main": "src/index.js",
6
6
  "bin": {
7
7
  "feishu-user-plugin": "src/cli.js"
@@ -14,7 +14,7 @@
14
14
  "smoke": "node scripts/smoke.js diff",
15
15
  "smoke:baseline": "node scripts/smoke.js write-baseline",
16
16
  "test:tools": "node scripts/test-all-tools.js",
17
- "prepublishOnly": "node scripts/check-version.js && node scripts/check-tool-count.js && node scripts/confirm-version.js",
17
+ "prepublishOnly": "node scripts/check-version.js && node scripts/check-tool-count.js && node scripts/sync-server-json.js check && node scripts/check-docs-sync.js && node scripts/check-changelog.js && node scripts/confirm-version.js",
18
18
  "prepare": "husky"
19
19
  },
20
20
  "keywords": [
@@ -55,12 +55,16 @@
55
55
  "node": ">=18.0.0"
56
56
  },
57
57
  "dependencies": {
58
- "@larksuiteoapi/node-sdk": "^1.59.0",
59
- "@modelcontextprotocol/sdk": "^1.12.1",
58
+ "@larksuiteoapi/node-sdk": "^1.63.1",
59
+ "@modelcontextprotocol/sdk": "^1.29.0",
60
60
  "dotenv": "^16.4.7",
61
- "protobufjs": "^7.4.0"
61
+ "feishu-docx": "^0.7.0",
62
+ "protobufjs": "^7.5.6"
62
63
  },
63
64
  "devDependencies": {
64
65
  "husky": "^9.1.7"
66
+ },
67
+ "overrides": {
68
+ "axios": "^1.16.0"
65
69
  }
66
70
  }
package/proto/lark.proto CHANGED
@@ -123,8 +123,18 @@ message Content {
123
123
  optional string text = 1;
124
124
  optional string imageKey = 2;
125
125
  optional string title = 3;
126
+ // Image metadata fields (v1.3.9 — reverse-engineered via brute-force probe;
127
+ // see scripts/explore-image-minimize.js). Required for IMAGE messages on the
128
+ // cookie-protobuf path: imageKey + thumbnailKey(10) is the minimum the
129
+ // gateway accepts. width(4) / height(5) / mimeType(8) / fileSize(9) are
130
+ // optional but commonly present in the real wire format.
131
+ optional int32 imageWidth = 4;
132
+ optional int32 imageHeight = 5;
126
133
  optional string fileKey = 6;
127
134
  optional string audioKey = 7;
135
+ optional string mimeType = 8;
136
+ optional int64 fileSize = 9;
137
+ optional string thumbnailKey = 10;
128
138
  optional string fileName = 11;
129
139
  optional RichText richText = 14;
130
140
  optional string stickerSetId = 24;
@@ -0,0 +1,86 @@
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+ // Companion script to docs/COOKIE-PROTOBUF-CAPTURES.md.
4
+ //
5
+ // Drives a single capture session: prints the recipe to follow, sets up
6
+ // the output dir, and after capture, decodes everything dropped into it.
7
+ //
8
+ // Usage:
9
+ // node scripts/capture-feishu-protobuf.js IMAGE # prints the IMAGE recipe
10
+ // node scripts/capture-feishu-protobuf.js DECODE # decodes everything in /tmp/feishu-captures/
11
+
12
+ const fs = require('fs');
13
+ const path = require('path');
14
+ const { execSync } = require('child_process');
15
+
16
+ const CAPTURE_DIR = '/tmp/feishu-captures';
17
+ const TYPES = {
18
+ IMAGE: {
19
+ description: 'Image message via cookie protobuf — cmd=5 PutMessageRequest, content.type=5 (IMAGE)',
20
+ targetMessage: 'Content',
21
+ },
22
+ AUDIO: {
23
+ description: 'Audio message — cmd=5, content.type=7 (AUDIO)',
24
+ targetMessage: 'Content',
25
+ },
26
+ STICKER: {
27
+ description: 'Sticker — cmd=5, content.type=10 (STICKER)',
28
+ targetMessage: 'Content',
29
+ },
30
+ CARD: {
31
+ description: 'Interactive card — cmd=5, content.type=14 (CARD)',
32
+ targetMessage: 'Content',
33
+ },
34
+ };
35
+
36
+ const cmd = process.argv[2] || 'help';
37
+
38
+ if (cmd === 'DECODE') {
39
+ if (!fs.existsSync(CAPTURE_DIR)) { console.error('No captures yet — run a TYPE first.'); process.exit(1); }
40
+ const files = fs.readdirSync(CAPTURE_DIR).filter(f => f.endsWith('.bin') || f.endsWith('.b64'));
41
+ if (!files.length) { console.error('No capture files in ' + CAPTURE_DIR); process.exit(1); }
42
+ for (const f of files) {
43
+ const type = path.basename(f).split('-')[0].toUpperCase();
44
+ const meta = TYPES[type] || { targetMessage: 'Packet' };
45
+ console.log(`\n=== ${f} (decoding as ${meta.targetMessage}) ===`);
46
+ const fullPath = path.join(CAPTURE_DIR, f);
47
+ const decodeScript = path.join(__dirname, 'decode-feishu-protobuf.js');
48
+ try {
49
+ if (f.endsWith('.b64')) {
50
+ const b64 = fs.readFileSync(fullPath, 'utf8').trim();
51
+ execSync(`node ${decodeScript} ${meta.targetMessage} --b64 ${JSON.stringify(b64)}`, { stdio: 'inherit' });
52
+ } else {
53
+ execSync(`node ${decodeScript} ${meta.targetMessage} < ${fullPath}`, { stdio: 'inherit' });
54
+ }
55
+ } catch (e) { console.error(` decode failed: ${e.message}`); }
56
+ }
57
+ process.exit(0);
58
+ }
59
+
60
+ if (!TYPES[cmd]) {
61
+ console.log('Usage: node scripts/capture-feishu-protobuf.js [IMAGE|AUDIO|STICKER|CARD|DECODE]');
62
+ console.log('\nCapture types:');
63
+ for (const [k, v] of Object.entries(TYPES)) console.log(` ${k} — ${v.description}`);
64
+ console.log('\nRecipe (IMAGE example):');
65
+ console.log(' 1. The agent uses Playwright MCP to:');
66
+ console.log(' a. Open https://www.feishu.cn/messenger/ with LARK_COOKIE');
67
+ console.log(' b. Click "我自己" / self-chat');
68
+ console.log(' c. Drag-drop a small test PNG OR click the image button + select file');
69
+ console.log(' d. Wait for the upload to complete');
70
+ console.log(' e. Click "send" and watch network for the POST to /im/gateway/');
71
+ console.log(` 2. Save the raw POST body to ${CAPTURE_DIR}/image-1.bin`);
72
+ console.log(` 3. Run: node scripts/capture-feishu-protobuf.js DECODE`);
73
+ process.exit(0);
74
+ }
75
+
76
+ fs.mkdirSync(CAPTURE_DIR, { recursive: true });
77
+ console.log(`=== ${cmd} capture session ===`);
78
+ console.log(TYPES[cmd].description);
79
+ console.log(`\nCapture dir: ${CAPTURE_DIR}`);
80
+ console.log('\nRecipe:');
81
+ console.log(' 1. Use Playwright MCP to open feishu.cn/messenger/ with cookie auth');
82
+ console.log(' 2. Send the message of type ' + cmd + ' to "我自己" via the web UI');
83
+ console.log(' 3. Capture POST /im/gateway/ request body via fetch monkey-patch');
84
+ console.log(` 4. Drop the raw body to ${CAPTURE_DIR}/${cmd.toLowerCase()}-1.bin (or .b64)`);
85
+ console.log(` 5. Run: node scripts/capture-feishu-protobuf.js DECODE`);
86
+ console.log('\nSee docs/COOKIE-PROTOBUF-CAPTURES.md for full step-by-step.');
@@ -0,0 +1,31 @@
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+ // Verifies CHANGELOG.md has an "## [vX.Y.Z]" section matching package.json
4
+ // version. Run from publish workflow + locally before tagging.
5
+ //
6
+ // Usage:
7
+ // node scripts/check-changelog.js → checks current package.json version
8
+ // node scripts/check-changelog.js 1.3.8 → checks given version explicitly
9
+
10
+ const fs = require('fs');
11
+ const path = require('path');
12
+
13
+ const explicit = process.argv[2];
14
+ const pkgVersion = explicit || require(path.join(__dirname, '..', 'package.json')).version;
15
+
16
+ const cl = fs.readFileSync(path.join(__dirname, '..', 'CHANGELOG.md'), 'utf8');
17
+
18
+ // Common section headings used in this repo: "## [v1.3.7]" or "## v1.3.7" or "## 1.3.7" or "### v1.3.7".
19
+ const patterns = [
20
+ new RegExp(`^##\\s*\\[?v?${pkgVersion.replace(/\./g, '\\.')}\\]?`, 'm'),
21
+ new RegExp(`^###\\s*v?${pkgVersion.replace(/\./g, '\\.')}`, 'm'),
22
+ ];
23
+ const match = patterns.some(re => re.test(cl));
24
+
25
+ if (!match) {
26
+ console.error(`ERROR: CHANGELOG.md has no section for v${pkgVersion}.`);
27
+ console.error(`Add "## [v${pkgVersion}]" or "### v${pkgVersion}" with the release notes before tagging.`);
28
+ process.exit(1);
29
+ }
30
+
31
+ console.log(`OK: CHANGELOG.md has v${pkgVersion} section`);
@@ -0,0 +1,41 @@
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+ // Verifies CLAUDE.md is in sync with AGENTS.md (Codex) and
4
+ // skills/feishu-user-plugin/references/CLAUDE.md (skill reference copy).
5
+ //
6
+ // Pre-commit hook (scripts/sync-claude-md.sh) already auto-regenerates these
7
+ // from CLAUDE.md, but this script gives prepublishOnly + CI a hard gate.
8
+ //
9
+ // Match logic mirrors validate.yml's diff steps:
10
+ // AGENTS.md = "# feishu-user-plugin — Codex Instructions\n" + tail -n +2 CLAUDE.md
11
+ // skills/.../CLAUDE.md = identical to CLAUDE.md
12
+
13
+ const fs = require('fs');
14
+ const path = require('path');
15
+
16
+ const ROOT = path.join(__dirname, '..');
17
+ const claude = fs.readFileSync(path.join(ROOT, 'CLAUDE.md'), 'utf8');
18
+
19
+ // AGENTS.md: header replaced with "# feishu-user-plugin — Codex Instructions"
20
+ const claudeBody = claude.split('\n').slice(1).join('\n'); // drop first line
21
+ const expectedAgents = '# feishu-user-plugin — Codex Instructions\n' + claudeBody;
22
+ const actualAgents = fs.readFileSync(path.join(ROOT, 'AGENTS.md'), 'utf8');
23
+
24
+ const failures = [];
25
+ if (actualAgents !== expectedAgents) {
26
+ failures.push('AGENTS.md is out of sync with CLAUDE.md');
27
+ failures.push('Fix: bash scripts/sync-claude-md.sh (or edit CLAUDE.md and re-stage)');
28
+ }
29
+
30
+ const skillRef = path.join(ROOT, 'skills', 'feishu-user-plugin', 'references', 'CLAUDE.md');
31
+ const actualSkillRef = fs.readFileSync(skillRef, 'utf8');
32
+ if (actualSkillRef !== claude) {
33
+ failures.push('skills/feishu-user-plugin/references/CLAUDE.md is out of sync with CLAUDE.md');
34
+ failures.push('Fix: bash scripts/sync-claude-md.sh (or edit CLAUDE.md and re-stage)');
35
+ }
36
+
37
+ if (failures.length) {
38
+ for (const f of failures) console.error(f);
39
+ process.exit(1);
40
+ }
41
+ console.log('OK: CLAUDE.md / AGENTS.md / skill reference all in sync');
@@ -3,13 +3,38 @@
3
3
  const path = require('path');
4
4
  const fs = require('fs');
5
5
  const { TOOLS } = require(path.join(__dirname, '..', 'src', 'server'));
6
+
7
+ const failures = [];
8
+
9
+ // Source 1: README.md "N tools" badge
6
10
  const readme = fs.readFileSync(path.join(__dirname, '..', 'README.md'), 'utf8');
7
- // Find the canonical "N tools" reference. Pick the highest-confidence match.
8
- const m = readme.match(/(\d+)\s+tools/);
9
- if (!m) { console.error('No "N tools" badge in README.md'); process.exit(1); }
10
- const claimed = parseInt(m[1], 10);
11
- if (claimed !== TOOLS.length) {
12
- console.error(`README claims ${claimed} tools, src/server.js has ${TOOLS.length}`);
11
+ const readmeMatch = readme.match(/(\d+)\s+tools/);
12
+ if (!readmeMatch) {
13
+ failures.push('No "N tools" badge in README.md');
14
+ } else if (parseInt(readmeMatch[1], 10) !== TOOLS.length) {
15
+ failures.push(`README.md claims ${readmeMatch[1]} tools, src/server.js has ${TOOLS.length}`);
16
+ }
17
+
18
+ // Source 2: SKILL.md `allowed-tools` frontmatter — comma-separated list.
19
+ const skillMd = fs.readFileSync(path.join(__dirname, '..', 'skills', 'feishu-user-plugin', 'SKILL.md'), 'utf8');
20
+ const skillMatch = skillMd.match(/^allowed-tools:\s*(.+)$/m);
21
+ if (!skillMatch) {
22
+ failures.push('No `allowed-tools:` line in skills/feishu-user-plugin/SKILL.md frontmatter');
23
+ } else {
24
+ const skillTools = skillMatch[1].split(',').map(s => s.trim()).filter(Boolean);
25
+ const skillSet = new Set(skillTools);
26
+ const toolSet = new Set(TOOLS.map(t => t.name));
27
+ const missingFromSkill = [...toolSet].filter(t => !skillSet.has(t)).sort();
28
+ const extraInSkill = [...skillSet].filter(t => !toolSet.has(t)).sort();
29
+ if (missingFromSkill.length || extraInSkill.length) {
30
+ failures.push(`SKILL.md allowed-tools out of sync (server has ${TOOLS.length}, SKILL.md has ${skillTools.length}):`);
31
+ if (missingFromSkill.length) failures.push(` missing from SKILL.md: ${missingFromSkill.join(', ')}`);
32
+ if (extraInSkill.length) failures.push(` extra in SKILL.md (not registered): ${extraInSkill.join(', ')}`);
33
+ }
34
+ }
35
+
36
+ if (failures.length) {
37
+ for (const f of failures) console.error(f);
13
38
  process.exit(1);
14
39
  }
15
- console.log(`OK: ${TOOLS.length} tools`);
40
+ console.log(`OK: ${TOOLS.length} tools (README badge + SKILL.md allowed-tools both match)`);
@@ -0,0 +1,115 @@
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+ // Decode a captured Feishu protobuf payload against proto/lark.proto.
4
+ //
5
+ // Usage:
6
+ // node scripts/decode-feishu-protobuf.js Packet < /path/to/payload.bin
7
+ // echo "0a..." | node scripts/decode-feishu-protobuf.js Packet --hex
8
+ // node scripts/decode-feishu-protobuf.js Packet --b64 'CgRwYWNr...'
9
+ //
10
+ // Output:
11
+ // - Decoded JSON of the named message
12
+ // - "Unknown fields detected" section listing tag numbers + wire types we
13
+ // don't have in the proto (these are what we need to add).
14
+
15
+ const path = require('path');
16
+ const protobuf = require('protobufjs');
17
+
18
+ async function main() {
19
+ const args = process.argv.slice(2);
20
+ const messageName = args[0];
21
+ if (!messageName) {
22
+ console.error('Usage: node scripts/decode-feishu-protobuf.js <MessageName> [--hex | --b64 <data>]');
23
+ process.exit(2);
24
+ }
25
+ const flagIdx = args.indexOf('--hex');
26
+ const b64Idx = args.indexOf('--b64');
27
+
28
+ let buf;
29
+ if (b64Idx !== -1) {
30
+ buf = Buffer.from(args[b64Idx + 1], 'base64');
31
+ } else if (flagIdx !== -1) {
32
+ const hex = await readStdin();
33
+ buf = Buffer.from(hex.replace(/\s+/g, ''), 'hex');
34
+ } else {
35
+ buf = await readStdinBuffer();
36
+ }
37
+
38
+ const proto = await protobuf.load(path.join(__dirname, '..', 'proto', 'lark.proto'));
39
+ const T = proto.lookupType(messageName);
40
+ const decoded = T.decode(buf);
41
+ const obj = T.toObject(decoded, { defaults: false, bytes: String });
42
+ // Walk the buffer to find unknown field tags.
43
+ const unknown = scanUnknownFields(buf, T);
44
+ console.log(JSON.stringify(obj, _dumpBytes, 2));
45
+ if (unknown.length) {
46
+ console.log('\n--- Unknown fields detected ---');
47
+ for (const u of unknown) console.log(` field ${u.tag} (wire type ${u.wireType}, ${u.length} bytes): ${u.preview}`);
48
+ console.log('\nFor each unknown tag, add the field to proto/lark.proto and re-run to see the decoded shape.');
49
+ } else {
50
+ console.log('\n--- All fields known ---');
51
+ }
52
+ }
53
+
54
+ function _dumpBytes(_, v) {
55
+ if (Buffer.isBuffer(v)) return `<${v.length} bytes 0x${v.slice(0, 16).toString('hex')}${v.length > 16 ? '…' : ''}>`;
56
+ return v;
57
+ }
58
+
59
+ function readStdin() {
60
+ return new Promise((resolve) => {
61
+ const chunks = [];
62
+ process.stdin.on('data', (c) => chunks.push(c));
63
+ process.stdin.on('end', () => resolve(Buffer.concat(chunks).toString('utf8')));
64
+ });
65
+ }
66
+
67
+ function readStdinBuffer() {
68
+ return new Promise((resolve) => {
69
+ const chunks = [];
70
+ process.stdin.on('data', (c) => chunks.push(c));
71
+ process.stdin.on('end', () => resolve(Buffer.concat(chunks)));
72
+ });
73
+ }
74
+
75
+ // Walks raw protobuf bytes, decoding tag headers, and reports tags whose
76
+ // number+wireType is not present in the schema. Matches protobufjs's reader
77
+ // state machine but operates entry-point-only (no recursion into subtrees).
78
+ function scanUnknownFields(buf, type) {
79
+ const known = new Set(type.fieldsArray.map(f => f.id));
80
+ const reader = protobuf.Reader.create(buf);
81
+ const out = [];
82
+ while (reader.pos < reader.len) {
83
+ const tagInt = reader.uint32();
84
+ const tag = tagInt >>> 3;
85
+ const wireType = tagInt & 7;
86
+ const start = reader.pos;
87
+ let value;
88
+ try {
89
+ value = readValueByWireType(reader, wireType);
90
+ } catch (e) {
91
+ out.push({ tag, wireType, length: 0, preview: `decode error: ${e.message}` });
92
+ break;
93
+ }
94
+ if (!known.has(tag)) {
95
+ const len = reader.pos - start;
96
+ let preview;
97
+ if (Buffer.isBuffer(value)) preview = `0x${value.slice(0, 24).toString('hex')}${value.length > 24 ? '…' : ''}`;
98
+ else preview = String(value).slice(0, 80);
99
+ out.push({ tag, wireType, length: len, preview });
100
+ }
101
+ }
102
+ return out;
103
+ }
104
+
105
+ function readValueByWireType(reader, wireType) {
106
+ switch (wireType) {
107
+ case 0: return reader.uint64(); // varint
108
+ case 1: return reader.fixed64(); // 64-bit
109
+ case 2: return Buffer.from(reader.bytes()); // length-delimited
110
+ case 5: return reader.fixed32(); // 32-bit
111
+ default: reader.skipType(wireType); return null;
112
+ }
113
+ }
114
+
115
+ main().catch(e => { console.error('Error:', e.message); process.exit(1); });