feishu-user-plugin 1.3.8 → 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.
- package/.claude-plugin/plugin.json +12 -2
- package/CHANGELOG.md +50 -12
- package/README.md +4 -4
- package/package.json +9 -5
- package/proto/lark.proto +10 -0
- package/scripts/explore-card-protobuf.js +144 -0
- package/scripts/explore-image-minimize.js +163 -0
- package/scripts/generate-release-artifacts.js +318 -0
- package/scripts/probe-feishu-docx.js +203 -0
- package/scripts/sync-team-skills.sh +109 -7
- package/skills/feishu-user-plugin/SKILL.md +76 -4
- package/skills/feishu-user-plugin/references/CLAUDE.md +74 -54
- package/src/auth/credentials.js +36 -0
- package/src/cli.js +86 -45
- package/src/clients/user.js +15 -13
- package/src/events/cursor.js +103 -0
- package/src/events/event-buffer.js +8 -5
- package/src/events/event-log.js +151 -0
- package/src/events/index.js +8 -1
- package/src/events/lockfile.js +126 -0
- package/src/events/owner.js +73 -0
- package/src/events/ws-server.js +95 -25
- package/src/oauth.js +48 -7
- package/src/resolver.js +10 -0
- package/src/server.js +248 -29
- package/src/setup.js +99 -25
- package/src/test-all.js +12 -9
- package/src/test-events-cursor.js +56 -0
- package/src/test-events-lockfile.js +36 -0
- package/src/test-events-log.js +67 -0
- package/src/test-events-owner.js +64 -0
- package/src/test-fixtures/doc-blocks/sample-1.json +1256 -0
- package/src/test-read-doc-markdown.js +61 -0
- package/src/test-switch-profile.js +171 -0
- package/src/tools/diagnostics.js +10 -3
- package/src/tools/docs.js +93 -3
- package/src/tools/events.js +143 -33
- package/src/tools/messaging-bot.js +2 -3
- package/src/tools/messaging-user.js +23 -14
- package/src/tools/profile.js +12 -7
|
@@ -1,9 +1,19 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "feishu-user-plugin",
|
|
3
|
-
"version": "1.3.
|
|
3
|
+
"version": "1.3.9",
|
|
4
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": [
|
|
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,27 +4,65 @@ 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
|
+
|
|
7
34
|
## [1.3.8] - 2026-05-05
|
|
8
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
|
+
|
|
9
38
|
### Added
|
|
10
|
-
-
|
|
11
|
-
-
|
|
12
|
-
- **
|
|
13
|
-
-
|
|
14
|
-
- **`
|
|
15
|
-
-
|
|
16
|
-
- **CI / docs gates (F)**: `scripts/sync-server-json.js` regenerates `server.json` from `package.json + TOOLS` (was frozen at v1.2.0 / 33 tools — now matches reality). `scripts/check-tool-count.js` extended to verify `SKILL.md::allowed-tools` in addition to README badge. `scripts/check-changelog.js` blocks publish when CHANGELOG has no section for the tag version. `scripts/check-docs-sync.js` enforces CLAUDE.md / AGENTS.md / skill-ref triple-sync at prepublish + CI.
|
|
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。
|
|
17
45
|
|
|
18
46
|
### Changed
|
|
19
|
-
- **`src/auth/uat.js`
|
|
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`。
|
|
20
49
|
|
|
21
50
|
### Fixed
|
|
22
|
-
- **
|
|
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 缺凭证时跳过。
|
|
23
54
|
|
|
24
55
|
### Deferred to v1.3.9
|
|
25
|
-
- Cookie protobuf
|
|
26
|
-
-
|
|
27
|
-
-
|
|
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`,所有工具调用照常
|
|
28
66
|
|
|
29
67
|
## [1.3.7] - 2026-05-04
|
|
30
68
|
|
package/README.md
CHANGED
|
@@ -3,10 +3,10 @@
|
|
|
3
3
|
[](LICENSE)
|
|
4
4
|
[](https://nodejs.org)
|
|
5
5
|
[](https://modelcontextprotocol.io)
|
|
6
|
-
[](#tools)
|
|
7
7
|
[](CONTRIBUTING.md)
|
|
8
8
|
|
|
9
|
-
**All-in-one Feishu/Lark MCP Server --
|
|
9
|
+
**All-in-one Feishu/Lark MCP Server -- 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,7 +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.
|
|
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.
|
|
25
25
|
- **Multi-platform** -- Claude Code, Cursor, Windsurf, VS Code, OpenClaw.
|
|
26
26
|
|
|
27
27
|
## Why This Exists
|
|
@@ -351,7 +351,7 @@ Add to `~/.codeium/windsurf/mcp_config.json`:
|
|
|
351
351
|
| `send_file_as_user` | Send file (requires `file_key` from `upload_file`) |
|
|
352
352
|
| `send_post_as_user` | Send rich text with title + formatted paragraphs |
|
|
353
353
|
| `batch_send` | Fan-out send to multiple targets in one call (text / image / file / post). v1.3.6 |
|
|
354
|
-
| `send_card_as_user` | Send a Feishu interactive card
|
|
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. |
|
|
355
355
|
|
|
356
356
|
### User Identity -- Contacts & Info (5 tools, cookie auth)
|
|
357
357
|
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "feishu-user-plugin",
|
|
3
|
-
"version": "1.3.
|
|
4
|
-
"description": "All-in-one Feishu plugin for Claude Code & Codex — messaging (
|
|
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"
|
|
@@ -55,12 +55,16 @@
|
|
|
55
55
|
"node": ">=18.0.0"
|
|
56
56
|
},
|
|
57
57
|
"dependencies": {
|
|
58
|
-
"@larksuiteoapi/node-sdk": "^1.
|
|
59
|
-
"@modelcontextprotocol/sdk": "^1.
|
|
58
|
+
"@larksuiteoapi/node-sdk": "^1.63.1",
|
|
59
|
+
"@modelcontextprotocol/sdk": "^1.29.0",
|
|
60
60
|
"dotenv": "^16.4.7",
|
|
61
|
-
"
|
|
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,144 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
'use strict';
|
|
3
|
+
// CARD (type=14) protobuf field exploration. Probe field numbers + types.
|
|
4
|
+
|
|
5
|
+
const fs = require('fs');
|
|
6
|
+
const path = require('path');
|
|
7
|
+
const protobuf = require('protobufjs');
|
|
8
|
+
|
|
9
|
+
const claudeCfg = JSON.parse(fs.readFileSync(path.join(require('os').homedir(), '.claude.json'), 'utf8'));
|
|
10
|
+
const env = claudeCfg.mcpServers?.['feishu-user-plugin']?.env || {};
|
|
11
|
+
process.env.LARK_COOKIE = env.LARK_COOKIE;
|
|
12
|
+
process.env.LARK_APP_ID = env.LARK_APP_ID;
|
|
13
|
+
process.env.LARK_APP_SECRET = env.LARK_APP_SECRET;
|
|
14
|
+
|
|
15
|
+
const PLUGIN_ROOT = path.join(__dirname, '..');
|
|
16
|
+
const { LarkUserClient } = require(path.join(PLUGIN_ROOT, 'src/clients/user'));
|
|
17
|
+
const { generateCid, generateRequestId } = require(path.join(PLUGIN_ROOT, 'src/utils'));
|
|
18
|
+
|
|
19
|
+
const TEST_GROUP_NAME = '飞书plugin测试群';
|
|
20
|
+
|
|
21
|
+
const errProto = protobuf.parse(`
|
|
22
|
+
syntax = "proto3";
|
|
23
|
+
message ErrorResponse {
|
|
24
|
+
optional string message = 1;
|
|
25
|
+
optional int32 code = 2;
|
|
26
|
+
optional int32 subCode = 3;
|
|
27
|
+
optional string detail = 4;
|
|
28
|
+
optional string trace = 5;
|
|
29
|
+
optional string requestId = 6;
|
|
30
|
+
}
|
|
31
|
+
`).root;
|
|
32
|
+
const ErrorResponse = errProto.lookupType('ErrorResponse');
|
|
33
|
+
|
|
34
|
+
async function fetchProto(url, opts) {
|
|
35
|
+
const u = new URL(url);
|
|
36
|
+
return new Promise((resolve, reject) => {
|
|
37
|
+
const req = require('node:https').request({
|
|
38
|
+
hostname: u.hostname,
|
|
39
|
+
port: u.port || 443,
|
|
40
|
+
path: u.pathname + u.search,
|
|
41
|
+
method: opts.method || 'GET',
|
|
42
|
+
headers: opts.headers || {},
|
|
43
|
+
}, (res) => {
|
|
44
|
+
const chunks = [];
|
|
45
|
+
res.on('data', (c) => chunks.push(c));
|
|
46
|
+
res.on('end', () => resolve({ status: res.statusCode, body: Buffer.concat(chunks) }));
|
|
47
|
+
});
|
|
48
|
+
req.on('error', reject);
|
|
49
|
+
if (opts.body) req.write(opts.body);
|
|
50
|
+
req.end();
|
|
51
|
+
});
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function encodeVarint(n) {
|
|
55
|
+
const bytes = [];
|
|
56
|
+
while (n >= 0x80) { bytes.push((n & 0x7f) | 0x80); n = Math.floor(n / 128); }
|
|
57
|
+
bytes.push(n & 0x7f);
|
|
58
|
+
return Buffer.from(bytes);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function encodeWireField(num, type, val) {
|
|
62
|
+
if (type === 'varint') return Buffer.concat([Buffer.from([(num << 3) | 0]), encodeVarint(val)]);
|
|
63
|
+
if (type === 'string' || type === 'bytes') {
|
|
64
|
+
const v = type === 'string' ? Buffer.from(val, 'utf8') : val;
|
|
65
|
+
const tag = (num << 3) | 2;
|
|
66
|
+
if (num < 16) return Buffer.concat([Buffer.from([tag]), encodeVarint(v.length), v]);
|
|
67
|
+
// For field numbers > 15, tag is multi-byte varint
|
|
68
|
+
return Buffer.concat([encodeVarint(tag), encodeVarint(v.length), v]);
|
|
69
|
+
}
|
|
70
|
+
throw new Error('unknown type');
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
async function trySendRaw(userClient, chatId, msgType, contentBytes, label) {
|
|
74
|
+
const proto = userClient.proto;
|
|
75
|
+
const PutMessageRequest = proto.lookupType('PutMessageRequest');
|
|
76
|
+
const Packet = proto.lookupType('Packet');
|
|
77
|
+
|
|
78
|
+
const restReq = PutMessageRequest.encode(PutMessageRequest.create({
|
|
79
|
+
type: msgType, chatId, cid: generateCid(), isNotified: true, version: 1,
|
|
80
|
+
})).finish();
|
|
81
|
+
const contentField = Buffer.concat([
|
|
82
|
+
Buffer.from([(2 << 3) | 2]),
|
|
83
|
+
encodeVarint(contentBytes.length),
|
|
84
|
+
contentBytes,
|
|
85
|
+
]);
|
|
86
|
+
const reqBuf = Buffer.concat([contentField, restReq]);
|
|
87
|
+
|
|
88
|
+
const packetBuf = Packet.encode(Packet.create({
|
|
89
|
+
payloadType: 1,
|
|
90
|
+
cmd: 5,
|
|
91
|
+
cid: generateRequestId(),
|
|
92
|
+
payload: reqBuf,
|
|
93
|
+
})).finish();
|
|
94
|
+
|
|
95
|
+
const res = await fetchProto('https://internal-api-lark-api.feishu.cn/im/gateway/', {
|
|
96
|
+
method: 'POST',
|
|
97
|
+
headers: userClient._protoHeaders(5, '5.7.0'),
|
|
98
|
+
body: packetBuf,
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
let parsedErr = null;
|
|
102
|
+
try { parsedErr = ErrorResponse.toObject(ErrorResponse.decode(res.body)); } catch (_) {}
|
|
103
|
+
|
|
104
|
+
const status = res.status === 200 ? '✓ OK' : `✗ ${res.status}`;
|
|
105
|
+
const errMsg = parsedErr?.message?.slice(0, 120) || '';
|
|
106
|
+
console.log(`${status} [${label}] ${errMsg}`);
|
|
107
|
+
return { ok: res.status === 200, status: res.status, err: errMsg };
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
(async () => {
|
|
111
|
+
const userClient = new LarkUserClient(process.env.LARK_COOKIE);
|
|
112
|
+
await userClient.init();
|
|
113
|
+
const sr = await userClient.search(TEST_GROUP_NAME);
|
|
114
|
+
const chatId = sr.find(x => x.type === 'group' && x.title.includes(TEST_GROUP_NAME)).id;
|
|
115
|
+
console.log('chatId:', chatId);
|
|
116
|
+
|
|
117
|
+
const card = JSON.stringify({
|
|
118
|
+
config: { wide_screen_mode: true },
|
|
119
|
+
header: { title: { tag: 'plain_text', content: '[explore-card] please ignore' } },
|
|
120
|
+
elements: [{ tag: 'div', text: { tag: 'lark_md', content: 'Cookie protobuf test card' } }],
|
|
121
|
+
});
|
|
122
|
+
const cardBuf = Buffer.from(card, 'utf8');
|
|
123
|
+
console.log('card json size:', cardBuf.length);
|
|
124
|
+
|
|
125
|
+
console.log('\n=== Phase A: try type=14 with each unused field number ===');
|
|
126
|
+
// Known used: 1(text), 2(imageKey), 3(title), 6(fileKey), 7(audioKey), 11(fileName), 14(richText), 24(stickerSetId), 25(stickerId)
|
|
127
|
+
// Unused (potential card field): 4, 5, 8, 9, 10, 12, 13, 15, 16, 17, 18, 19, 20, 21, 22, 23
|
|
128
|
+
const candidates = [4, 5, 8, 9, 10, 12, 13, 15, 16, 17, 18, 19, 20, 21, 22, 23];
|
|
129
|
+
for (const fieldNum of candidates) {
|
|
130
|
+
// Try as string (JSON)
|
|
131
|
+
const ckS = encodeWireField(fieldNum, 'string', card);
|
|
132
|
+
const res = await trySendRaw(userClient, chatId, 14, ckS, `field ${fieldNum} string=cardJSON`);
|
|
133
|
+
if (res.ok) {
|
|
134
|
+
console.log(`\n🎯 SUCCESS at field ${fieldNum}!`);
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
console.log('\n=== Phase B: special — Content field 14 is richText. Try CARD via richText? ===');
|
|
139
|
+
// Skip — richText is for POST type. CARD is different.
|
|
140
|
+
|
|
141
|
+
console.log('\n=== Phase C: combos — maybe card needs type at multiple fields ===');
|
|
142
|
+
// Like image needed thumb. Try card at field 8 + 9 + 10 + 16 etc.
|
|
143
|
+
// Skip for now — Phase A may have answered.
|
|
144
|
+
})().catch(e => { console.error('FATAL:', e.message); process.exit(1); });
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
'use strict';
|
|
3
|
+
// Minimize IMAGE Content fields. Start with known-working combo, drop one field
|
|
4
|
+
// at a time and observe which omissions still pass.
|
|
5
|
+
|
|
6
|
+
const fs = require('fs');
|
|
7
|
+
const path = require('path');
|
|
8
|
+
const protobuf = require('protobufjs');
|
|
9
|
+
|
|
10
|
+
const claudeCfg = JSON.parse(fs.readFileSync(path.join(require('os').homedir(), '.claude.json'), 'utf8'));
|
|
11
|
+
const env = claudeCfg.mcpServers?.['feishu-user-plugin']?.env || {};
|
|
12
|
+
process.env.LARK_COOKIE = env.LARK_COOKIE;
|
|
13
|
+
process.env.LARK_APP_ID = env.LARK_APP_ID;
|
|
14
|
+
process.env.LARK_APP_SECRET = env.LARK_APP_SECRET;
|
|
15
|
+
|
|
16
|
+
const PLUGIN_ROOT = path.join(__dirname, '..');
|
|
17
|
+
const { LarkUserClient } = require(path.join(PLUGIN_ROOT, 'src/clients/user'));
|
|
18
|
+
const { LarkOfficialClient } = require(path.join(PLUGIN_ROOT, 'src/clients/official'));
|
|
19
|
+
const { generateCid, generateRequestId } = require(path.join(PLUGIN_ROOT, 'src/utils'));
|
|
20
|
+
|
|
21
|
+
const TEST_IMAGE = path.join(PLUGIN_ROOT, '.playwright-mcp/captures/test-small.png');
|
|
22
|
+
const TEST_GROUP_NAME = '飞书plugin测试群';
|
|
23
|
+
|
|
24
|
+
const errProto = protobuf.parse(`
|
|
25
|
+
syntax = "proto3";
|
|
26
|
+
message ErrorResponse {
|
|
27
|
+
optional string message = 1;
|
|
28
|
+
optional int32 code = 2;
|
|
29
|
+
optional int32 subCode = 3;
|
|
30
|
+
optional string detail = 4;
|
|
31
|
+
optional string trace = 5;
|
|
32
|
+
optional string requestId = 6;
|
|
33
|
+
}
|
|
34
|
+
`).root;
|
|
35
|
+
const ErrorResponse = errProto.lookupType('ErrorResponse');
|
|
36
|
+
|
|
37
|
+
async function fetchProto(url, opts) {
|
|
38
|
+
const u = new URL(url);
|
|
39
|
+
return new Promise((resolve, reject) => {
|
|
40
|
+
const req = require('node:https').request({
|
|
41
|
+
hostname: u.hostname,
|
|
42
|
+
port: u.port || 443,
|
|
43
|
+
path: u.pathname + u.search,
|
|
44
|
+
method: opts.method || 'GET',
|
|
45
|
+
headers: opts.headers || {},
|
|
46
|
+
}, (res) => {
|
|
47
|
+
const chunks = [];
|
|
48
|
+
res.on('data', (c) => chunks.push(c));
|
|
49
|
+
res.on('end', () => resolve({ status: res.statusCode, body: Buffer.concat(chunks) }));
|
|
50
|
+
});
|
|
51
|
+
req.on('error', reject);
|
|
52
|
+
if (opts.body) req.write(opts.body);
|
|
53
|
+
req.end();
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function encodeVarint(n) {
|
|
58
|
+
const bytes = [];
|
|
59
|
+
while (n >= 0x80) {
|
|
60
|
+
bytes.push((n & 0x7f) | 0x80);
|
|
61
|
+
n = Math.floor(n / 128);
|
|
62
|
+
}
|
|
63
|
+
bytes.push(n & 0x7f);
|
|
64
|
+
return Buffer.from(bytes);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function encodeWireField(num, type, val) {
|
|
68
|
+
if (type === 'varint') return Buffer.concat([Buffer.from([(num << 3) | 0]), encodeVarint(val)]);
|
|
69
|
+
if (type === 'string') {
|
|
70
|
+
const v = Buffer.from(val, 'utf8');
|
|
71
|
+
return Buffer.concat([Buffer.from([(num << 3) | 2]), encodeVarint(v.length), v]);
|
|
72
|
+
}
|
|
73
|
+
throw new Error('unknown type');
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function buildContent(fields) {
|
|
77
|
+
return Buffer.concat(fields.map(([n, t, v]) => encodeWireField(n, t, v)));
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
async function trySendRaw(userClient, chatId, msgType, contentBytes, label) {
|
|
81
|
+
const proto = userClient.proto;
|
|
82
|
+
const PutMessageRequest = proto.lookupType('PutMessageRequest');
|
|
83
|
+
const Packet = proto.lookupType('Packet');
|
|
84
|
+
|
|
85
|
+
const restReq = PutMessageRequest.encode(PutMessageRequest.create({
|
|
86
|
+
type: msgType, chatId, cid: generateCid(), isNotified: true, version: 1,
|
|
87
|
+
})).finish();
|
|
88
|
+
const contentField = Buffer.concat([
|
|
89
|
+
Buffer.from([(2 << 3) | 2]),
|
|
90
|
+
encodeVarint(contentBytes.length),
|
|
91
|
+
contentBytes,
|
|
92
|
+
]);
|
|
93
|
+
const reqBuf = Buffer.concat([contentField, restReq]);
|
|
94
|
+
|
|
95
|
+
const packetBuf = Packet.encode(Packet.create({
|
|
96
|
+
payloadType: 1,
|
|
97
|
+
cmd: 5,
|
|
98
|
+
cid: generateRequestId(),
|
|
99
|
+
payload: reqBuf,
|
|
100
|
+
})).finish();
|
|
101
|
+
|
|
102
|
+
const res = await fetchProto('https://internal-api-lark-api.feishu.cn/im/gateway/', {
|
|
103
|
+
method: 'POST',
|
|
104
|
+
headers: userClient._protoHeaders(5, '5.7.0'),
|
|
105
|
+
body: packetBuf,
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
let parsedErr = null;
|
|
109
|
+
try { parsedErr = ErrorResponse.toObject(ErrorResponse.decode(res.body)); } catch (_) {}
|
|
110
|
+
|
|
111
|
+
const status = res.status === 200 ? '✓ OK' : `✗ ${res.status}`;
|
|
112
|
+
const errMsg = parsedErr?.message?.slice(0, 100) || '';
|
|
113
|
+
console.log(`${status} [${label}] ${errMsg}`);
|
|
114
|
+
return { ok: res.status === 200, status: res.status, err: errMsg };
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
(async () => {
|
|
118
|
+
const oc = new LarkOfficialClient(process.env.LARK_APP_ID, process.env.LARK_APP_SECRET);
|
|
119
|
+
const r = await oc.uploadImage(TEST_IMAGE, 'message');
|
|
120
|
+
const imageKey = r.imageKey;
|
|
121
|
+
console.log('imageKey:', imageKey);
|
|
122
|
+
|
|
123
|
+
const userClient = new LarkUserClient(process.env.LARK_COOKIE);
|
|
124
|
+
await userClient.init();
|
|
125
|
+
const sr = await userClient.search(TEST_GROUP_NAME);
|
|
126
|
+
const chatId = sr.find(x => x.type === 'group' && x.title.includes(TEST_GROUP_NAME)).id;
|
|
127
|
+
console.log('chatId:', chatId);
|
|
128
|
+
|
|
129
|
+
// ALL fields baseline (known to work)
|
|
130
|
+
const allFields = [
|
|
131
|
+
[2, 'string', imageKey], // imageKey
|
|
132
|
+
[4, 'varint', 50], // ?
|
|
133
|
+
[5, 'varint', 50], // ?
|
|
134
|
+
[8, 'string', 'image/png'], // mime?
|
|
135
|
+
[9, 'varint', 141], // size?
|
|
136
|
+
[10, 'string', imageKey], // thumbnail?
|
|
137
|
+
];
|
|
138
|
+
|
|
139
|
+
console.log('\n=== Verify baseline (all 6 fields) ===');
|
|
140
|
+
await trySendRaw(userClient, chatId, 5, buildContent(allFields), 'baseline all fields');
|
|
141
|
+
|
|
142
|
+
console.log('\n=== Drop each field individually ===');
|
|
143
|
+
// Field labels: imageKey is required; we test which of the OTHER 5 are required
|
|
144
|
+
const others = [4, 5, 8, 9, 10];
|
|
145
|
+
for (const dropField of others) {
|
|
146
|
+
const subset = allFields.filter(([n]) => n !== dropField);
|
|
147
|
+
const result = await trySendRaw(userClient, chatId, 5, buildContent(subset), `omit field ${dropField}`);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
console.log('\n=== Drop pairs / minimize ===');
|
|
151
|
+
// Just imageKey alone (already known to fail)
|
|
152
|
+
await trySendRaw(userClient, chatId, 5, buildContent([allFields[0]]), 'just imageKey');
|
|
153
|
+
// imageKey + field 10 (thumb)
|
|
154
|
+
await trySendRaw(userClient, chatId, 5, buildContent([allFields[0], allFields[5]]), 'imageKey + thumb(10)');
|
|
155
|
+
// imageKey + 4 + 5 (dims)
|
|
156
|
+
await trySendRaw(userClient, chatId, 5, buildContent([allFields[0], allFields[1], allFields[2]]), 'imageKey + 4 + 5');
|
|
157
|
+
// imageKey + 4 + 5 + 10
|
|
158
|
+
await trySendRaw(userClient, chatId, 5, buildContent([allFields[0], allFields[1], allFields[2], allFields[5]]), 'imageKey + 4 + 5 + 10');
|
|
159
|
+
// imageKey + 8 + 10 (mime + thumb)
|
|
160
|
+
await trySendRaw(userClient, chatId, 5, buildContent([allFields[0], allFields[3], allFields[5]]), 'imageKey + mime(8) + thumb(10)');
|
|
161
|
+
// imageKey + 4 + 5 + 8 (no size, no thumb)
|
|
162
|
+
await trySendRaw(userClient, chatId, 5, buildContent([allFields[0], allFields[1], allFields[2], allFields[3]]), 'imageKey + 4 + 5 + mime(8)');
|
|
163
|
+
})().catch(e => { console.error('FATAL:', e.message); process.exit(1); });
|