feishu-user-plugin 1.3.14 → 1.3.16
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 +1 -1
- package/.cursor-plugin/plugin.json +1 -1
- package/.mcpb/manifest.json +1 -1
- package/CHANGELOG.md +38 -0
- package/package.json +3 -3
- package/skills/feishu-user-plugin/SKILL.md +2 -2
- package/skills/feishu-user-plugin/references/doc.md +12 -0
- package/skills/feishu-user-plugin/references/drive.md +8 -2
- package/src/auth/uat.js +38 -1
- package/src/clients/official/docs.js +120 -8
- package/src/clients/official/drive.js +28 -3
- package/src/clients/official/wiki.js +46 -8
- package/src/test-all.js +8 -0
- package/src/test-doc-table.js +123 -0
- package/src/test-uat-lifecycle.js +95 -0
- package/src/test-uat-read-paths.js +313 -0
- package/src/tools/docs.js +23 -7
- package/src/tools/drive.js +10 -3
- package/src/tools/wiki.js +10 -3
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "feishu-user-plugin",
|
|
3
|
-
"version": "1.3.
|
|
3
|
+
"version": "1.3.16",
|
|
4
4
|
"description": "All-in-one Feishu MCP server + CLI tool 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. 85 tools + 9 prompts, 3 auth layers.",
|
|
5
5
|
"author": {
|
|
6
6
|
"name": "EthanQC"
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
"name": "feishu-user-plugin",
|
|
3
3
|
"displayName": "Feishu MCP for Claude Code & Codex",
|
|
4
4
|
"description": "All-in-one Feishu MCP server + CLI tool for Claude Code / Codex / Cursor / scripts — 85 tools across 3 auth layers (cookie / app / OAuth). Send as you, read groups, manage docs / bitable / wiki / drive / calendar / tasks / OKR.",
|
|
5
|
-
"version": "1.3.
|
|
5
|
+
"version": "1.3.16",
|
|
6
6
|
"author": {
|
|
7
7
|
"name": "EthanQC"
|
|
8
8
|
},
|
package/.mcpb/manifest.json
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
"manifest_version": "0.3",
|
|
3
3
|
"name": "feishu-user-plugin",
|
|
4
4
|
"display_name": "Feishu MCP for Claude Code & Codex",
|
|
5
|
-
"version": "1.3.
|
|
5
|
+
"version": "1.3.16",
|
|
6
6
|
"description": "All-in-one Feishu MCP server + CLI tool for Claude Code / Codex / Cursor / scripts — 85 tools across 3 auth layers (cookie / app / OAuth). Send as you, read groups, manage docs / bitable / wiki / drive / calendar / tasks / OKR.",
|
|
7
7
|
"long_description": "feishu-user-plugin is a local stdio MCP server (and shell CLI tool) that bridges Feishu / Lark and any MCP client (Claude Code, Codex, Cursor, Windsurf, OpenClaw, Claude Desktop). It exposes 85 tools across three auth layers: cookie + protobuf for sending messages as the real user (a capability not available through the official bot API), Feishu Open Platform app credentials for groups / docs / bitable / wiki / drive / calendar / tasks / OKR, and user OAuth (UAT) for P2P chat reading and user-owned resource creation.",
|
|
8
8
|
"author": {
|
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,44 @@ 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.16] - 2026-06-06
|
|
8
|
+
|
|
9
|
+
修掉发现类读路径的身份盲区:上传到个人空间的文件此前找不到、也因此删不掉。`list_files` / `search_docs` / `search_wiki` / `get_wiki_node` 四条读路径改为 UAT 优先(bot fallback 保留)。85 工具数不变,list_files / search_docs / search_wiki 三个 schema 新增分页参数,无 breaking API。
|
|
10
|
+
|
|
11
|
+
### Added
|
|
12
|
+
|
|
13
|
+
- **list_files 看得见你的个人空间了(用户报障修复)**:此前 `list_files` 走纯 app token,bot 对个人空间("我的空间")文件夹 403,导致 `upload_drive_file` 走 UAT 传上去的文件**不可发现、也不可删除**(`manage_drive_file(action=delete)` 需要的 file_token 拿不到)。现在 UAT 优先、bot fallback:配置 UAT 后空 `folder_token` 列你自己的"我的空间"根目录。新增 `page_size` / `page_token` 入参与 `nextPageToken` 返回;root 空结果且走 bot 路径时附 `scopeHint` 解释 bot root ≠ 我的空间。(`src/clients/official/drive.js`)
|
|
14
|
+
- **search_docs / search_wiki 分页游标**:新增 `page_size` / `offset` 入参,`hasMore` 时返回 `nextOffset` 直接回填即可翻页;此前只有 `hasMore` 没有可用游标,截断的尾部恰好可能藏着要找的个人空间文档。异常的 `has_more:true` 空页不发 cursor,防止翻页死循环。坏参数(NaN / 负数)收敛为非负整数后才发给飞书。
|
|
15
|
+
|
|
16
|
+
### Changed
|
|
17
|
+
|
|
18
|
+
- **search_docs / search_wiki / get_wiki_node 改 UAT-first**:suite 搜索 API 只索引调用身份可见的内容,app 身份搜不到个人空间文档(报障里上传的 PDF 就是这样消失的)。三条路径与 `list_files` 一并走 `_asUserOrApp`(UAT 优先、bot fallback,被迫走 bot 时返回 ⚠ fallbackWarning),响应统一带 `viaUser` 标明视角归属。`get_wiki_node` 保持裸 node 返回形状(resolver 兼容),additive 附加 `viaUser` / `fallbackWarning`;obj_token 合成正则不受新错误形状影响(953001 与 live 实测 131005 双分支测试钉死)。
|
|
19
|
+
- **依赖升级**:protobufjs 7.5.6 → 8.6.0(cookie protobuf 发送层经真实发送探针 + 读回验证);`@larksuiteoapi/node-sdk` 1.63.1 → 1.66.0(official API 读路径实测)。
|
|
20
|
+
- **MCP Registry namespace** 指向 `io.github.zhuzhen-team`(仓库迁移收尾)。
|
|
21
|
+
|
|
22
|
+
### Test scenarios
|
|
23
|
+
|
|
24
|
+
- 配置 UAT 后调 `list_files`(空参)应列出你"我的空间"根目录且 `viaUser:true`
|
|
25
|
+
- `upload_drive_file` 上传 → `list_files` 拿 file_token → `manage_drive_file(action=delete)` 删除 → 再 `list_files` 确认消失
|
|
26
|
+
- `search_docs` 搜个人空间上传的 PDF 标题应能命中,`page_size`+`offset` 翻页两页无重叠
|
|
27
|
+
|
|
28
|
+
## [1.3.15] - 2026-05-31
|
|
29
|
+
|
|
30
|
+
两条增强:文档建表格不再让 agent 猜 block_type;UAT 频繁重新授权的根因(良性 refresh_token 轮换竞态被误判为撤销)修掉。无 schema 变化、无新工具(仍 85)、无 breaking API。升级后重启 Claude Code / Codex 自动拉 v1.3.15。
|
|
31
|
+
|
|
32
|
+
### Added
|
|
33
|
+
|
|
34
|
+
- **manage_doc_block 新增 table 创建模式(mode F)**:`manage_doc_block(action=create, table={rows, columns, cells?, column_width?, header_row?, header_column?})` 一步建表 + 填格。此前 agent 要自己拼 table block 并猜 `block_type`(猜成 40 → 飞书报 `invalid_param`);现在插件内部建 `block_type=31` 表、由飞书自动生成 `block_type=32` 单元格、逐格 UPDATE 单元格自带的空文本块(无遗留空块)。单元格 ID 行优先解析自创建响应,回退到 scoped `getBlockChildren`(不吃整文档 500 块上限),解析不全则报错而非静默丢内容。返回 `tableBlockId` + 行优先 `cells` 网格。`skills/feishu-user-plugin/references/doc.md` 同步补建表 + 决策树指引。
|
|
35
|
+
|
|
36
|
+
### Fixed
|
|
37
|
+
|
|
38
|
+
- **UAT refresh `invalid_grant` 良性轮换竞态自愈**:频繁收到飞书"授权操作通知"、"没撑过一晚上"的根因。飞书 refresh_token 每次刷新滚动轮换;当跨进程互斥失效时(20s 锁超时兜底,或 v1.3.14 升级期间新旧锁路径 `~/.claude/feishu-uat-refresh.lock` vs `~/.feishu-user-plugin/uat-refresh.lock` 不对齐),并发刷新的输家拿 `invalid_grant`,此前 `refreshUAT` 直接判 `UAT_REVOKED` 并提示重跑 oauth——而赢家此刻早已把有效新 token 落盘。现在 `invalid_grant` 时先快照已发送的 refresh_token、回查磁盘,若已有"不同且仍有效"的 token(peer 赢了轮换)就采用并恢复,只有磁盘也是同一个死 token 才真判撤销;按 client 状态判定(而非 `adoptPersistedUATIfNewer` 返回值)兼顾 in-process / credentials-monitor hot-reload race。(`src/auth/uat.js`)
|
|
39
|
+
|
|
40
|
+
### Test scenarios
|
|
41
|
+
|
|
42
|
+
- 多进程 / 多版本并发刷 UAT 时,良性轮换不再弹"授权操作通知"、不再提示重跑 oauth(`auth_time` 不跳)
|
|
43
|
+
- `manage_doc_block(action=create, table={rows:2,columns:2,cells:[["A","B"],["C","D"]]})` 在文档里生成 2×2 表、四格有内容、无空行
|
|
44
|
+
|
|
7
45
|
## [1.3.14] - 2026-05-21
|
|
8
46
|
|
|
9
47
|
**TL;DR**:纯收紧的 bug fix / security release。无 schema 变化、无新工具、无 breaking API。升级后重启 Claude Code / Codex 自动拉 v1.3.14;如果之前还没跑过 `migrate --confirm`,**强烈建议**跑一次(canonical store 是 v1.3.7+ 推荐路径,v1.3.14 把 UAT refresh 锁也搬过来完成最后一块)。
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "feishu-user-plugin",
|
|
3
|
-
"mcpName": "io.github.
|
|
4
|
-
"version": "1.3.
|
|
3
|
+
"mcpName": "io.github.zhuzhen-team/feishu-user-plugin",
|
|
4
|
+
"version": "1.3.16",
|
|
5
5
|
"description": "All-in-one Feishu MCP server + CLI tool for Claude Code / Codex / Cursor / scripts — 85 tools across 3 auth layers (cookie / app / OAuth). Send as you, read groups, manage docs / bitable / wiki / drive / calendar / tasks / OKR.",
|
|
6
6
|
"main": "src/index.js",
|
|
7
7
|
"bin": {
|
|
@@ -64,7 +64,7 @@
|
|
|
64
64
|
"@modelcontextprotocol/sdk": "^1.29.0",
|
|
65
65
|
"dotenv": "^16.4.7",
|
|
66
66
|
"feishu-docx": "^0.7.0",
|
|
67
|
-
"protobufjs": "^
|
|
67
|
+
"protobufjs": "^8.6.0"
|
|
68
68
|
},
|
|
69
69
|
"devDependencies": {
|
|
70
70
|
"husky": "^9.1.7"
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: feishu-user-plugin
|
|
3
|
-
version: "1.3.
|
|
4
|
-
description: "All-in-one Feishu MCP server + CLI tool — send messages as yourself (incl. batch_send), read group/P2P chats (auto-expands merge_forward), manage docs/tables/wiki (full CRUD)/drive, OKR (with progress writes), calendar (read+write), Tasks v2, multi-profile auto-switch, real-time WS events. v1.3.
|
|
3
|
+
version: "1.3.16"
|
|
4
|
+
description: "All-in-one Feishu MCP server + CLI tool — send messages as yourself (incl. batch_send), read group/P2P chats (auto-expands merge_forward), manage docs/tables/wiki (full CRUD)/drive, OKR (with progress writes), calendar (read+write), Tasks v2, multi-profile auto-switch, real-time WS events. v1.3.16: discovery reads go UAT-first — list_files / search_docs / search_wiki / get_wiki_node previously ran app-token-only, so the bot 403'd on personal-space (我的空间) folders and user-uploaded files were undiscoverable and thus undeletable (manage_drive_file needs a file_token only list_files can provide); now they try your user identity first with bot fallback (+⚠ fallbackWarning) and every response carries viaUser. list_files gains page_size/page_token (+nextPageToken), both search tools gain page_size/offset (+nextOffset cursor; withheld on an abnormal empty page to prevent paging loops). Deps: protobufjs 8.6, lark-sdk 1.66."
|
|
5
5
|
allowed-tools: send_to_user, send_to_group, send_as_user, send_image_as_user, send_file_as_user, send_post_as_user, batch_send, send_card_as_user, search_contacts, create_p2p_chat, get_chat_info, get_user_info, get_login_status, list_profiles, switch_profile, manage_profile_hints, read_p2p_messages, list_user_chats, list_chats, read_messages, search_messages, send_message_as_bot, reply_message, forward_message, delete_message, update_message, add_reaction, delete_reaction, pin_message, create_group, update_group, list_members, manage_members, search_docs, read_doc, get_doc_blocks, create_doc, manage_doc_block, read_doc_markdown, manage_bitable_app, manage_bitable_table, manage_bitable_field, manage_bitable_view, manage_bitable_record, upload_bitable_attachment, list_wiki_spaces, search_wiki, list_wiki_nodes, get_wiki_node, create_wiki_node, update_wiki_node, move_wiki_node, copy_wiki_node, delete_wiki_node, list_files, create_folder, upload_drive_file, manage_drive_file, upload_image, upload_file, download_message_resource, download_doc_image, list_user_okrs, get_okrs, list_okr_periods, create_okr_progress_record, list_okr_progress_records, delete_okr_progress_record, list_calendars, list_calendar_events, get_calendar_event, create_calendar_event, update_calendar_event, delete_calendar_event, respond_calendar_event, get_freebusy, list_tasks, get_task, create_task, update_task, complete_task, delete_task, manage_task_members, get_new_events, manage_ws_status
|
|
6
6
|
user_invocable: true
|
|
7
7
|
---
|
|
@@ -17,11 +17,23 @@
|
|
|
17
17
|
1. 用 `create_doc` 创建新文档(传入标题和可选 folder_id)
|
|
18
18
|
2. 返回文档 ID
|
|
19
19
|
|
|
20
|
+
### 在文档里建表格
|
|
21
|
+
1. 用 `manage_doc_block(action=create, document_id, parent_block_id, table={...})` 建表
|
|
22
|
+
- `parent_block_id` 用 `document_id` 表示文档根(或某个块 ID 表示插在该块下)
|
|
23
|
+
- `table` 形如 `{"rows":2,"columns":2,"cells":[["姓名","角色"],["Ann","PM"]]}`:`cells` 行优先,可省略或留空字符串表示空格
|
|
24
|
+
- 插件内部建 `block_type=31` 表格、由飞书自动生成单元格(`block_type=32`)、逐格填内容——**不要自己用 `children` 拼 table block 或猜 block_type**(表格是 31 不是 40,猜错会报 `invalid_param`)
|
|
25
|
+
2. 返回 `tableBlockId` + 行优先的 `cells` 单元格 ID 网格
|
|
26
|
+
|
|
27
|
+
### 写决策树等纯文本结构
|
|
28
|
+
表格只用于真正的二维数据;决策树、流程、缩进结构用 `children` 里的文本块(`block_type=2`)+ 代码块(`block_type=14`)表达即可,不依赖表格线。
|
|
29
|
+
|
|
20
30
|
## 示例
|
|
21
31
|
- `/doc search MCP 协议`
|
|
22
32
|
- `/doc read doxcnXXXXXX`
|
|
23
33
|
- `/doc create 本周工作总结`
|
|
34
|
+
- `/doc 在 doxcnXXXX 里建一个 2×2 表格,表头 姓名/角色`
|
|
24
35
|
|
|
25
36
|
## 注意
|
|
26
37
|
- 文档操作使用 Official API(需要 LARK_APP_ID)
|
|
27
38
|
- 搜索结果受机器人权限范围限制
|
|
39
|
+
- 建表格走 `manage_doc_block` 的 `table` 模式,不要手拼 block——表格 `block_type=31`、单元格 `32`
|
|
@@ -7,18 +7,24 @@
|
|
|
7
7
|
|
|
8
8
|
### 列出文件
|
|
9
9
|
1. 用 `list_files` 列出文件夹内容
|
|
10
|
-
- 不传 folder_token
|
|
10
|
+
- 不传 folder_token 则列出根目录(配置了 UAT 时是**你的**"我的空间"根目录)
|
|
11
11
|
- 传入 folder_token 则列出指定文件夹
|
|
12
|
+
- 结果多时用 `page_token`(来自上一页的 `nextPageToken`)翻页
|
|
12
13
|
|
|
13
14
|
### 创建文件夹
|
|
14
15
|
1. 用 `create_folder` 创建新文件夹
|
|
15
16
|
- 传入 name 和可选的 parent_token
|
|
16
17
|
|
|
18
|
+
### 删除 / 移动 / 复制文件
|
|
19
|
+
1. 用 `list_files` 找到目标的 token
|
|
20
|
+
2. 用 `manage_drive_file` 操作(action=delete/move/copy,必传 type)
|
|
21
|
+
|
|
17
22
|
## 示例
|
|
18
23
|
- `/drive list` — 列出根目录文件
|
|
19
24
|
- `/drive list folderXxx` — 列出指定文件夹
|
|
20
25
|
- `/drive create 项目资料` — 在根目录创建文件夹
|
|
26
|
+
- `/drive 删掉根目录里的 xxx.pdf` — list_files 找 token 后 manage_drive_file 删除
|
|
21
27
|
|
|
22
28
|
## 注意
|
|
23
29
|
- 使用 Official API,需要 LARK_APP_ID
|
|
24
|
-
-
|
|
30
|
+
- `list_files` UAT-first(v1.3.16+):配置了 UAT 就以你的身份列文件(个人空间可见);否则以 bot 身份,只能看到共享给 bot 的文件夹。返回的 `viaUser` 标明视角归属
|
package/src/auth/uat.js
CHANGED
|
@@ -163,6 +163,12 @@ async function refreshUAT(client) {
|
|
|
163
163
|
return client._uat;
|
|
164
164
|
}
|
|
165
165
|
if (!client._uatRefresh) throw new Error('UAT expired and no refresh token. Run: npx feishu-user-plugin oauth');
|
|
166
|
+
// Snapshot the refresh_token we are about to send BEFORE awaiting. A peer
|
|
167
|
+
// in-process refresh or the credentials monitor can hot-reload
|
|
168
|
+
// client._uatRefresh during the round-trip; the invalid_grant self-heal
|
|
169
|
+
// below must compare against the token actually sent, not a field that may
|
|
170
|
+
// have already rotated. (Codex review, PR #111.)
|
|
171
|
+
const attemptedRefresh = client._uatRefresh;
|
|
166
172
|
const res = await fetchWithTimeout('https://open.feishu.cn/open-apis/authen/v2/oauth/token', {
|
|
167
173
|
method: 'POST',
|
|
168
174
|
headers: { 'content-type': 'application/json' },
|
|
@@ -170,7 +176,7 @@ async function refreshUAT(client) {
|
|
|
170
176
|
grant_type: 'refresh_token',
|
|
171
177
|
client_id: client.appId,
|
|
172
178
|
client_secret: client.appSecret,
|
|
173
|
-
refresh_token:
|
|
179
|
+
refresh_token: attemptedRefresh,
|
|
174
180
|
}),
|
|
175
181
|
});
|
|
176
182
|
const data = await res.json();
|
|
@@ -187,6 +193,37 @@ async function refreshUAT(client) {
|
|
|
187
193
|
// UAT_REVOKED, and withIdentityFallback can give the LLM clear guidance.
|
|
188
194
|
const isInvalidGrant = errCode === 'invalid_grant' || errCode === 20064;
|
|
189
195
|
if (isInvalidGrant) {
|
|
196
|
+
// v1.3.15 — self-heal a benign refresh_token rotation race before
|
|
197
|
+
// declaring the 7-day chain dead. When cross-process mutual exclusion
|
|
198
|
+
// is defeated (lock-acquire timeout fallthrough above, or a transient
|
|
199
|
+
// mixed-version upgrade window where old/new instances use different
|
|
200
|
+
// lock paths), two processes can refresh with the same refresh_token;
|
|
201
|
+
// Feishu rotates it for the winner and rejects the loser with
|
|
202
|
+
// invalid_grant. By the time the loser lands here, the winner has very
|
|
203
|
+
// likely already persisted a fresh, valid, DIFFERENT token to disk.
|
|
204
|
+
// Re-read disk: if it now holds a different, still-valid token, our
|
|
205
|
+
// invalid_grant just means "our copy was rotated away" — adopt it and
|
|
206
|
+
// recover, instead of flipping to UAT_REVOKED and pushing the user
|
|
207
|
+
// through a needless `oauth` re-consent (the "授权操作通知 没撑过一晚上"
|
|
208
|
+
// symptom). Only when disk still holds the SAME (now-dead) refresh_token
|
|
209
|
+
// is this a genuine revocation. Covered by test-uat-lifecycle
|
|
210
|
+
// "invalid_grant + peer rotated fresh token to disk".
|
|
211
|
+
now = Math.floor(Date.now() / 1000);
|
|
212
|
+
// Best-effort re-sync from disk (a no-op if a peer/monitor already
|
|
213
|
+
// updated this client in memory). Then recover iff we now hold a
|
|
214
|
+
// DIFFERENT, still-valid token than the one we actually sent — this
|
|
215
|
+
// covers both the cross-process race (disk holds the winner) and the
|
|
216
|
+
// in-process / hot-reload race (client already holds the winner).
|
|
217
|
+
// Gating on the resulting client state, rather than on
|
|
218
|
+
// adoptPersistedUATIfNewer()'s return value, is what lets the
|
|
219
|
+
// hot-reload case recover (adopt is a no-op there). (Codex review, PR #111.)
|
|
220
|
+
adoptPersistedUATIfNewer(client);
|
|
221
|
+
if (client._uat
|
|
222
|
+
&& client._uatRefresh !== attemptedRefresh
|
|
223
|
+
&& client._uatExpires > now + 300) {
|
|
224
|
+
console.error('[feishu-user-plugin] UAT invalid_grant on the sent refresh_token; a different valid token is present (peer won the rotation) — adopted, no re-consent needed');
|
|
225
|
+
return client._uat;
|
|
226
|
+
}
|
|
190
227
|
try {
|
|
191
228
|
const { _refineIdentity, IdentityState } = require('./identity-state');
|
|
192
229
|
_refineIdentity(client, IdentityState.UAT_REVOKED);
|
|
@@ -11,14 +11,31 @@ module.exports = {
|
|
|
11
11
|
// --- Docs ---
|
|
12
12
|
|
|
13
13
|
async searchDocs(query, { pageSize = 10, pageToken } = {}) {
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
);
|
|
21
|
-
|
|
14
|
+
// UAT-first (v1.3.16): the suite search API only indexes docs the calling
|
|
15
|
+
// identity can see. App identity misses everything in the user's personal
|
|
16
|
+
// space — the 2026-06-06 "search_docs 搜不到个人空间 PDF" report.
|
|
17
|
+
// Tool args arrive unvalidated — clamp to sane non-negative integers so a
|
|
18
|
+
// bad offset can't reach Feishu as NaN/negative or corrupt nextOffset
|
|
19
|
+
// math (Copilot review, PR #115).
|
|
20
|
+
const offset = Math.max(0, parseInt(pageToken, 10) || 0);
|
|
21
|
+
const size = Math.max(1, parseInt(pageSize, 10) || 10);
|
|
22
|
+
const body = { search_key: query, count: size, offset, owner_ids: [], chat_ids: [], docs_types: [] };
|
|
23
|
+
const res = await this._asUserOrApp({
|
|
24
|
+
uatPath: '/open-apis/suite/docs-api/search/object',
|
|
25
|
+
method: 'POST',
|
|
26
|
+
body,
|
|
27
|
+
sdkFn: () => this.client.request({ method: 'POST', url: '/open-apis/suite/docs-api/search/object', data: body }),
|
|
28
|
+
label: 'searchDocs',
|
|
29
|
+
});
|
|
30
|
+
const out = { items: res.data.docs_entities || [], hasMore: res.data.has_more, viaUser: !!res._viaUser };
|
|
31
|
+
// Offset-based cursor — hasMore alone gave callers no way to actually
|
|
32
|
+
// page forward, and UAT-wide search makes truncation likelier (the hidden
|
|
33
|
+
// tail may hold the very personal-space doc the user is hunting).
|
|
34
|
+
// Guard on items.length: an abnormal has_more:true + empty page would
|
|
35
|
+
// otherwise emit nextOffset === offset and stall a paging loop.
|
|
36
|
+
if (res.data.has_more && out.items.length > 0) out.nextOffset = offset + out.items.length;
|
|
37
|
+
if (res._fallbackWarning) out.fallbackWarning = res._fallbackWarning;
|
|
38
|
+
return out;
|
|
22
39
|
},
|
|
23
40
|
|
|
24
41
|
async readDoc(documentId) {
|
|
@@ -63,6 +80,22 @@ module.exports = {
|
|
|
63
80
|
return { items: res.data.items || [] };
|
|
64
81
|
},
|
|
65
82
|
|
|
83
|
+
// Direct children of a single block — scoped, so it does not inherit the
|
|
84
|
+
// whole-document 500-block cap of getDocBlocks. Used by createDocTable to map
|
|
85
|
+
// a table's cells (and each cell's text block) reliably in large documents.
|
|
86
|
+
async getBlockChildren(documentId, blockId) {
|
|
87
|
+
const res = await this._asUserOrApp({
|
|
88
|
+
uatPath: `/open-apis/docx/v1/documents/${documentId}/blocks/${blockId}/children`,
|
|
89
|
+
query: { page_size: '500' },
|
|
90
|
+
sdkFn: () => this.client.docx.documentBlockChildren.get({
|
|
91
|
+
path: { document_id: documentId, block_id: blockId },
|
|
92
|
+
params: { page_size: 500 },
|
|
93
|
+
}),
|
|
94
|
+
label: 'getBlockChildren',
|
|
95
|
+
});
|
|
96
|
+
return { items: res.data.items || [] };
|
|
97
|
+
},
|
|
98
|
+
|
|
66
99
|
async createDocBlock(documentId, parentBlockId, children, index) {
|
|
67
100
|
const data = { children };
|
|
68
101
|
if (index !== undefined) data.index = index;
|
|
@@ -79,6 +112,85 @@ module.exports = {
|
|
|
79
112
|
return { blocks: res.data.children || [], fallbackWarning: res._fallbackWarning || null };
|
|
80
113
|
},
|
|
81
114
|
|
|
115
|
+
// Create a Feishu docx table (block_type=31) and optionally fill its cells —
|
|
116
|
+
// so callers never have to know docx block types. Added after field reports
|
|
117
|
+
// of agents guessing the table block_type (40 is wrong; 31 table / 32 cell).
|
|
118
|
+
// Flow:
|
|
119
|
+
// 1) create the table block with row_size/column_size — Feishu auto-creates
|
|
120
|
+
// the table_cell (32) children (row-major) and gives each cell an empty
|
|
121
|
+
// text block.
|
|
122
|
+
// 2) read the table back to map cell_id -> its auto-created text block.
|
|
123
|
+
// 3) fill: UPDATE each cell's existing text block (clean — no stray empty
|
|
124
|
+
// block) when present, else CREATE a text block in the cell.
|
|
125
|
+
// `cells` is an optional row-major 2D array of plain strings.
|
|
126
|
+
// Returns { tableBlockId, cells:[[cellId,...],...], rows, columns, filled, viaUser, fallbackWarning }.
|
|
127
|
+
async createDocTable(documentId, parentBlockId, { rows, columns, cells, columnWidth, headerRow, headerColumn, index } = {}) {
|
|
128
|
+
rows = Number(rows); columns = Number(columns);
|
|
129
|
+
if (!Number.isInteger(rows) || !Number.isInteger(columns) || rows < 1 || columns < 1) {
|
|
130
|
+
throw new Error('createDocTable: rows and columns must be integers >= 1');
|
|
131
|
+
}
|
|
132
|
+
const property = { row_size: rows, column_size: columns };
|
|
133
|
+
if (Array.isArray(columnWidth) && columnWidth.length === columns) property.column_width = columnWidth;
|
|
134
|
+
if (headerRow) property.header_row = true;
|
|
135
|
+
if (headerColumn) property.header_column = true;
|
|
136
|
+
const createBody = { children: [{ block_type: 31, table: { property } }] };
|
|
137
|
+
if (index !== undefined) createBody.index = index;
|
|
138
|
+
const created = await this._asUserOrApp({
|
|
139
|
+
uatPath: `/open-apis/docx/v1/documents/${documentId}/blocks/${parentBlockId}/children`,
|
|
140
|
+
method: 'POST',
|
|
141
|
+
body: createBody,
|
|
142
|
+
sdkFn: () => this.client.docx.documentBlockChildren.create({
|
|
143
|
+
path: { document_id: documentId, block_id: parentBlockId },
|
|
144
|
+
data: createBody,
|
|
145
|
+
}),
|
|
146
|
+
label: 'createDocTable',
|
|
147
|
+
});
|
|
148
|
+
const tableCreated = (created.data.children || [])[0];
|
|
149
|
+
const tableBlockId = tableCreated?.block_id;
|
|
150
|
+
if (!tableBlockId) throw new Error(`createDocTable: no table block_id returned: ${JSON.stringify(created.data).slice(0, 400)}`);
|
|
151
|
+
const viaUser = !!created._viaUser;
|
|
152
|
+
const fallbackWarning = created._fallbackWarning || null;
|
|
153
|
+
|
|
154
|
+
// Resolve the cell IDs. Prefer the create response; else fetch the table
|
|
155
|
+
// block's children directly (scoped — NOT the whole-doc getDocBlocks, which
|
|
156
|
+
// caps at 500 blocks and would silently lose an appended table's cells in a
|
|
157
|
+
// large document). Fail loud rather than silently dropping requested content.
|
|
158
|
+
let flatCellIds = tableCreated.table?.cells || tableCreated.children || [];
|
|
159
|
+
if (flatCellIds.length < rows * columns) {
|
|
160
|
+
flatCellIds = ((await this.getBlockChildren(documentId, tableBlockId)).items || []).map(b => b.block_id);
|
|
161
|
+
}
|
|
162
|
+
if (flatCellIds.length < rows * columns) {
|
|
163
|
+
throw new Error(`createDocTable: created table ${tableBlockId} but resolved only ${flatCellIds.length}/${rows * columns} cells — aborting fill to avoid silently dropping content.`);
|
|
164
|
+
}
|
|
165
|
+
const grid = [];
|
|
166
|
+
for (let r = 0; r < rows; r++) grid.push(flatCellIds.slice(r * columns, (r + 1) * columns));
|
|
167
|
+
|
|
168
|
+
let filled = 0;
|
|
169
|
+
if (Array.isArray(cells)) {
|
|
170
|
+
for (let r = 0; r < rows; r++) {
|
|
171
|
+
for (let c = 0; c < columns; c++) {
|
|
172
|
+
const content = cells[r] ? cells[r][c] : undefined;
|
|
173
|
+
if (content === undefined || content === null || content === '') continue;
|
|
174
|
+
const cellId = grid[r][c];
|
|
175
|
+
if (!cellId) throw new Error(`createDocTable: missing cell id at row ${r}, col ${c}`);
|
|
176
|
+
// Each fresh cell auto-creates exactly one empty text block — UPDATE it
|
|
177
|
+
// (clean) rather than CREATE a second. Scoped per-cell fetch stays
|
|
178
|
+
// correct regardless of overall document size.
|
|
179
|
+
const cellChildren = (await this.getBlockChildren(documentId, cellId)).items || [];
|
|
180
|
+
const textChild = cellChildren.find(b => b.block_type === 2);
|
|
181
|
+
const elements = { elements: [{ text_run: { content: String(content) } }] };
|
|
182
|
+
if (textChild) {
|
|
183
|
+
await this.updateDocBlock(documentId, textChild.block_id, { update_text_elements: elements });
|
|
184
|
+
} else {
|
|
185
|
+
await this.createDocBlock(documentId, cellId, [{ block_type: 2, text: elements }]);
|
|
186
|
+
}
|
|
187
|
+
filled++;
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
return { tableBlockId, cells: grid, rows, columns, filled, viaUser, fallbackWarning };
|
|
192
|
+
},
|
|
193
|
+
|
|
82
194
|
async updateDocBlock(documentId, blockId, updateBody) {
|
|
83
195
|
const res = await this._asUserOrApp({
|
|
84
196
|
uatPath: `/open-apis/docx/v1/documents/${documentId}/blocks/${blockId}`,
|
|
@@ -8,10 +8,35 @@ module.exports = {
|
|
|
8
8
|
// --- Drive ---
|
|
9
9
|
|
|
10
10
|
async listFiles(folderToken, { pageSize = 50, pageToken } = {}) {
|
|
11
|
-
|
|
11
|
+
// UAT-first (v1.3.16): the bot identity 403s on personal-space ("我的空间")
|
|
12
|
+
// folders it was never invited to, which made user-uploaded files (UAT
|
|
13
|
+
// upload path) undiscoverable — and therefore undeletable, because
|
|
14
|
+
// manage_drive_file needs a file_token only list_files can provide.
|
|
15
|
+
// Bot fallback keeps bot-shared folders working. (2026-06-06 user report.)
|
|
16
|
+
const size = Math.max(1, parseInt(pageSize, 10) || 50);
|
|
17
|
+
const params = { page_size: size, folder_token: folderToken || '' };
|
|
12
18
|
if (pageToken) params.page_token = pageToken;
|
|
13
|
-
const
|
|
14
|
-
|
|
19
|
+
const query = { page_size: String(size), folder_token: folderToken || '' };
|
|
20
|
+
if (pageToken) query.page_token = pageToken;
|
|
21
|
+
const res = await this._asUserOrApp({
|
|
22
|
+
uatPath: '/open-apis/drive/v1/files',
|
|
23
|
+
query,
|
|
24
|
+
sdkFn: () => this.client.drive.file.list({ params }),
|
|
25
|
+
label: 'listFiles',
|
|
26
|
+
});
|
|
27
|
+
const out = { items: res.data.files || [], hasMore: res.data.has_more, viaUser: !!res._viaUser };
|
|
28
|
+
if (res.data.next_page_token) out.nextPageToken = res.data.next_page_token;
|
|
29
|
+
if (res._fallbackWarning) out.fallbackWarning = res._fallbackWarning;
|
|
30
|
+
// Empty + bot path + ROOT listing only: with an empty folder_token the
|
|
31
|
+
// bot lists its OWN root space (usually empty), not the user's 我的空间 —
|
|
32
|
+
// that mismatch is the blind spot worth explaining. A specific
|
|
33
|
+
// folder_token the bot cannot access throws (403) and never reaches here,
|
|
34
|
+
// and a bot-visible folder that is genuinely empty should stay a bare []
|
|
35
|
+
// (Copilot review, PR #115).
|
|
36
|
+
if (out.items.length === 0 && !res._viaUser && !folderToken) {
|
|
37
|
+
out.scopeHint = 'Empty result via app identity: with an empty folder_token the bot lists its OWN root space, not your 我的空间 — your personal files are invisible to it. Run `npx feishu-user-plugin oauth` so list_files can read your own space via UAT.';
|
|
38
|
+
}
|
|
39
|
+
return out;
|
|
15
40
|
},
|
|
16
41
|
|
|
17
42
|
async createFolder(name, parentToken) {
|
|
@@ -28,12 +28,31 @@ module.exports = {
|
|
|
28
28
|
return out;
|
|
29
29
|
},
|
|
30
30
|
|
|
31
|
-
async searchWiki(query) {
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
)
|
|
36
|
-
|
|
31
|
+
async searchWiki(query, { pageSize = 20, offset = 0 } = {}) {
|
|
32
|
+
// UAT-first (v1.3.16): same blind spot as searchDocs — the suite search
|
|
33
|
+
// API only indexes entities the calling identity can see, so the app
|
|
34
|
+
// identity misses wiki nodes in spaces the bot wasn't invited to.
|
|
35
|
+
// Clamp unvalidated tool args (Copilot review, PR #115).
|
|
36
|
+
const safeOffset = Math.max(0, parseInt(offset, 10) || 0);
|
|
37
|
+
const size = Math.max(1, parseInt(pageSize, 10) || 20);
|
|
38
|
+
const body = { search_key: query, count: size, offset: safeOffset, owner_ids: [], chat_ids: [], docs_types: ['wiki'] };
|
|
39
|
+
const res = await this._asUserOrApp({
|
|
40
|
+
uatPath: '/open-apis/suite/docs-api/search/object',
|
|
41
|
+
method: 'POST',
|
|
42
|
+
body,
|
|
43
|
+
sdkFn: () => this.client.request({ method: 'POST', url: '/open-apis/suite/docs-api/search/object', data: body }),
|
|
44
|
+
label: 'searchWiki',
|
|
45
|
+
});
|
|
46
|
+
const out = { items: res.data.docs_entities || [], hasMore: res.data.has_more, viaUser: !!res._viaUser };
|
|
47
|
+
// The suite search API is offset-based; hand the caller a ready-to-use
|
|
48
|
+
// cursor so paging doesn't require manual offset math (UAT-wide search
|
|
49
|
+
// makes truncation likelier — the hidden tail may hold the very
|
|
50
|
+
// personal-space doc the user is hunting).
|
|
51
|
+
// Guard on items.length: see searchDocs — prevents a stalled cursor on an
|
|
52
|
+
// abnormal has_more:true + empty page.
|
|
53
|
+
if (res.data.has_more && out.items.length > 0) out.nextOffset = safeOffset + out.items.length;
|
|
54
|
+
if (res._fallbackWarning) out.fallbackWarning = res._fallbackWarning;
|
|
55
|
+
return out;
|
|
37
56
|
},
|
|
38
57
|
|
|
39
58
|
// Resolves a wiki node token to its underlying object (docx / sheet / bitable / ...).
|
|
@@ -46,8 +65,27 @@ module.exports = {
|
|
|
46
65
|
// and returns a synthesized node-shaped result so callers don't have to know
|
|
47
66
|
// which ID space they're holding.
|
|
48
67
|
async getWikiNode(nodeToken, _spaceId) {
|
|
49
|
-
|
|
50
|
-
|
|
68
|
+
// UAT-first (v1.3.16): bot identity hits permission errors on spaces it
|
|
69
|
+
// wasn't invited to (same class as listWikiNodes' 131006). The dual-failure
|
|
70
|
+
// error from _asUserOrApp embeds the Feishu code ("as user: code=953001
|
|
71
|
+
// ..."), so the obj_token detection regex in tools/wiki.js keeps working.
|
|
72
|
+
const res = await this._asUserOrApp({
|
|
73
|
+
uatPath: '/open-apis/wiki/v2/spaces/get_node',
|
|
74
|
+
query: { token: nodeToken },
|
|
75
|
+
sdkFn: () => this.client.wiki.space.getNode({ params: { token: nodeToken } }),
|
|
76
|
+
label: 'getNode',
|
|
77
|
+
});
|
|
78
|
+
const node = res.data.node;
|
|
79
|
+
// Keep the bare-node return shape (resolver.js reads obj_token/obj_type
|
|
80
|
+
// off it), but attach identity metadata additively so the get_wiki_node
|
|
81
|
+
// tool surfaces degradation like its 3 sibling discovery reads — without
|
|
82
|
+
// this, a UAT-revoked → bot fallback would silently swallow the warning
|
|
83
|
+
// (json() hoists `fallbackWarning` only when it is on the returned object).
|
|
84
|
+
if (node && typeof node === 'object') {
|
|
85
|
+
node.viaUser = !!res._viaUser;
|
|
86
|
+
if (res._fallbackWarning) node.fallbackWarning = res._fallbackWarning;
|
|
87
|
+
}
|
|
88
|
+
return node;
|
|
51
89
|
},
|
|
52
90
|
|
|
53
91
|
async listWikiNodes(spaceId, { parentNodeToken, pageToken } = {}) {
|
package/src/test-all.js
CHANGED
|
@@ -322,6 +322,10 @@ async function main() {
|
|
|
322
322
|
main().catch(console.error).finally(() => {
|
|
323
323
|
// Fixture-based unit test — runs regardless of credential availability
|
|
324
324
|
require('./test-read-doc-markdown').run();
|
|
325
|
+
require('./test-doc-table').run().catch(e => {
|
|
326
|
+
console.error('doc-table: FAIL', e);
|
|
327
|
+
process.exitCode = 1;
|
|
328
|
+
});
|
|
325
329
|
require('./test-switch-profile').run().catch(e => {
|
|
326
330
|
console.error('switch-profile-e2e: FAIL', e);
|
|
327
331
|
process.exitCode = 1;
|
|
@@ -373,6 +377,10 @@ main().catch(console.error).finally(() => {
|
|
|
373
377
|
console.error('search-messages: FAIL', e);
|
|
374
378
|
process.exitCode = 1;
|
|
375
379
|
});
|
|
380
|
+
require('./test-uat-read-paths').run().catch(e => {
|
|
381
|
+
console.error('uat-read-paths: FAIL', e);
|
|
382
|
+
process.exitCode = 1;
|
|
383
|
+
});
|
|
376
384
|
require('./test-cli-tool').run();
|
|
377
385
|
require('./test-lark-desktop').run();
|
|
378
386
|
require('./test-display-label'); // standalone — runs on require, exits non-zero on fail
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Unit tests for createDocTable (manage_doc_block create mode F — tables).
|
|
3
|
+
//
|
|
4
|
+
// Guards the payload contract (block_type=31 table, row_size/column_size), the
|
|
5
|
+
// cell-fill behaviour (UPDATE an existing auto-created text block — no stray
|
|
6
|
+
// empty blocks — else CREATE one), and the fail-loud behaviour when the table's
|
|
7
|
+
// cells cannot be resolved (so large docs never silently drop content). Pure
|
|
8
|
+
// unit: the client methods (_asUserOrApp / getBlockChildren / updateDocBlock /
|
|
9
|
+
// createDocBlock) are stubbed, so no network. End-to-end behaviour is verified
|
|
10
|
+
// separately against live Feishu (create doc → table → read back → delete).
|
|
11
|
+
'use strict';
|
|
12
|
+
|
|
13
|
+
const assert = require('assert');
|
|
14
|
+
const docs = require('./clients/official/docs');
|
|
15
|
+
|
|
16
|
+
let pass = 0, fail = 0;
|
|
17
|
+
async function ok(name, fn) {
|
|
18
|
+
try { await fn(); console.log(` OK ${name}`); pass++; }
|
|
19
|
+
catch (e) { console.log(` FAIL ${name}: ${e.message}`); fail++; }
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
// Build a stubbed `this` for createDocTable.
|
|
23
|
+
// cellIds — the table's cells (row-major)
|
|
24
|
+
// cellsInCreate — true: create response carries cell ids; false: forces a
|
|
25
|
+
// scoped getBlockChildren(table) lookup
|
|
26
|
+
// cellHasText — true: each cell already has an auto text block (UPDATE);
|
|
27
|
+
// false: empty cell (CREATE)
|
|
28
|
+
// resolvableCells — cell ids that getBlockChildren(table) will return (defaults
|
|
29
|
+
// to cellIds); set shorter to exercise fail-loud
|
|
30
|
+
function stub({ cellIds, cellsInCreate = true, cellHasText = true, resolvableCells } = {}) {
|
|
31
|
+
const calls = { createBody: null, updates: [], creates: [], childFetches: [] };
|
|
32
|
+
const self = {
|
|
33
|
+
async _asUserOrApp({ body }) {
|
|
34
|
+
calls.createBody = body;
|
|
35
|
+
const tbl = { block_id: 'tbl1' };
|
|
36
|
+
if (cellsInCreate) tbl.children = cellIds;
|
|
37
|
+
return { data: { children: [tbl] }, _viaUser: true, _fallbackWarning: null };
|
|
38
|
+
},
|
|
39
|
+
async getBlockChildren(documentId, blockId) {
|
|
40
|
+
calls.childFetches.push(blockId);
|
|
41
|
+
if (blockId === 'tbl1') {
|
|
42
|
+
const ids = resolvableCells || cellIds;
|
|
43
|
+
return { items: ids.map(id => ({ block_id: id, block_type: 32 })) };
|
|
44
|
+
}
|
|
45
|
+
// a cell → its auto text block (or none)
|
|
46
|
+
return { items: cellHasText ? [{ block_id: 't-' + blockId, block_type: 2 }] : [] };
|
|
47
|
+
},
|
|
48
|
+
async updateDocBlock(documentId, blockId, body) { calls.updates.push({ blockId, body }); return { block: {} }; },
|
|
49
|
+
async createDocBlock(documentId, parent, children) { calls.creates.push({ parent, children }); return { blocks: [] }; },
|
|
50
|
+
};
|
|
51
|
+
return { self, calls };
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
async function run() {
|
|
55
|
+
console.log('=== test-doc-table ===');
|
|
56
|
+
|
|
57
|
+
await ok('builds block_type=31 payload + fills by UPDATEing each cell\'s existing text (no stray blocks)', async () => {
|
|
58
|
+
const { self, calls } = stub({ cellIds: ['c00', 'c01', 'c10', 'c11'], cellsInCreate: true, cellHasText: true });
|
|
59
|
+
const r = await docs.createDocTable.call(self, 'docX', 'docX', { rows: 2, columns: 2, cells: [['A', 'B'], ['C', 'D']] });
|
|
60
|
+
const tableBody = calls.createBody.children[0];
|
|
61
|
+
assert.strictEqual(tableBody.block_type, 31, 'table block_type must be 31 (not 40)');
|
|
62
|
+
assert.strictEqual(tableBody.table.property.row_size, 2);
|
|
63
|
+
assert.strictEqual(tableBody.table.property.column_size, 2);
|
|
64
|
+
assert.deepStrictEqual(r.cells, [['c00', 'c01'], ['c10', 'c11']], 'cells mapped row-major');
|
|
65
|
+
assert.strictEqual(r.filled, 4);
|
|
66
|
+
assert.strictEqual(calls.updates.length, 4, 'should UPDATE 4 existing cell text blocks');
|
|
67
|
+
assert.strictEqual(calls.creates.length, 0, 'should NOT create extra blocks when the cell already has a text block');
|
|
68
|
+
assert.strictEqual(calls.updates[0].body.update_text_elements.elements[0].text_run.content, 'A');
|
|
69
|
+
assert.strictEqual(r.viaUser, true);
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
await ok('CREATEs a text block when a cell has no auto text block', async () => {
|
|
73
|
+
const { self, calls } = stub({ cellIds: ['c0', 'c1'], cellsInCreate: true, cellHasText: false });
|
|
74
|
+
const r = await docs.createDocTable.call(self, 'd', 'd', { rows: 1, columns: 2, cells: [['X', 'Y']] });
|
|
75
|
+
assert.strictEqual(r.filled, 2);
|
|
76
|
+
assert.strictEqual(calls.creates.length, 2, 'should CREATE a text block in each empty cell');
|
|
77
|
+
assert.strictEqual(calls.updates.length, 0);
|
|
78
|
+
assert.strictEqual(calls.creates[0].children[0].block_type, 2, 'created child is a text block');
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
await ok('resolves cells via scoped getBlockChildren when the create response lacks them', async () => {
|
|
82
|
+
const { self, calls } = stub({ cellIds: ['c0', 'c1'], cellsInCreate: false, cellHasText: true });
|
|
83
|
+
const r = await docs.createDocTable.call(self, 'd', 'd', { rows: 1, columns: 2, cells: [['X', 'Y']] });
|
|
84
|
+
assert.deepStrictEqual(r.cells, [['c0', 'c1']]);
|
|
85
|
+
assert.strictEqual(r.filled, 2);
|
|
86
|
+
assert.ok(calls.childFetches.includes('tbl1'), 'should scope-fetch the table block children when create response lacks cells');
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
await ok('fails loud (throws) when cells cannot be fully resolved — never silently drops content', async () => {
|
|
90
|
+
// create response lacks cells AND scoped lookup returns too few (e.g. >500-block doc)
|
|
91
|
+
const { self } = stub({ cellIds: ['c0', 'c1'], cellsInCreate: false, resolvableCells: ['c0'] });
|
|
92
|
+
let threw = false, msg = '';
|
|
93
|
+
try { await docs.createDocTable.call(self, 'd', 'd', { rows: 1, columns: 2, cells: [['X', 'Y']] }); }
|
|
94
|
+
catch (e) { threw = true; msg = e.message; }
|
|
95
|
+
assert.ok(threw, 'should throw rather than return a low-filled success');
|
|
96
|
+
assert.ok(/resolved only 1\/2 cells/.test(msg), `error should name the shortfall: ${msg}`);
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
await ok('leaves omitted/blank cells empty and counts only filled', async () => {
|
|
100
|
+
const { self, calls } = stub({ cellIds: ['c0', 'c1', 'c2', 'c3'], cellsInCreate: true, cellHasText: true });
|
|
101
|
+
const r = await docs.createDocTable.call(self, 'd', 'd', { rows: 2, columns: 2, cells: [['only', ''], [null, 'here']] });
|
|
102
|
+
assert.strictEqual(r.filled, 2, 'blank/null cells are skipped');
|
|
103
|
+
assert.strictEqual(calls.updates.length, 2);
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
await ok('rejects rows/columns < 1', async () => {
|
|
107
|
+
const { self } = stub({ cellIds: [] });
|
|
108
|
+
for (const bad of [{ rows: 0, columns: 2 }, { rows: 2, columns: 0 }, { rows: -1, columns: 1 }]) {
|
|
109
|
+
let threw = false;
|
|
110
|
+
try { await docs.createDocTable.call(self, 'd', 'd', bad); } catch (_) { threw = true; }
|
|
111
|
+
assert.ok(threw, `rows/columns ${JSON.stringify(bad)} should throw`);
|
|
112
|
+
}
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
console.log(`\n=== test-doc-table: ${pass} passed, ${fail} failed ===`);
|
|
116
|
+
if (fail > 0) process.exit(1);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
if (require.main === module) {
|
|
120
|
+
run().catch((e) => { console.error('test-doc-table harness error:', e); process.exit(1); });
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
module.exports = { run };
|
|
@@ -276,6 +276,101 @@ async function run() {
|
|
|
276
276
|
}
|
|
277
277
|
});
|
|
278
278
|
|
|
279
|
+
// --- refreshUAT: benign rotation race must self-heal, not false-revoke ---
|
|
280
|
+
//
|
|
281
|
+
// A peer process wins the refresh_token rotation and persists a fresh, VALID
|
|
282
|
+
// token to disk DURING our refresh round-trip; our now-stale refresh_token
|
|
283
|
+
// then comes back invalid_grant. refreshUAT must adopt the peer's on-disk
|
|
284
|
+
// token and recover, instead of throwing uatRevoked (which would push the
|
|
285
|
+
// user through a needless `npx feishu-user-plugin oauth` re-consent — the
|
|
286
|
+
// root cause of the "授权操作通知 没撑过一晚上" reports).
|
|
287
|
+
await ok('refreshUAT: invalid_grant + peer rotated fresh token to disk → adopts & recovers (no false revoke)', async () => {
|
|
288
|
+
os.homedir = () => fakeHome;
|
|
289
|
+
const now = Math.floor(Date.now() / 1000);
|
|
290
|
+
// Disk starts equal to our stale in-memory token so the pre-fetch adopt
|
|
291
|
+
// checks find nothing newer and we proceed into the refresh fetch.
|
|
292
|
+
writeCanonical({
|
|
293
|
+
LARK_USER_ACCESS_TOKEN: 'stale.access',
|
|
294
|
+
LARK_USER_REFRESH_TOKEN: 'stale.refresh',
|
|
295
|
+
LARK_UAT_EXPIRES: String(now - 3600),
|
|
296
|
+
});
|
|
297
|
+
|
|
298
|
+
const origFetch = global.fetch;
|
|
299
|
+
global.fetch = async () => {
|
|
300
|
+
// The winning peer finishes mid-round-trip: persists a fresh VALID token.
|
|
301
|
+
writeCanonical({
|
|
302
|
+
LARK_USER_ACCESS_TOKEN: 'winner.access',
|
|
303
|
+
LARK_USER_REFRESH_TOKEN: 'winner.refresh',
|
|
304
|
+
LARK_UAT_EXPIRES: String(now + 7200),
|
|
305
|
+
});
|
|
306
|
+
// Our stale refresh_token was rotated away on the Feishu side.
|
|
307
|
+
return { json: async () => ({ error: 'invalid_grant', error_description: 'refresh_token expired' }) };
|
|
308
|
+
};
|
|
309
|
+
|
|
310
|
+
try {
|
|
311
|
+
const client = {
|
|
312
|
+
appId: 'test', appSecret: 'test',
|
|
313
|
+
_uat: 'stale.access',
|
|
314
|
+
_uatRefresh: 'stale.refresh',
|
|
315
|
+
_uatExpires: now - 3600,
|
|
316
|
+
};
|
|
317
|
+
let thrown = null, ret = null;
|
|
318
|
+
try { ret = await uat.refreshUAT(client); } catch (e) { thrown = e; }
|
|
319
|
+
assert.ok(!thrown, `should recover, not throw; got: ${thrown && thrown.message}`);
|
|
320
|
+
assert.strictEqual(ret, 'winner.access', 'should return the peer-rotated access token');
|
|
321
|
+
assert.strictEqual(client._uatRefresh, 'winner.refresh', 'should adopt the peer-rotated refresh_token');
|
|
322
|
+
} finally {
|
|
323
|
+
global.fetch = origFetch;
|
|
324
|
+
os.homedir = origHomedir;
|
|
325
|
+
}
|
|
326
|
+
});
|
|
327
|
+
|
|
328
|
+
// Codex review (PR #111) edge: a peer in-process refresh / credentials-monitor
|
|
329
|
+
// hot-reloads THIS client in memory (and persists to disk) WHILE our refresh
|
|
330
|
+
// request — carrying the now-stale token — is still in flight. Because we
|
|
331
|
+
// snapshot the sent token before awaiting and gate recovery on client state
|
|
332
|
+
// (not adoptPersistedUATIfNewer's return value), we must still recover instead
|
|
333
|
+
// of false-revoking. With the pre-fix code (post-await capture) this throws.
|
|
334
|
+
await ok('refreshUAT: invalid_grant after mid-flight hot-reload to a fresh token → recovers (no false revoke)', async () => {
|
|
335
|
+
os.homedir = () => fakeHome;
|
|
336
|
+
const now = Math.floor(Date.now() / 1000);
|
|
337
|
+
// Disk starts stale so the pre-fetch adopt checks proceed into the fetch.
|
|
338
|
+
writeCanonical({
|
|
339
|
+
LARK_USER_ACCESS_TOKEN: 'stale.access',
|
|
340
|
+
LARK_USER_REFRESH_TOKEN: 'stale.refresh',
|
|
341
|
+
LARK_UAT_EXPIRES: String(now - 3600),
|
|
342
|
+
});
|
|
343
|
+
const client = {
|
|
344
|
+
appId: 'test', appSecret: 'test',
|
|
345
|
+
_uat: 'stale.access', _uatRefresh: 'stale.refresh', _uatExpires: now - 3600,
|
|
346
|
+
};
|
|
347
|
+
|
|
348
|
+
const origFetch = global.fetch;
|
|
349
|
+
global.fetch = async () => {
|
|
350
|
+
// Concurrent winner finishes mid-flight: persists rotated token to disk
|
|
351
|
+
// AND a hot-reload updates this very client in memory.
|
|
352
|
+
writeCanonical({
|
|
353
|
+
LARK_USER_ACCESS_TOKEN: 'winner.access',
|
|
354
|
+
LARK_USER_REFRESH_TOKEN: 'winner.refresh',
|
|
355
|
+
LARK_UAT_EXPIRES: String(now + 7200),
|
|
356
|
+
});
|
|
357
|
+
client._uat = 'winner.access';
|
|
358
|
+
client._uatRefresh = 'winner.refresh';
|
|
359
|
+
client._uatExpires = now + 7200;
|
|
360
|
+
return { json: async () => ({ error: 'invalid_grant', error_description: 'refresh_token expired' }) };
|
|
361
|
+
};
|
|
362
|
+
|
|
363
|
+
try {
|
|
364
|
+
let thrown = null, ret = null;
|
|
365
|
+
try { ret = await uat.refreshUAT(client); } catch (e) { thrown = e; }
|
|
366
|
+
assert.ok(!thrown, `should recover, not throw; got: ${thrown && thrown.message}`);
|
|
367
|
+
assert.strictEqual(ret, 'winner.access', 'should recover the winner token despite mid-flight hot-reload');
|
|
368
|
+
} finally {
|
|
369
|
+
global.fetch = origFetch;
|
|
370
|
+
os.homedir = origHomedir;
|
|
371
|
+
}
|
|
372
|
+
});
|
|
373
|
+
|
|
279
374
|
// identity-state.js redact-regex regression guard. Exercises the actual
|
|
280
375
|
// regex on `_classifyUatFailure` path that the previous "no dead.refresh"
|
|
281
376
|
// assertion in test #14 did NOT cover (the invalid_grant message is
|
|
@@ -0,0 +1,313 @@
|
|
|
1
|
+
// src/test-uat-read-paths.js — verify discovery-read paths are UAT-first.
|
|
2
|
+
//
|
|
3
|
+
// Background (2026-06-06 user report): upload_drive_file goes UAT (file owned
|
|
4
|
+
// by the user), but list_files went app-token-only → bot gets 403 on personal
|
|
5
|
+
// space folders ("我的空间"), so uploaded files were undiscoverable and thus
|
|
6
|
+
// undeletable (manage_drive_file needs a file_token the user can't obtain).
|
|
7
|
+
// search_docs had the same blind spot (personal-space files not indexed for
|
|
8
|
+
// the bot identity). searchWiki / getWikiNode shared the app-only pattern.
|
|
9
|
+
//
|
|
10
|
+
// Fix: route listFiles / searchDocs / searchWiki / getWikiNode through
|
|
11
|
+
// _asUserOrApp (UAT-first, bot fallback + fallbackWarning), matching
|
|
12
|
+
// listWikiSpaces / listWikiNodes which were already UAT-first.
|
|
13
|
+
//
|
|
14
|
+
// Tests stub `this._asUserOrApp` at the mixin level (methods are mixed into
|
|
15
|
+
// LarkOfficialClient.prototype; binding them to a fake `this` is the
|
|
16
|
+
// supported seam — same approach as test-via-user.js's fakeCtx).
|
|
17
|
+
|
|
18
|
+
'use strict';
|
|
19
|
+
|
|
20
|
+
const assert = require('node:assert/strict');
|
|
21
|
+
|
|
22
|
+
const driveMixin = require('./clients/official/drive');
|
|
23
|
+
const docsMixin = require('./clients/official/docs');
|
|
24
|
+
const wikiMixin = require('./clients/official/wiki');
|
|
25
|
+
|
|
26
|
+
// fake `this` for mixin methods. Records _asUserOrApp / _safeSDKCall calls.
|
|
27
|
+
// uatResult is what _asUserOrApp resolves to (shape: legacy asUserOrApp
|
|
28
|
+
// contract — data object with _viaUser + optional _fallbackWarning).
|
|
29
|
+
function fakeClient({ uatResult, sdkResult }) {
|
|
30
|
+
const calls = { asUserOrApp: [], safeSDKCall: [] };
|
|
31
|
+
return {
|
|
32
|
+
calls,
|
|
33
|
+
async _asUserOrApp(opts) {
|
|
34
|
+
calls.asUserOrApp.push(opts);
|
|
35
|
+
return uatResult;
|
|
36
|
+
},
|
|
37
|
+
async _safeSDKCall(fn, label) {
|
|
38
|
+
calls.safeSDKCall.push(label);
|
|
39
|
+
// Default shape covers all four legacy call sites so pre-fix code fails
|
|
40
|
+
// on the routing assertions (clean RED) instead of a TypeError here.
|
|
41
|
+
return sdkResult || { code: 0, data: { files: [], has_more: false, docs_entities: [], node: {} } };
|
|
42
|
+
},
|
|
43
|
+
// SDK surface — only reached via the sdkFn closures, which these tests
|
|
44
|
+
// never execute (the _asUserOrApp stub doesn't call sdkFn).
|
|
45
|
+
client: {
|
|
46
|
+
drive: { file: { list: async () => { throw new Error('sdkFn should not run in these tests'); } } },
|
|
47
|
+
wiki: { space: { getNode: async () => { throw new Error('sdkFn should not run in these tests'); } } },
|
|
48
|
+
request: async () => { throw new Error('sdkFn should not run in these tests'); },
|
|
49
|
+
},
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
async function run() {
|
|
54
|
+
// --- 1. listFiles is UAT-first via _asUserOrApp ---
|
|
55
|
+
{
|
|
56
|
+
const c = fakeClient({
|
|
57
|
+
uatResult: { code: 0, data: { files: [{ token: 'boxcnX', name: 'a.pdf' }], has_more: false }, _viaUser: true },
|
|
58
|
+
});
|
|
59
|
+
const res = await driveMixin.listFiles.call(c, 'fldcnROOT');
|
|
60
|
+
assert.equal(c.calls.asUserOrApp.length, 1, 'listFiles must route through _asUserOrApp (UAT-first)');
|
|
61
|
+
assert.equal(c.calls.safeSDKCall.length, 0, 'listFiles must not call _safeSDKCall directly (app-only blind spot)');
|
|
62
|
+
const opts = c.calls.asUserOrApp[0];
|
|
63
|
+
assert.equal(opts.uatPath, '/open-apis/drive/v1/files', 'listFiles UAT path');
|
|
64
|
+
assert.equal(opts.query.folder_token, 'fldcnROOT');
|
|
65
|
+
assert.ok(opts.sdkFn, 'bot fallback must be preserved');
|
|
66
|
+
assert.equal(res.viaUser, true, 'viaUser surfaced');
|
|
67
|
+
assert.equal(res.items.length, 1);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// --- 2. listFiles surfaces fallbackWarning + scopeHint on bot path ---
|
|
71
|
+
{
|
|
72
|
+
const c = fakeClient({
|
|
73
|
+
uatResult: { code: 0, data: { files: [], has_more: false }, _viaUser: false, _fallbackWarning: '⚠️ test-warning' },
|
|
74
|
+
});
|
|
75
|
+
const res = await driveMixin.listFiles.call(c, '');
|
|
76
|
+
assert.equal(res.viaUser, false);
|
|
77
|
+
assert.equal(res.fallbackWarning, '⚠️ test-warning', 'fallbackWarning must surface so ownership blind spot is visible');
|
|
78
|
+
assert.ok(res.scopeHint && /403|个人|personal|my space|我的空间|scope/i.test(res.scopeHint),
|
|
79
|
+
'empty bot-path result must carry a scopeHint explaining the personal-space blind spot');
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// --- 3. listFiles passes pagination through ---
|
|
83
|
+
{
|
|
84
|
+
const c = fakeClient({
|
|
85
|
+
uatResult: { code: 0, data: { files: [], has_more: true, next_page_token: 'NPT' }, _viaUser: true },
|
|
86
|
+
});
|
|
87
|
+
const res = await driveMixin.listFiles.call(c, 'fld', { pageSize: 10, pageToken: 'PT' });
|
|
88
|
+
const opts = c.calls.asUserOrApp[0];
|
|
89
|
+
assert.equal(String(opts.query.page_size), '10');
|
|
90
|
+
assert.equal(opts.query.page_token, 'PT');
|
|
91
|
+
assert.equal(res.nextPageToken, 'NPT', 'next_page_token must surface for pagination');
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// --- 4. searchDocs is UAT-first ---
|
|
95
|
+
{
|
|
96
|
+
const c = fakeClient({
|
|
97
|
+
uatResult: { code: 0, data: { docs_entities: [{ docs_token: 'boxcnY' }], has_more: false }, _viaUser: true },
|
|
98
|
+
});
|
|
99
|
+
const res = await docsMixin.searchDocs.call(c, 'PDF 报告');
|
|
100
|
+
assert.equal(c.calls.asUserOrApp.length, 1, 'searchDocs must route through _asUserOrApp');
|
|
101
|
+
const opts = c.calls.asUserOrApp[0];
|
|
102
|
+
assert.equal(opts.uatPath, '/open-apis/suite/docs-api/search/object');
|
|
103
|
+
assert.equal(opts.method, 'POST');
|
|
104
|
+
assert.equal(opts.body.search_key, 'PDF 报告');
|
|
105
|
+
assert.deepEqual(opts.body.docs_types, [], 'searchDocs searches all types');
|
|
106
|
+
assert.equal(res.viaUser, true);
|
|
107
|
+
assert.equal(res.items.length, 1);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// --- 5. searchWiki is UAT-first, scoped to wiki ---
|
|
111
|
+
{
|
|
112
|
+
const c = fakeClient({
|
|
113
|
+
uatResult: { code: 0, data: { docs_entities: [] }, _viaUser: false, _fallbackWarning: '⚠️ w' },
|
|
114
|
+
});
|
|
115
|
+
const res = await wikiMixin.searchWiki.call(c, 'roadmap');
|
|
116
|
+
assert.equal(c.calls.asUserOrApp.length, 1, 'searchWiki must route through _asUserOrApp');
|
|
117
|
+
const opts = c.calls.asUserOrApp[0];
|
|
118
|
+
assert.equal(opts.uatPath, '/open-apis/suite/docs-api/search/object');
|
|
119
|
+
assert.deepEqual(opts.body.docs_types, ['wiki'], 'searchWiki restricted to wiki entities');
|
|
120
|
+
assert.equal(res.viaUser, false);
|
|
121
|
+
assert.equal(res.fallbackWarning, '⚠️ w');
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// --- 6. getWikiNode is UAT-first and surfaces viaUser ---
|
|
125
|
+
{
|
|
126
|
+
const c = fakeClient({
|
|
127
|
+
uatResult: { code: 0, data: { node: { node_token: 'wikcnZ', obj_type: 'docx' } }, _viaUser: true },
|
|
128
|
+
});
|
|
129
|
+
const node = await wikiMixin.getWikiNode.call(c, 'wikcnZ');
|
|
130
|
+
assert.equal(c.calls.asUserOrApp.length, 1, 'getWikiNode must route through _asUserOrApp');
|
|
131
|
+
const opts = c.calls.asUserOrApp[0];
|
|
132
|
+
assert.equal(opts.uatPath, '/open-apis/wiki/v2/spaces/get_node');
|
|
133
|
+
assert.equal(opts.query.token, 'wikcnZ');
|
|
134
|
+
assert.equal(node.node_token, 'wikcnZ');
|
|
135
|
+
assert.equal(node.viaUser, true, 'getWikiNode must surface viaUser like its 3 sibling reads');
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// --- 6b. getWikiNode bot fallback must NOT swallow the fallbackWarning ---
|
|
139
|
+
// The warning lives on the top-level data object from withIdentityFallback,
|
|
140
|
+
// not on data.node — without explicit copying, a UAT-revoked → bot fallback
|
|
141
|
+
// silently drops it (caught by multi-agent review of the original commit).
|
|
142
|
+
{
|
|
143
|
+
const c = fakeClient({
|
|
144
|
+
uatResult: { code: 0, data: { node: { node_token: 'wikcnZ', obj_type: 'docx' } }, _viaUser: false, _fallbackWarning: '⚠️ g' },
|
|
145
|
+
});
|
|
146
|
+
const node = await wikiMixin.getWikiNode.call(c, 'wikcnZ');
|
|
147
|
+
assert.equal(node.viaUser, false);
|
|
148
|
+
assert.equal(node.fallbackWarning, '⚠️ g', 'fallbackWarning must survive onto the node so json() hoists it');
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// --- 7. get_wiki_node handler still synthesizes for obj_tokens on dual failure ---
|
|
152
|
+
// withIdentityFallback dual-failure error message embeds the Feishu code
|
|
153
|
+
// (e.g. "as user: code=953001 ..."). The handler's /95300\d/ detection must
|
|
154
|
+
// keep matching so search_wiki obj_tokens (docxXXX) still resolve.
|
|
155
|
+
{
|
|
156
|
+
const { handlers } = require('./tools/wiki');
|
|
157
|
+
const err = new Error('getNode failed on both identities. as user: code=953001 msg=node not found. as app: getNode failed (953001): invalid token');
|
|
158
|
+
const ctx = {
|
|
159
|
+
getOfficialClient: () => ({
|
|
160
|
+
getWikiNode: async () => { throw err; },
|
|
161
|
+
}),
|
|
162
|
+
};
|
|
163
|
+
const resp = await handlers.get_wiki_node({ node_token: 'docxabcdef' }, ctx);
|
|
164
|
+
const body = JSON.parse(resp.content[0].text);
|
|
165
|
+
assert.equal(body.obj_type, 'docx', 'obj_token synthesis must survive the dual-identity error shape');
|
|
166
|
+
assert.equal(body.obj_token, 'docxabcdef');
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// --- 7b. synthesis also survives the LIVE error shape (131005, not 95300x) ---
|
|
170
|
+
// Real Feishu instances return code=131005 "not found" for non-wiki tokens
|
|
171
|
+
// (observed in E2E 2026-06-06); only the `node.*not.*found` regex branch
|
|
172
|
+
// catches it. Pin that branch so a regex edit can't silently regress it.
|
|
173
|
+
{
|
|
174
|
+
const { handlers } = require('./tools/wiki');
|
|
175
|
+
const err = new Error('getNode failed on both identities. as user: code=131005 msg=not found. as app: getNode failed (HTTP 400, code=131005): not found');
|
|
176
|
+
const ctx = {
|
|
177
|
+
getOfficialClient: () => ({
|
|
178
|
+
getWikiNode: async () => { throw err; },
|
|
179
|
+
}),
|
|
180
|
+
};
|
|
181
|
+
const resp = await handlers.get_wiki_node({ node_token: 'bascnabcdef' }, ctx);
|
|
182
|
+
const body = JSON.parse(resp.content[0].text);
|
|
183
|
+
assert.equal(body.obj_type, 'bitable', 'live 131005 error shape must still trigger obj_token synthesis');
|
|
184
|
+
assert.equal(body.obj_token, 'bascnabcdef');
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
// --- 10. search pagination: nextOffset cursor surfaces; params pass through ---
|
|
188
|
+
{
|
|
189
|
+
const c = fakeClient({
|
|
190
|
+
uatResult: { code: 0, data: { docs_entities: [{ t: 1 }, { t: 2 }], has_more: true }, _viaUser: true },
|
|
191
|
+
});
|
|
192
|
+
const res = await docsMixin.searchDocs.call(c, 'q', { pageSize: 2, pageToken: '4' });
|
|
193
|
+
assert.equal(c.calls.asUserOrApp[0].body.offset, 4, 'searchDocs offset passthrough');
|
|
194
|
+
assert.equal(c.calls.asUserOrApp[0].body.count, 2, 'searchDocs page size passthrough');
|
|
195
|
+
assert.equal(res.nextOffset, 6, 'searchDocs nextOffset = offset + items returned');
|
|
196
|
+
}
|
|
197
|
+
{
|
|
198
|
+
const c = fakeClient({
|
|
199
|
+
uatResult: { code: 0, data: { docs_entities: [{ t: 1 }], has_more: true }, _viaUser: true },
|
|
200
|
+
});
|
|
201
|
+
const res = await wikiMixin.searchWiki.call(c, 'q', { pageSize: 1, offset: 3 });
|
|
202
|
+
assert.equal(c.calls.asUserOrApp[0].body.offset, 3, 'searchWiki offset passthrough');
|
|
203
|
+
assert.equal(c.calls.asUserOrApp[0].body.count, 1, 'searchWiki page size passthrough');
|
|
204
|
+
assert.equal(res.nextOffset, 4, 'searchWiki nextOffset cursor');
|
|
205
|
+
assert.equal(res.hasMore, true, 'searchWiki must surface hasMore');
|
|
206
|
+
}
|
|
207
|
+
// schema: pagination params exposed on both search tools
|
|
208
|
+
{
|
|
209
|
+
const sd = require('./tools/docs').schemas.find(s => s.name === 'search_docs');
|
|
210
|
+
const sw = require('./tools/wiki').schemas.find(s => s.name === 'search_wiki');
|
|
211
|
+
assert.ok(sd.inputSchema.properties.page_size && sd.inputSchema.properties.offset, 'search_docs schema pagination');
|
|
212
|
+
assert.ok(sw.inputSchema.properties.page_size && sw.inputSchema.properties.offset, 'search_wiki schema pagination');
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
// --- 11. unvalidated args are clamped, never reach Feishu as NaN/negative ---
|
|
216
|
+
// Tool args have no schema validation layer; a bad offset/page_size must be
|
|
217
|
+
// normalized to sane non-negative integers (Copilot review, PR #115).
|
|
218
|
+
{
|
|
219
|
+
const c = fakeClient({
|
|
220
|
+
uatResult: { code: 0, data: { docs_entities: [{ t: 1 }], has_more: true }, _viaUser: true },
|
|
221
|
+
});
|
|
222
|
+
const res = await docsMixin.searchDocs.call(c, 'q', { pageSize: 'abc', pageToken: '-5' });
|
|
223
|
+
const body = c.calls.asUserOrApp[0].body;
|
|
224
|
+
assert.equal(body.offset, 0, 'searchDocs negative offset clamps to 0');
|
|
225
|
+
assert.equal(body.count, 10, 'searchDocs non-numeric page size falls back to default');
|
|
226
|
+
assert.equal(res.nextOffset, 1, 'nextOffset math stays sane after clamping');
|
|
227
|
+
}
|
|
228
|
+
{
|
|
229
|
+
const c = fakeClient({
|
|
230
|
+
uatResult: { code: 0, data: { docs_entities: [], has_more: false }, _viaUser: true },
|
|
231
|
+
});
|
|
232
|
+
await wikiMixin.searchWiki.call(c, 'q', { pageSize: NaN, offset: 'xyz' });
|
|
233
|
+
const body = c.calls.asUserOrApp[0].body;
|
|
234
|
+
assert.equal(body.offset, 0, 'searchWiki non-numeric offset clamps to 0');
|
|
235
|
+
assert.equal(body.count, 20, 'searchWiki NaN page size falls back to default');
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
// --- 11b. abnormal has_more:true + empty page must NOT emit a stalled cursor ---
|
|
239
|
+
// nextOffset === offset would loop a paging caller forever (final release
|
|
240
|
+
// review, v1.3.16). hasMore stays visible; the unusable cursor is withheld.
|
|
241
|
+
{
|
|
242
|
+
const c = fakeClient({
|
|
243
|
+
uatResult: { code: 0, data: { docs_entities: [], has_more: true }, _viaUser: true },
|
|
244
|
+
});
|
|
245
|
+
const res = await docsMixin.searchDocs.call(c, 'q', { pageToken: '5' });
|
|
246
|
+
assert.equal(res.hasMore, true);
|
|
247
|
+
assert.equal(res.nextOffset, undefined, 'searchDocs empty page must not emit nextOffset === offset');
|
|
248
|
+
}
|
|
249
|
+
{
|
|
250
|
+
const c = fakeClient({
|
|
251
|
+
uatResult: { code: 0, data: { docs_entities: [], has_more: true }, _viaUser: true },
|
|
252
|
+
});
|
|
253
|
+
const res = await wikiMixin.searchWiki.call(c, 'q', { offset: 5 });
|
|
254
|
+
assert.equal(res.nextOffset, undefined, 'searchWiki empty page must not emit nextOffset === offset');
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
// --- 11c. explicit offset:0 is honored by the handlers (not dropped as falsy) ---
|
|
258
|
+
{
|
|
259
|
+
const docsHandlers = require('./tools/docs').handlers;
|
|
260
|
+
let got;
|
|
261
|
+
const ctx = { getOfficialClient: () => ({ searchDocs: async (q, opts) => { got = opts; return { items: [] }; } }) };
|
|
262
|
+
await docsHandlers.search_docs({ query: 'q', offset: 0 }, ctx);
|
|
263
|
+
assert.equal(got.pageToken, '0', 'search_docs handler must pass explicit offset:0 through');
|
|
264
|
+
}
|
|
265
|
+
{
|
|
266
|
+
const wikiHandlers = require('./tools/wiki').handlers;
|
|
267
|
+
let got;
|
|
268
|
+
const ctx = { getOfficialClient: () => ({ searchWiki: async (q, opts) => { got = opts; return { items: [] }; } }) };
|
|
269
|
+
await wikiHandlers.search_wiki({ query: 'q', offset: 0 }, ctx);
|
|
270
|
+
assert.equal(got.offset, 0, 'search_wiki handler must pass explicit offset:0 through');
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
// --- 12. scopeHint fires ONLY for empty root listing via bot ---
|
|
274
|
+
// A bot-visible folder that is genuinely empty must stay a bare [] — the
|
|
275
|
+
// blind-spot hint is about the bot's OWN root vs the user's 我的空间
|
|
276
|
+
// (Copilot review, PR #115). 403-on-personal-folder throws and never gets here.
|
|
277
|
+
{
|
|
278
|
+
const c = fakeClient({
|
|
279
|
+
uatResult: { code: 0, data: { files: [], has_more: false }, _viaUser: false },
|
|
280
|
+
});
|
|
281
|
+
const res = await driveMixin.listFiles.call(c, 'fldcnSharedEmpty');
|
|
282
|
+
assert.equal(res.scopeHint, undefined, 'empty bot-visible folder must NOT carry the root blind-spot hint');
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
// --- 8. list_files tool schema exposes pagination + UAT-first semantics ---
|
|
286
|
+
{
|
|
287
|
+
const { schemas } = require('./tools/drive');
|
|
288
|
+
const lf = schemas.find(s => s.name === 'list_files');
|
|
289
|
+
assert.ok(lf.inputSchema.properties.page_size, 'list_files schema: page_size');
|
|
290
|
+
assert.ok(lf.inputSchema.properties.page_token, 'list_files schema: page_token');
|
|
291
|
+
assert.ok(/UAT/i.test(lf.description), 'list_files description must state UAT-first routing');
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
// --- 9. list_files handler passes pagination args through ---
|
|
295
|
+
{
|
|
296
|
+
const { handlers } = require('./tools/drive');
|
|
297
|
+
let got;
|
|
298
|
+
const ctx = {
|
|
299
|
+
getOfficialClient: () => ({
|
|
300
|
+
listFiles: async (folderToken, opts) => { got = { folderToken, opts }; return { items: [], viaUser: true }; },
|
|
301
|
+
}),
|
|
302
|
+
};
|
|
303
|
+
await handlers.list_files({ folder_token: 'fldX', page_size: 25, page_token: 'PT2' }, ctx);
|
|
304
|
+
assert.equal(got.folderToken, 'fldX');
|
|
305
|
+
assert.equal(got.opts.pageSize, 25);
|
|
306
|
+
assert.equal(got.opts.pageToken, 'PT2');
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
console.log('uat-read-paths.js: PASS');
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
if (require.main === module) run().catch(e => { console.error(e); process.exit(1); });
|
|
313
|
+
module.exports = { run };
|
package/src/tools/docs.js
CHANGED
|
@@ -9,10 +9,14 @@ const { text, json } = require('./_registry');
|
|
|
9
9
|
const schemas = [
|
|
10
10
|
{
|
|
11
11
|
name: 'search_docs',
|
|
12
|
-
description: '[Official API] Search Feishu documents by keyword.',
|
|
12
|
+
description: '[Official API] Search Feishu documents by keyword. UAT-first with app fallback: with user identity (UAT) the search covers docs visible to YOU, including your personal space; via bot it only covers docs shared with the bot. Response carries viaUser; when hasMore is true, pass the returned nextOffset back as offset to page forward.',
|
|
13
13
|
inputSchema: {
|
|
14
14
|
type: 'object',
|
|
15
|
-
properties: {
|
|
15
|
+
properties: {
|
|
16
|
+
query: { type: 'string', description: 'Search keyword' },
|
|
17
|
+
page_size: { type: 'number', description: 'Max results per page (default 10)' },
|
|
18
|
+
offset: { type: 'number', description: 'Pagination offset from a previous nextOffset' },
|
|
19
|
+
},
|
|
16
20
|
required: ['query'],
|
|
17
21
|
},
|
|
18
22
|
},
|
|
@@ -52,7 +56,7 @@ const schemas = [
|
|
|
52
56
|
},
|
|
53
57
|
{
|
|
54
58
|
name: 'manage_doc_block',
|
|
55
|
-
description: '[Official API] Manage content blocks in a document. Single tool replaces v1.3.6 create_doc_block / update_doc_block / delete_doc_blocks.\n action=create —
|
|
59
|
+
description: '[Official API] Manage content blocks in a document. Single tool replaces v1.3.6 create_doc_block / update_doc_block / delete_doc_blocks.\n action=create — six modes (pass exactly ONE):\n (A) Generic — pass `children` array (e.g. [{block_type:2, text:{...}}]).\n (B) Image from local file — pass `image_path`; plugin uploads and patches.\n (C) Image from token — pass `image_token` (already uploaded).\n (D) File attachment from local file — pass `file_path`; plugin handles VIEW-wrap + replace_file.\n (E) File from token — pass `file_token`.\n (F) Table — pass `table={rows,columns,cells?}`; plugin creates a block_type=31 table (Feishu auto-makes the block_type=32 cells) and fills each provided cell. USE THIS for tables — do NOT hand-build table blocks via `children` (the table block_type is 31, NOT 40; getting it wrong returns invalid_param).\n action=update — generic (pass `update_body`), image-replace (pass `image_token`), or file-replace (pass `file_token`).\n action=delete — pass `parent_block_id` + `start_index` + `end_index` (range delete).\n`document_id` accepts native ID, wiki node token, or Feishu URL.',
|
|
56
60
|
inputSchema: {
|
|
57
61
|
type: 'object',
|
|
58
62
|
properties: {
|
|
@@ -69,6 +73,7 @@ const schemas = [
|
|
|
69
73
|
file_path: { type: 'string', description: 'Local file path — create mode D (mutually exclusive with other create modes).' },
|
|
70
74
|
file_token: { type: 'string', description: 'Pre-uploaded docx file token — create mode E, or update file-replace.' },
|
|
71
75
|
update_body: { type: 'object', description: 'Generic update payload for action=update. E.g. {update_text_elements:{elements:[{text_run:{content:"new text"}}]}}.' },
|
|
76
|
+
table: { type: 'object', description: 'Create a table — create mode F (mutually exclusive with other create modes). Shape: {rows:int>=1, columns:int>=1, cells?:string[][] (row-major plain text; omit/empty-string to leave a cell blank), column_width?:int[] (px, length=columns), header_row?:bool, header_column?:bool}. The plugin creates a block_type=31 table, lets Feishu auto-create the cells, and fills each provided cell by updating its text — you never specify block types. Returns {tableBlockId, cells:[[cellId,...]] (row-major grid), filled}. Example: {"rows":2,"columns":2,"cells":[["Name","Role"],["Ann","PM"]]}.' },
|
|
72
77
|
},
|
|
73
78
|
required: ['action', 'document_id'],
|
|
74
79
|
},
|
|
@@ -94,7 +99,10 @@ function need(arg, name, action) {
|
|
|
94
99
|
|
|
95
100
|
const handlers = {
|
|
96
101
|
async search_docs(args, ctx) {
|
|
97
|
-
|
|
102
|
+
const opts = {};
|
|
103
|
+
if (args.page_size) opts.pageSize = args.page_size;
|
|
104
|
+
if (args.offset !== undefined) opts.pageToken = String(args.offset);
|
|
105
|
+
return json(await ctx.getOfficialClient().searchDocs(args.query, opts));
|
|
98
106
|
},
|
|
99
107
|
async read_doc(args, ctx) {
|
|
100
108
|
return json(await ctx.getOfficialClient().readDoc(await ctx.resolveDocId(args.document_id)));
|
|
@@ -121,8 +129,16 @@ const handlers = {
|
|
|
121
129
|
switch (args.action) {
|
|
122
130
|
case 'create': {
|
|
123
131
|
need(args.parent_block_id, 'parent_block_id', 'create');
|
|
124
|
-
const modes = [args.children, args.image_path, args.image_token, args.file_path, args.file_token].filter(Boolean);
|
|
125
|
-
if (modes.length > 1) return text('manage_doc_block(create): pass exactly ONE of children / image_path / image_token / file_path / file_token.');
|
|
132
|
+
const modes = [args.children, args.image_path, args.image_token, args.file_path, args.file_token, args.table].filter(Boolean);
|
|
133
|
+
if (modes.length > 1) return text('manage_doc_block(create): pass exactly ONE of children / image_path / image_token / file_path / file_token / table.');
|
|
134
|
+
if (args.table) {
|
|
135
|
+
const t = args.table;
|
|
136
|
+
return json(await official.createDocTable(docId, args.parent_block_id, {
|
|
137
|
+
rows: t.rows, columns: t.columns, cells: t.cells,
|
|
138
|
+
columnWidth: t.column_width, headerRow: t.header_row, headerColumn: t.header_column,
|
|
139
|
+
index: args.index,
|
|
140
|
+
}));
|
|
141
|
+
}
|
|
126
142
|
if (args.image_path || args.image_token) {
|
|
127
143
|
const r = await official.createDocBlockWithImage(docId, args.parent_block_id, {
|
|
128
144
|
imagePath: args.image_path,
|
|
@@ -139,7 +155,7 @@ const handlers = {
|
|
|
139
155
|
});
|
|
140
156
|
return json(r);
|
|
141
157
|
}
|
|
142
|
-
if (!args.children) return text('manage_doc_block(create): children, image_path, image_token, file_path, or
|
|
158
|
+
if (!args.children) return text('manage_doc_block(create): children, image_path, image_token, file_path, file_token, or table is required.');
|
|
143
159
|
return json(await official.createDocBlock(docId, args.parent_block_id, args.children, args.index));
|
|
144
160
|
}
|
|
145
161
|
case 'update': {
|
package/src/tools/drive.js
CHANGED
|
@@ -9,10 +9,14 @@ const { text, json } = require('./_registry');
|
|
|
9
9
|
const schemas = [
|
|
10
10
|
{
|
|
11
11
|
name: 'list_files',
|
|
12
|
-
description: '[Official API] List files in a Drive folder.',
|
|
12
|
+
description: '[Official API] List files in a Drive folder. UAT-first with app fallback: with user identity (UAT), empty folder_token lists YOUR personal-space ("我的空间") root; via bot it can only see folders shared with the bot (personal-space folders return 403). Response carries viaUser so you know whose view you got. Use the returned file token with manage_drive_file to copy/move/delete.',
|
|
13
13
|
inputSchema: {
|
|
14
14
|
type: 'object',
|
|
15
|
-
properties: {
|
|
15
|
+
properties: {
|
|
16
|
+
folder_token: { type: 'string', description: 'Folder token (empty for root — your 我的空间 root when UAT is configured)' },
|
|
17
|
+
page_size: { type: 'number', description: 'Max files per page (default 50)' },
|
|
18
|
+
page_token: { type: 'string', description: 'Pagination token from a previous nextPageToken' },
|
|
19
|
+
},
|
|
16
20
|
},
|
|
17
21
|
},
|
|
18
22
|
{
|
|
@@ -66,7 +70,10 @@ function need(arg, name, action) {
|
|
|
66
70
|
|
|
67
71
|
const handlers = {
|
|
68
72
|
async list_files(args, ctx) {
|
|
69
|
-
|
|
73
|
+
const opts = {};
|
|
74
|
+
if (args.page_size) opts.pageSize = args.page_size;
|
|
75
|
+
if (args.page_token) opts.pageToken = args.page_token;
|
|
76
|
+
return json(await ctx.getOfficialClient().listFiles(args.folder_token, opts));
|
|
70
77
|
},
|
|
71
78
|
async create_folder(args, ctx) {
|
|
72
79
|
const r = await ctx.getOfficialClient().createFolder(args.name, args.parent_token);
|
package/src/tools/wiki.js
CHANGED
|
@@ -10,10 +10,14 @@ const schemas = [
|
|
|
10
10
|
},
|
|
11
11
|
{
|
|
12
12
|
name: 'search_wiki',
|
|
13
|
-
description: '[Official API] Search Wiki nodes by keyword.',
|
|
13
|
+
description: '[Official API] Search Wiki nodes by keyword. UAT-first with app fallback: with user identity (UAT) the search covers wiki spaces visible to YOU; via bot it only covers spaces the bot was invited to. Response carries viaUser; when hasMore is true, pass the returned nextOffset back as offset to page forward.',
|
|
14
14
|
inputSchema: {
|
|
15
15
|
type: 'object',
|
|
16
|
-
properties: {
|
|
16
|
+
properties: {
|
|
17
|
+
query: { type: 'string', description: 'Search keyword' },
|
|
18
|
+
page_size: { type: 'number', description: 'Max results per page (default 20)' },
|
|
19
|
+
offset: { type: 'number', description: 'Pagination offset from a previous nextOffset' },
|
|
20
|
+
},
|
|
17
21
|
required: ['query'],
|
|
18
22
|
},
|
|
19
23
|
},
|
|
@@ -119,7 +123,10 @@ const handlers = {
|
|
|
119
123
|
return json(await ctx.getOfficialClient().listWikiSpaces());
|
|
120
124
|
},
|
|
121
125
|
async search_wiki(args, ctx) {
|
|
122
|
-
|
|
126
|
+
const opts = {};
|
|
127
|
+
if (args.page_size) opts.pageSize = args.page_size;
|
|
128
|
+
if (args.offset !== undefined) opts.offset = args.offset;
|
|
129
|
+
return json(await ctx.getOfficialClient().searchWiki(args.query, opts));
|
|
123
130
|
},
|
|
124
131
|
async list_wiki_nodes(args, ctx) {
|
|
125
132
|
return json(await ctx.getOfficialClient().listWikiNodes(args.space_id, { parentNodeToken: args.parent_node_token }));
|