feishu-user-plugin 1.3.16 → 1.3.17
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 +30 -0
- package/package.json +1 -1
- package/scripts/probe-feishu-docx.js +2 -3
- package/scripts/sync-server-json.js +14 -1
- package/skills/feishu-user-plugin/SKILL.md +2 -2
- package/skills/feishu-user-plugin/references/doc.md +3 -0
- package/skills/feishu-user-plugin/references/wiki.md +1 -1
- package/src/clients/official/bitable.js +6 -1
- package/src/clients/official/docs.js +210 -53
- package/src/clients/official/groups.js +8 -1
- package/src/clients/official/wiki.js +48 -11
- package/src/error-codes.js +7 -0
- package/src/test-all.js +12 -0
- package/src/test-doc-block-media.js +140 -0
- package/src/test-doc-blocks-pagination.js +186 -0
- package/src/test-doc-table.js +92 -3
- package/src/test-error-codes.js +10 -0
- package/src/test-pagination-cursor-chain.js +194 -0
- package/src/tools/bitable.js +2 -1
- package/src/tools/docs.js +30 -8
- package/src/tools/groups.js +12 -1
- package/src/tools/im-read.js +4 -2
- package/src/tools/wiki.js +4 -3
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "feishu-user-plugin",
|
|
3
|
-
"version": "1.3.
|
|
3
|
+
"version": "1.3.17",
|
|
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.17",
|
|
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.17",
|
|
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,36 @@ 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.17] - 2026-06-08
|
|
8
|
+
|
|
9
|
+
本版围绕读路径完整性做一轮系统性收口:大文档与大列表不再静默截断、批量写的部分失败不再被读作全成功、文档表格 / 媒体块建失败可定位可修复。85 工具数不变,`get_doc_blocks` / `manage_doc_block` / `read_messages` / `read_p2p_messages` / `list_wiki_nodes` / `manage_bitable_record` 等 schema 新增分页或上报字段,无 breaking API。升级后重启 Claude Code / Codex 自动拉 v1.3.17。
|
|
10
|
+
|
|
11
|
+
### Added
|
|
12
|
+
|
|
13
|
+
- **get_doc_blocks / read_doc_markdown 分页拉全量**:跟进 `page_token` 拉完整块树,`hasMore:false` 才代表拉全;此前单页静默截断在 500 块,大文档(一次报障是 280+ 块 / ~300KB)尾部"消失"且无任何标志,调用方误以为那部分块没建成功。`get_doc_blocks` 新增 `max_blocks` 限定单次返回 + `nextPageToken` 续拉,被限定的返回带 `truncated:true`;`read_doc_markdown` 块树不完整时末尾追加 `[output truncated]` 注记。(src/clients/official/docs.js)
|
|
14
|
+
- **read_messages / read_p2p_messages / list_wiki_nodes / manage_bitable_record(search) 分页续拉**:四条读路径新增 `page_token` 入参,`hasMore:true` 时把返回的 `pageToken` 回填即可翻页;此前 client 已返回游标但工具层丢弃 / schema 无入参,超出单页窗口(消息默认 20 上限 50、wiki 节点 50、bitable 默认 20)的内容拿不到也续不了,digest / 全表扫描类任务据此得出残缺结论。
|
|
15
|
+
- **manage_members(add) 部分失败上报**:新增 `notExistedIds`(用户不存在)/ `pendingApprovalIds`(卡入群审批、尚未进群),任一非空时响应顶部 ⚠ 逐一点名;此前只透 `invalidIds`,后两类被静默吞掉,半失败读作全员入群(卡审批的"成员"后续 @ 不到)。字段名对照飞书 OpenAPI 生成的 SDK 类型核验。(src/clients/official/groups.js)
|
|
16
|
+
|
|
17
|
+
### Fixed
|
|
18
|
+
|
|
19
|
+
- **manage_doc_block 建表填格部分失败可恢复**:mode F 填格遇瞬态错误(`code=2200` scope-check 抖动 / 限频 / 5xx)自动退避重试;重试后仍失败的格子记录 `failedCells:[{row,col,cellId,textBlockId,reason,skipped}]`(row/col 0 起算)随成功结果返回、不再整体抛错,连续 3 格失败止损并标 `skipped`。逐格 `action=update`(block_id 传 textBlockId)补内容即可,不必重建表。一次报障是 7×3 表第 6 行起填格失败、整表残缺且拿不到已填清单。(src/clients/official/docs.js)
|
|
20
|
+
- **list_wiki_spaces 拉全量**:内部跟进 `page_token` 拉完整空间列表;此前单页截断在 50,第 51+ 个空间的 `space_id` 无法发现,后续 `list_wiki_nodes` / `create_wiki_node` 选不到 parent。(src/clients/official/wiki.js)
|
|
21
|
+
- **图片 / 文件块三步建块瞬态重试 + 孤儿可定位**:`createDocBlockWithImage` / `createDocBlockWithFile`(建占位块 → 上传 → PATCH)的上传与 PATCH 步骤自动重试瞬态错误;持续失败时错误携带占位块 `blockId`(上传已成功时附媒体 token + 含 `document_id` 的修复指引),不必全文找空块。(src/clients/official/docs.js)
|
|
22
|
+
- **空页不再当分页终止信号**:飞书因权限过滤可能返回空页 + `has_more:true` 但后面还有数据(其 `spaceNode.list` API 文档明确写"可以继续分页请求");`getDocBlocks` / `listWikiSpaces` 内部循环不再在空页停(停滞保护改由 token 守卫 + 页数 backstop),空页 + 前进游标继续翻。`list_wiki_nodes` schema 同步提醒 agent 空页 + hasMore 要续翻。(src/clients/official/docs.js、wiki.js)
|
|
23
|
+
- **error-codes:2200 归类 retry**:docx 的 "check incr user_access_token scope fail" 是 scope-check 服务的瞬态抖动(同一 UAT 前序调用均成功、scope 本已授权),归为可重试。(src/error-codes.js)
|
|
24
|
+
- **server.json 目录描述词边界截断**:registry catalog 描述改为词边界截断 + 省略号,不再切在半个词(32 个长描述受益);运行时 MCP client 经 `tools/list` 拿的仍是完整描述。(scripts/sync-server-json.js)
|
|
25
|
+
|
|
26
|
+
### Changed
|
|
27
|
+
|
|
28
|
+
- **update_text_elements 整段替换语义强调**:`manage_doc_block(action=update)` 的 `update_text_elements` 全量覆盖该块的 elements(**非** patch / append),漏传的 element(加粗前缀、链接等)永久丢失;改局部应先 `get_doc_blocks` 读原块、整组传回。schema 描述 + docs/TOOLS.md + CLAUDE.md + skill reference 四处加粗。
|
|
29
|
+
|
|
30
|
+
### Test scenarios
|
|
31
|
+
|
|
32
|
+
- 280+ 块大文档调 `get_doc_blocks` 应返回全部块、`hasMore:false`;`read_doc_markdown` 渲染到末段不截断
|
|
33
|
+
- `read_messages` 对消息密集的群 `hasMore:true` 时回填 `page_token` 应拿到更早的消息
|
|
34
|
+
- `manage_doc_block(action=create, table=7×3)` 21 格应全部填充;瞬态失败时返回 `failedCells` 而非整体报错
|
|
35
|
+
- `list_wiki_spaces` 在加入 >50 个空间的账号应返回全部、`hasMore:false`
|
|
36
|
+
|
|
7
37
|
## [1.3.16] - 2026-06-06
|
|
8
38
|
|
|
9
39
|
修掉发现类读路径的身份盲区:上传到个人空间的文件此前找不到、也因此删不掉。`list_files` / `search_docs` / `search_wiki` / `get_wiki_node` 四条读路径改为 UAT 优先(bot fallback 保留)。85 工具数不变,list_files / search_docs / search_wiki 三个 schema 新增分页参数,无 breaking API。
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "feishu-user-plugin",
|
|
3
3
|
"mcpName": "io.github.zhuzhen-team/feishu-user-plugin",
|
|
4
|
-
"version": "1.3.
|
|
4
|
+
"version": "1.3.17",
|
|
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": {
|
|
@@ -91,9 +91,8 @@ const BLOCK_LABELS = {
|
|
|
91
91
|
if (process.env.LARK_USER_ACCESS_TOKEN) c.loadUAT();
|
|
92
92
|
|
|
93
93
|
// --- Fetch blocks ---
|
|
94
|
-
//
|
|
95
|
-
//
|
|
96
|
-
// one-shot probe.
|
|
94
|
+
// getDocBlocks follows page_token pagination to completion (v1.3.17), so
|
|
95
|
+
// the fixture covers the whole document regardless of size.
|
|
97
96
|
let blocks;
|
|
98
97
|
try {
|
|
99
98
|
const result = await c.getDocBlocks(rawDocId);
|
|
@@ -17,10 +17,23 @@ const SERVER_JSON = path.join(ROOT, 'server.json');
|
|
|
17
17
|
const PKG = require(path.join(ROOT, 'package.json'));
|
|
18
18
|
const { TOOLS } = require(path.join(ROOT, 'src', 'server'));
|
|
19
19
|
|
|
20
|
+
// Truncate a description to at most `limit` chars for the registry catalog.
|
|
21
|
+
// Cuts at the last word boundary (when one exists in the tail) and appends an
|
|
22
|
+
// ellipsis, so server.json never ends a description mid-word — the full text
|
|
23
|
+
// still reaches MCP clients at runtime via tools/list (this is catalog
|
|
24
|
+
// metadata only). PR #121 review.
|
|
25
|
+
function truncateForCatalog(s, limit = 200) {
|
|
26
|
+
if (s.length <= limit) return s;
|
|
27
|
+
const slice = s.slice(0, limit - 1); // leave room for the ellipsis
|
|
28
|
+
const lastSpace = slice.lastIndexOf(' ');
|
|
29
|
+
const cut = lastSpace > limit * 0.6 ? slice.slice(0, lastSpace) : slice;
|
|
30
|
+
return cut.replace(/[\s,;.:—-]+$/, '') + '…';
|
|
31
|
+
}
|
|
32
|
+
|
|
20
33
|
function deriveToolEntry(t) {
|
|
21
34
|
// Strip the "[Plugin]"/"[Cookie]"/etc category prefix from descriptions for compactness.
|
|
22
35
|
const desc = (t.description || '').replace(/^\[[^\]]+\]\s*/, '');
|
|
23
|
-
return { name: t.name, description: desc.split('\n')[0]
|
|
36
|
+
return { name: t.name, description: truncateForCatalog(desc.split('\n')[0], 200) };
|
|
24
37
|
}
|
|
25
38
|
|
|
26
39
|
const existing = JSON.parse(fs.readFileSync(SERVER_JSON, 'utf8'));
|
|
@@ -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.17"
|
|
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.17: read-path completeness — get_doc_blocks / read_doc_markdown now paginate past the old silent 500-block cap (hasMore / truncated / page_token / max_blocks); read_messages / read_p2p_messages / list_wiki_nodes / manage_bitable_record(search) gain page_token cursors and list_wiki_spaces fetches all spaces (was capped at 50); empty pages with has_more no longer stop pagination early. manage_doc_block table fills retry transient errors (code=2200) and report failedCells instead of aborting; manage_members(add) surfaces not-existed / pending-approval ids; image/file block creation retries and names orphaned placeholders on failure. update_text_elements full-replace semantics emphasized. 85 tools + 9 prompts unchanged."
|
|
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
|
---
|
|
@@ -23,6 +23,7 @@
|
|
|
23
23
|
- `table` 形如 `{"rows":2,"columns":2,"cells":[["姓名","角色"],["Ann","PM"]]}`:`cells` 行优先,可省略或留空字符串表示空格
|
|
24
24
|
- 插件内部建 `block_type=31` 表格、由飞书自动生成单元格(`block_type=32`)、逐格填内容——**不要自己用 `children` 拼 table block 或猜 block_type**(表格是 31 不是 40,猜错会报 `invalid_param`)
|
|
25
25
|
2. 返回 `tableBlockId` + 行优先的 `cells` 单元格 ID 网格
|
|
26
|
+
3. 若个别单元格填充失败(瞬态错误已自动重试),返回里会带 `failedCells:[{row,col,cellId,textBlockId?,reason}]`(row/col 0 起算)——逐格用 `manage_doc_block(action=update, block_id=<textBlockId>)` 补内容即可,**不必删表重建**
|
|
26
27
|
|
|
27
28
|
### 写决策树等纯文本结构
|
|
28
29
|
表格只用于真正的二维数据;决策树、流程、缩进结构用 `children` 里的文本块(`block_type=2`)+ 代码块(`block_type=14`)表达即可,不依赖表格线。
|
|
@@ -37,3 +38,5 @@
|
|
|
37
38
|
- 文档操作使用 Official API(需要 LARK_APP_ID)
|
|
38
39
|
- 搜索结果受机器人权限范围限制
|
|
39
40
|
- 建表格走 `manage_doc_block` 的 `table` 模式,不要手拼 block——表格 `block_type=31`、单元格 `32`
|
|
41
|
+
- `manage_doc_block(action=update)` 的 `update_text_elements` 是**整段替换**(非 patch / append):漏传的 element(加粗前缀、链接等)会永久丢失,改局部先 `get_doc_blocks` 读出原 elements、改完整组传回
|
|
42
|
+
- 读大文档:`get_doc_blocks` / `read_doc_markdown` 自动分页拉全量,`hasMore:false` 才代表拿到了完整块树
|
|
@@ -116,7 +116,12 @@ module.exports = {
|
|
|
116
116
|
}),
|
|
117
117
|
label: 'searchRecords',
|
|
118
118
|
});
|
|
119
|
-
|
|
119
|
+
// pageToken accompanies hasMore (2026-06-07 audit) — hasMore + total
|
|
120
|
+
// without the resume cursor stranded callers at the first page of a
|
|
121
|
+
// potentially thousands-row table.
|
|
122
|
+
const out = { items: res.data.items || [], total: res.data.total, hasMore: res.data.has_more };
|
|
123
|
+
if (res.data.page_token) out.pageToken = res.data.page_token;
|
|
124
|
+
return out;
|
|
120
125
|
},
|
|
121
126
|
|
|
122
127
|
async createBitableRecord(appToken, tableId, fields) {
|
|
@@ -6,6 +6,43 @@
|
|
|
6
6
|
// base.js or mixed in via other domain modules.
|
|
7
7
|
|
|
8
8
|
const { buildEmptyImageBlock, buildReplaceImagePayload, buildEmptyFileBlock, buildReplaceFilePayload } = require('../../doc-blocks');
|
|
9
|
+
const { classifyError } = require('../../error-codes');
|
|
10
|
+
|
|
11
|
+
const _sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
12
|
+
|
|
13
|
+
// Backoff schedule between cell-fill retries (attempts = delays.length + 1).
|
|
14
|
+
// Field report 2026-06-07: rapid-fire docx writes intermittently trip the
|
|
15
|
+
// code=2200 scope-check flake / frequency control — a short pause clears it.
|
|
16
|
+
const CELL_RETRY_DELAYS_MS = [400, 1200];
|
|
17
|
+
|
|
18
|
+
// Run fn(); retry on transient failures (classifyError → 'retry') after the
|
|
19
|
+
// next backoff delay. Permanent failures propagate immediately. Safe here
|
|
20
|
+
// because every retried operation is idempotent (update_text_elements is a
|
|
21
|
+
// full replacement; getBlockChildren is a read).
|
|
22
|
+
async function _withTransientRetry(fn, delays) {
|
|
23
|
+
for (let attempt = 0; ; attempt++) {
|
|
24
|
+
try { return await fn(); }
|
|
25
|
+
catch (e) {
|
|
26
|
+
if (attempt >= delays.length || classifyError(e).action !== 'retry') throw e;
|
|
27
|
+
await _sleep(delays[attempt]);
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// Structured error for the 3-step media flows (placeholder → upload → PATCH):
|
|
33
|
+
// the placeholder block already exists in the document when step 2/3 fails, so
|
|
34
|
+
// the failure must name it — and carry the uploaded media token when present —
|
|
35
|
+
// or the caller is left hunting for an unexplained empty block with no repair
|
|
36
|
+
// path (2026-06-07 audit).
|
|
37
|
+
function _orphanBlockError(label, documentId, blockId, stage, cause, mediaToken, tokenParam) {
|
|
38
|
+
const repair = mediaToken
|
|
39
|
+
? `re-attach it with manage_doc_block(action=update, document_id=${documentId}, block_id=${blockId}, ${tokenParam}=${mediaToken}) — no need to re-upload`
|
|
40
|
+
: `retry, or remove the orphan via manage_doc_block(action=delete, document_id=${documentId}) on its parent block`;
|
|
41
|
+
const err = new Error(`${label}: placeholder block ${blockId} was created but ${stage} failed — ${cause.message}. The empty block remains in the document; ${repair}.`);
|
|
42
|
+
err.blockId = blockId;
|
|
43
|
+
if (mediaToken) err.mediaToken = mediaToken;
|
|
44
|
+
return err;
|
|
45
|
+
}
|
|
9
46
|
|
|
10
47
|
module.exports = {
|
|
11
48
|
// --- Docs ---
|
|
@@ -70,14 +107,71 @@ module.exports = {
|
|
|
70
107
|
return out;
|
|
71
108
|
},
|
|
72
109
|
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
110
|
+
// Fetch the document's block tree. Follows page_token pagination until
|
|
111
|
+
// exhaustion by default — the pre-v1.3.17 single 500-block page silently
|
|
112
|
+
// truncated large docs (field report 2026-06-07: a ~300KB doc lost everything
|
|
113
|
+
// past mid-document, with no flag — callers believed the tail blocks were
|
|
114
|
+
// never created). Pass maxBlocks to bound one call (rounded up to page
|
|
115
|
+
// granularity) and resume with the returned nextPageToken as pageToken.
|
|
116
|
+
// Returns { items, total, hasMore, truncated?, nextPageToken?, viaUser, fallbackWarning? }.
|
|
117
|
+
// hasMore:false guarantees the full tree is in `items`.
|
|
118
|
+
async getDocBlocks(documentId, { pageToken, maxBlocks } = {}) {
|
|
119
|
+
// Tool args arrive unvalidated — accept the cap only as a finite integer
|
|
120
|
+
// >= 1; anything else (0, negatives, NaN, random strings) means "no cap"
|
|
121
|
+
// so a malformed value can never silently change paging semantics
|
|
122
|
+
// (Copilot review PR #118; same clamp precedent as searchDocs offset).
|
|
123
|
+
const cap = Number.isFinite(Number(maxBlocks)) && Number(maxBlocks) >= 1 ? Math.floor(Number(maxBlocks)) : null;
|
|
124
|
+
const items = [];
|
|
125
|
+
let token = pageToken || undefined;
|
|
126
|
+
let viaUser = true;
|
|
127
|
+
let fallbackWarning = null;
|
|
128
|
+
let hasMore = false;
|
|
129
|
+
let nextPageToken;
|
|
130
|
+
const seenTokens = new Set();
|
|
131
|
+
// 1000-page backstop (~500k blocks) — a server that keeps minting fresh
|
|
132
|
+
// tokens must not pin the loop forever. Hitting it reports hasMore:true.
|
|
133
|
+
const MAX_PAGES = 1000;
|
|
134
|
+
let page = 0;
|
|
135
|
+
for (; page < MAX_PAGES; page++) {
|
|
136
|
+
if (token) seenTokens.add(token);
|
|
137
|
+
const query = { page_size: '500' };
|
|
138
|
+
if (token) query.page_token = token;
|
|
139
|
+
const params = { page_size: 500 };
|
|
140
|
+
if (token) params.page_token = token;
|
|
141
|
+
const res = await this._asUserOrApp({
|
|
142
|
+
uatPath: `/open-apis/docx/v1/documents/${documentId}/blocks`,
|
|
143
|
+
query,
|
|
144
|
+
sdkFn: () => this.client.docx.documentBlock.list({ path: { document_id: documentId }, params }),
|
|
145
|
+
label: 'getDocBlocks',
|
|
146
|
+
});
|
|
147
|
+
const pageItems = res.data.items || [];
|
|
148
|
+
items.push(...pageItems);
|
|
149
|
+
viaUser = viaUser && !!res._viaUser;
|
|
150
|
+
if (!fallbackWarning && res._fallbackWarning) fallbackWarning = res._fallbackWarning;
|
|
151
|
+
hasMore = !!res.data.has_more;
|
|
152
|
+
if (!hasMore) break;
|
|
153
|
+
const next = res.data.page_token;
|
|
154
|
+
if (cap && items.length >= cap) {
|
|
155
|
+
if (next) nextPageToken = next;
|
|
156
|
+
break;
|
|
157
|
+
}
|
|
158
|
+
// Stall/cycle guards (PR #116 parity): a missing token, an unchanged
|
|
159
|
+
// token, or one we've already used means paging forward is futile — stop
|
|
160
|
+
// and WITHHOLD the cursor rather than loop forever. An EMPTY page is NOT
|
|
161
|
+
// a stop signal on its own: Feishu legitimately returns empty pages with
|
|
162
|
+
// has_more:true (permission filtering) and real data behind them, so we
|
|
163
|
+
// keep going as long as the cursor advances; the MAX_PAGES backstop bounds
|
|
164
|
+
// a pathological always-empty-new-token server.
|
|
165
|
+
if (!next || next === token || seenTokens.has(next)) break;
|
|
166
|
+
token = next;
|
|
167
|
+
}
|
|
168
|
+
// Backstop exhausted mid-document: `token` is the still-unfetched cursor.
|
|
169
|
+
if (page >= MAX_PAGES && hasMore && token) nextPageToken = token;
|
|
170
|
+
const out = { items, total: items.length, hasMore, viaUser };
|
|
171
|
+
if (hasMore) out.truncated = true;
|
|
172
|
+
if (nextPageToken) out.nextPageToken = nextPageToken;
|
|
173
|
+
if (fallbackWarning) out.fallbackWarning = fallbackWarning;
|
|
174
|
+
return out;
|
|
81
175
|
},
|
|
82
176
|
|
|
83
177
|
// Direct children of a single block — scoped, so it does not inherit the
|
|
@@ -123,8 +217,18 @@ module.exports = {
|
|
|
123
217
|
// 3) fill: UPDATE each cell's existing text block (clean — no stray empty
|
|
124
218
|
// block) when present, else CREATE a text block in the cell.
|
|
125
219
|
// `cells` is an optional row-major 2D array of plain strings.
|
|
126
|
-
//
|
|
127
|
-
|
|
220
|
+
// Cell fills retry transient Feishu errors (code=2200 scope-check flake,
|
|
221
|
+
// rate limits, 5xx) with backoff; cells that still fail are reported in
|
|
222
|
+
// `failedCells` [{row,col,cellId,textBlockId?,reason,skipped?}] (0-based)
|
|
223
|
+
// instead of aborting the whole call — the table block already exists, so
|
|
224
|
+
// the caller needs the partial-success map to repair, not an opaque throw
|
|
225
|
+
// (field report 2026-06-07: a 7×3 fill died at row 6 and the caller had to
|
|
226
|
+
// grep the whole doc for empty cells). After 3 consecutive cell failures the
|
|
227
|
+
// remaining fills are skipped (marked skipped:true) — a structural error
|
|
228
|
+
// (revoked perms) would otherwise burn 2 API calls per remaining cell.
|
|
229
|
+
// `retryDelaysMs` overrides the backoff schedule (tests only).
|
|
230
|
+
// Returns { tableBlockId, cells:[[cellId,...],...], rows, columns, filled, failedCells?, viaUser, fallbackWarning }.
|
|
231
|
+
async createDocTable(documentId, parentBlockId, { rows, columns, cells, columnWidth, headerRow, headerColumn, index, retryDelaysMs } = {}) {
|
|
128
232
|
rows = Number(rows); columns = Number(columns);
|
|
129
233
|
if (!Number.isInteger(rows) || !Number.isInteger(columns) || rows < 1 || columns < 1) {
|
|
130
234
|
throw new Error('createDocTable: rows and columns must be integers >= 1');
|
|
@@ -166,29 +270,58 @@ module.exports = {
|
|
|
166
270
|
for (let r = 0; r < rows; r++) grid.push(flatCellIds.slice(r * columns, (r + 1) * columns));
|
|
167
271
|
|
|
168
272
|
let filled = 0;
|
|
273
|
+
const failedCells = [];
|
|
169
274
|
if (Array.isArray(cells)) {
|
|
275
|
+
const delays = Array.isArray(retryDelaysMs) ? retryDelaysMs : CELL_RETRY_DELAYS_MS;
|
|
276
|
+
let consecutiveFailures = 0;
|
|
277
|
+
let lastReason = '';
|
|
170
278
|
for (let r = 0; r < rows; r++) {
|
|
171
279
|
for (let c = 0; c < columns; c++) {
|
|
172
280
|
const content = cells[r] ? cells[r][c] : undefined;
|
|
173
281
|
if (content === undefined || content === null || content === '') continue;
|
|
174
282
|
const cellId = grid[r][c];
|
|
175
283
|
if (!cellId) throw new Error(`createDocTable: missing cell id at row ${r}, col ${c}`);
|
|
284
|
+
if (consecutiveFailures >= 3) {
|
|
285
|
+
failedCells.push({
|
|
286
|
+
row: r, col: c, cellId, skipped: true,
|
|
287
|
+
reason: `skipped: aborted after ${consecutiveFailures} consecutive cell failures (last: ${lastReason})`,
|
|
288
|
+
});
|
|
289
|
+
continue;
|
|
290
|
+
}
|
|
176
291
|
// Each fresh cell auto-creates exactly one empty text block — UPDATE it
|
|
177
292
|
// (clean) rather than CREATE a second. Scoped per-cell fetch stays
|
|
178
293
|
// correct regardless of overall document size.
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
294
|
+
let textBlockId = null;
|
|
295
|
+
try {
|
|
296
|
+
await _withTransientRetry(async () => {
|
|
297
|
+
// Reset per attempt — the cell is re-inspected on every retry, and
|
|
298
|
+
// a stale id from a prior attempt must not leak into failedCells.
|
|
299
|
+
textBlockId = null;
|
|
300
|
+
const cellChildren = (await this.getBlockChildren(documentId, cellId)).items || [];
|
|
301
|
+
const textChild = cellChildren.find(b => b.block_type === 2);
|
|
302
|
+
const elements = { elements: [{ text_run: { content: String(content) } }] };
|
|
303
|
+
if (textChild) {
|
|
304
|
+
textBlockId = textChild.block_id;
|
|
305
|
+
await this.updateDocBlock(documentId, textChild.block_id, { update_text_elements: elements });
|
|
306
|
+
} else {
|
|
307
|
+
await this.createDocBlock(documentId, cellId, [{ block_type: 2, text: elements }]);
|
|
308
|
+
}
|
|
309
|
+
}, delays);
|
|
310
|
+
filled++;
|
|
311
|
+
consecutiveFailures = 0;
|
|
312
|
+
} catch (e) {
|
|
313
|
+
consecutiveFailures++;
|
|
314
|
+
lastReason = e.message;
|
|
315
|
+
const entry = { row: r, col: c, cellId, reason: e.message };
|
|
316
|
+
if (textBlockId) entry.textBlockId = textBlockId;
|
|
317
|
+
failedCells.push(entry);
|
|
186
318
|
}
|
|
187
|
-
filled++;
|
|
188
319
|
}
|
|
189
320
|
}
|
|
190
321
|
}
|
|
191
|
-
|
|
322
|
+
const out = { tableBlockId, cells: grid, rows, columns, filled, viaUser, fallbackWarning };
|
|
323
|
+
if (failedCells.length) out.failedCells = failedCells;
|
|
324
|
+
return out;
|
|
192
325
|
},
|
|
193
326
|
|
|
194
327
|
async updateDocBlock(documentId, blockId, updateBody) {
|
|
@@ -224,8 +357,12 @@ module.exports = {
|
|
|
224
357
|
// 1) create empty image placeholder block
|
|
225
358
|
// 2) upload pixels (skipped if caller passes a ready-made imageToken)
|
|
226
359
|
// 3) patch the placeholder with the uploaded token
|
|
360
|
+
// Steps 2/3 retry transient failures (upload re-send and PATCH full-replace
|
|
361
|
+
// are both idempotent); a persistent failure throws an error carrying the
|
|
362
|
+
// placeholder blockId (+ uploaded token when step 2 succeeded) so the caller
|
|
363
|
+
// can repair instead of orphan-hunting. `retryDelaysMs` is test-only.
|
|
227
364
|
// Returns { blockId, imageToken, viaUser }.
|
|
228
|
-
async createDocBlockWithImage(documentId, parentBlockId, { imagePath, imageToken, index } = {}) {
|
|
365
|
+
async createDocBlockWithImage(documentId, parentBlockId, { imagePath, imageToken, index, retryDelaysMs } = {}) {
|
|
229
366
|
if (!imagePath && !imageToken) {
|
|
230
367
|
throw new Error('createDocBlockWithImage: either imagePath or imageToken is required');
|
|
231
368
|
}
|
|
@@ -248,28 +385,39 @@ module.exports = {
|
|
|
248
385
|
const blockId = newBlock?.block_id;
|
|
249
386
|
if (!blockId) throw new Error(`createDocBlockWithImage: placeholder creation returned no block_id: ${JSON.stringify(created.data).slice(0, 400)}`);
|
|
250
387
|
|
|
251
|
-
// Step 2 — upload (if needed).
|
|
388
|
+
// Step 2 — upload (if needed), with transient retry.
|
|
389
|
+
const delays = Array.isArray(retryDelaysMs) ? retryDelaysMs : CELL_RETRY_DELAYS_MS;
|
|
252
390
|
let finalToken = imageToken;
|
|
253
391
|
let viaUser = !!created._viaUser;
|
|
254
392
|
let fallbackWarning = created._fallbackWarning || null;
|
|
255
393
|
if (!finalToken) {
|
|
256
|
-
|
|
394
|
+
let uploaded;
|
|
395
|
+
try {
|
|
396
|
+
uploaded = await _withTransientRetry(() => this.uploadMedia(imagePath, blockId, 'docx_image'), delays);
|
|
397
|
+
} catch (e) {
|
|
398
|
+
throw _orphanBlockError('createDocBlockWithImage', documentId, blockId, 'image upload', e, null);
|
|
399
|
+
}
|
|
257
400
|
finalToken = uploaded.fileToken;
|
|
258
401
|
viaUser = viaUser && uploaded.viaUser; // true iff both steps went via user
|
|
259
402
|
}
|
|
260
403
|
|
|
261
|
-
// Step 3 — attach token to the placeholder via PATCH replace_image
|
|
404
|
+
// Step 3 — attach token to the placeholder via PATCH replace_image
|
|
405
|
+
// (idempotent full replace), with transient retry.
|
|
262
406
|
const patch = buildReplaceImagePayload(finalToken);
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
407
|
+
try {
|
|
408
|
+
await _withTransientRetry(() => this._asUserOrApp({
|
|
409
|
+
uatPath: `/open-apis/docx/v1/documents/${documentId}/blocks/${blockId}`,
|
|
410
|
+
method: 'PATCH',
|
|
411
|
+
body: patch,
|
|
412
|
+
sdkFn: () => this.client.docx.documentBlock.patch({
|
|
413
|
+
path: { document_id: documentId, block_id: blockId },
|
|
414
|
+
data: patch,
|
|
415
|
+
}),
|
|
416
|
+
label: 'createDocBlockWithImage.replaceImage',
|
|
417
|
+
}), delays);
|
|
418
|
+
} catch (e) {
|
|
419
|
+
throw _orphanBlockError('createDocBlockWithImage', documentId, blockId, 'replace_image PATCH', e, finalToken, 'image_token');
|
|
420
|
+
}
|
|
273
421
|
|
|
274
422
|
return { blockId, imageToken: finalToken, viaUser, fallbackWarning };
|
|
275
423
|
},
|
|
@@ -296,8 +444,11 @@ module.exports = {
|
|
|
296
444
|
// 1) create empty file placeholder block
|
|
297
445
|
// 2) upload the binary via uploadMedia(parent_type=docx_file)
|
|
298
446
|
// 3) PATCH with replace_file.token to attach
|
|
447
|
+
// Steps 2/3 retry transient failures and a persistent failure throws an
|
|
448
|
+
// error carrying the placeholder blockId (+ uploaded token when step 2
|
|
449
|
+
// succeeded) — mirrors createDocBlockWithImage. `retryDelaysMs` is test-only.
|
|
299
450
|
// Returns { blockId, fileToken, viaUser, fallbackWarning }.
|
|
300
|
-
async createDocBlockWithFile(documentId, parentBlockId, { filePath, fileToken, index } = {}) {
|
|
451
|
+
async createDocBlockWithFile(documentId, parentBlockId, { filePath, fileToken, index, retryDelaysMs } = {}) {
|
|
301
452
|
if (!filePath && !fileToken) {
|
|
302
453
|
throw new Error('createDocBlockWithFile: either filePath or fileToken is required');
|
|
303
454
|
}
|
|
@@ -332,26 +483,36 @@ module.exports = {
|
|
|
332
483
|
blockId = inner;
|
|
333
484
|
}
|
|
334
485
|
|
|
486
|
+
const delays = Array.isArray(retryDelaysMs) ? retryDelaysMs : CELL_RETRY_DELAYS_MS;
|
|
335
487
|
let finalToken = fileToken;
|
|
336
488
|
let viaUser = !!created._viaUser;
|
|
337
489
|
let fallbackWarning = created._fallbackWarning || null;
|
|
338
490
|
if (!finalToken) {
|
|
339
|
-
|
|
491
|
+
let uploaded;
|
|
492
|
+
try {
|
|
493
|
+
uploaded = await _withTransientRetry(() => this.uploadMedia(filePath, blockId, 'docx_file'), delays);
|
|
494
|
+
} catch (e) {
|
|
495
|
+
throw _orphanBlockError('createDocBlockWithFile', documentId, blockId, 'file upload', e, null);
|
|
496
|
+
}
|
|
340
497
|
finalToken = uploaded.fileToken;
|
|
341
498
|
viaUser = viaUser && uploaded.viaUser;
|
|
342
499
|
}
|
|
343
500
|
|
|
344
501
|
const patch = buildReplaceFilePayload(finalToken);
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
502
|
+
try {
|
|
503
|
+
await _withTransientRetry(() => this._asUserOrApp({
|
|
504
|
+
uatPath: `/open-apis/docx/v1/documents/${documentId}/blocks/${blockId}`,
|
|
505
|
+
method: 'PATCH',
|
|
506
|
+
body: patch,
|
|
507
|
+
sdkFn: () => this.client.docx.documentBlock.patch({
|
|
508
|
+
path: { document_id: documentId, block_id: blockId },
|
|
509
|
+
data: patch,
|
|
510
|
+
}),
|
|
511
|
+
label: 'createDocBlockWithFile.replaceFile',
|
|
512
|
+
}), delays);
|
|
513
|
+
} catch (e) {
|
|
514
|
+
throw _orphanBlockError('createDocBlockWithFile', documentId, blockId, 'replace_file PATCH', e, finalToken, 'file_token');
|
|
515
|
+
}
|
|
355
516
|
|
|
356
517
|
return { blockId, viewBlockId: outerBlockId !== blockId ? outerBlockId : undefined, fileToken: finalToken, viaUser, fallbackWarning };
|
|
357
518
|
},
|
|
@@ -377,15 +538,11 @@ module.exports = {
|
|
|
377
538
|
// None matched directly; return the first as best-effort
|
|
378
539
|
return childIds[0];
|
|
379
540
|
}
|
|
380
|
-
// Fallback: list all blocks and find a 23 whose parent_id is the view block
|
|
541
|
+
// Fallback: list all blocks and find a 23 whose parent_id is the view block.
|
|
542
|
+
// Uses getDocBlocks (paginates past 500 blocks) — a single unpaged list
|
|
543
|
+
// would miss the freshly appended view block in a large document.
|
|
381
544
|
try {
|
|
382
|
-
const
|
|
383
|
-
uatPath: `/open-apis/docx/v1/documents/${documentId}/blocks`,
|
|
384
|
-
method: 'GET',
|
|
385
|
-
sdkFn: () => this.client.docx.documentBlock.list({ path: { document_id: documentId } }),
|
|
386
|
-
label: '_findFileChildOf.list',
|
|
387
|
-
});
|
|
388
|
-
const items = res?.data?.items || [];
|
|
545
|
+
const { items } = await this.getDocBlocks(documentId);
|
|
389
546
|
const match = items.find(b => b.block_type === 23 && b.parent_id === viewBlockId);
|
|
390
547
|
return match?.block_id || null;
|
|
391
548
|
} catch (_) {
|
|
@@ -51,7 +51,14 @@ module.exports = {
|
|
|
51
51
|
}),
|
|
52
52
|
'addChatMembers'
|
|
53
53
|
);
|
|
54
|
-
|
|
54
|
+
// Feishu reports three partial-failure buckets on batch add (2026-06-07
|
|
55
|
+
// audit) — swallowing not_existed/pending_approval made a half-failed add
|
|
56
|
+
// read as full success (members "in the group" who never joined).
|
|
57
|
+
return {
|
|
58
|
+
invalidIds: res.data.invalid_id_list || [],
|
|
59
|
+
notExistedIds: res.data.not_existed_id_list || [],
|
|
60
|
+
pendingApprovalIds: res.data.pending_approval_id_list || [],
|
|
61
|
+
};
|
|
55
62
|
},
|
|
56
63
|
|
|
57
64
|
async removeChatMembers(chatId, userIds, memberIdType = 'open_id') {
|
|
@@ -11,18 +11,51 @@ module.exports = {
|
|
|
11
11
|
// Try UAT first — most users access only their own / team Wiki spaces
|
|
12
12
|
// which the bot may not have been invited to. Falling back to app keeps
|
|
13
13
|
// the bot-shared-spaces case working too.
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
14
|
+
//
|
|
15
|
+
// Follows page_token pagination to completion (2026-06-07 audit): the
|
|
16
|
+
// endpoint pages at 50/page and the pre-fix single call silently dropped
|
|
17
|
+
// every space past the first page with no hasMore flag — spaces beyond
|
|
18
|
+
// #50 were unreachable (their space_id could never be discovered).
|
|
19
|
+
const items = [];
|
|
20
|
+
let token;
|
|
21
|
+
let viaUser = true;
|
|
22
|
+
let fallbackWarning = null;
|
|
23
|
+
let hasMore = false;
|
|
24
|
+
const seenTokens = new Set();
|
|
25
|
+
for (let page = 0; page < 200; page++) {
|
|
26
|
+
if (token) seenTokens.add(token);
|
|
27
|
+
const query = { page_size: '50' };
|
|
28
|
+
if (token) query.page_token = token;
|
|
29
|
+
const params = { page_size: 50 };
|
|
30
|
+
if (token) params.page_token = token;
|
|
31
|
+
const res = await this._asUserOrApp({
|
|
32
|
+
uatPath: '/open-apis/wiki/v2/spaces',
|
|
33
|
+
method: 'GET',
|
|
34
|
+
query,
|
|
35
|
+
sdkFn: () => this.client.wiki.space.list({ params }),
|
|
36
|
+
label: 'listSpaces',
|
|
37
|
+
});
|
|
38
|
+
const pageItems = res.data.items || [];
|
|
39
|
+
items.push(...pageItems);
|
|
40
|
+
viaUser = viaUser && !!res._viaUser;
|
|
41
|
+
if (!fallbackWarning && res._fallbackWarning) fallbackWarning = res._fallbackWarning;
|
|
42
|
+
hasMore = !!res.data.has_more;
|
|
43
|
+
if (!hasMore) break;
|
|
44
|
+
const next = res.data.page_token;
|
|
45
|
+
// Stall/cycle guards (getDocBlocks parity) — never loop on a server that
|
|
46
|
+
// drops or repeats the cursor. An empty page is NOT a stop signal: the
|
|
47
|
+
// Feishu wiki endpoints document empty pages with has_more:true under
|
|
48
|
+
// permission filtering, with real spaces behind them — keep paging while
|
|
49
|
+
// the cursor advances; the 200-page backstop bounds a pathological server.
|
|
50
|
+
if (!next || next === token || seenTokens.has(next)) break;
|
|
51
|
+
token = next;
|
|
52
|
+
}
|
|
53
|
+
const out = { items, viaUser };
|
|
54
|
+
if (hasMore) out.hasMore = true; // stalled upstream cursor — incompleteness stays visible
|
|
55
|
+
if (fallbackWarning) out.fallbackWarning = fallbackWarning;
|
|
23
56
|
// Empty + bot path means scope is missing; surface a clear hint instead
|
|
24
57
|
// of silently returning nothing.
|
|
25
|
-
if (items.length === 0 && !
|
|
58
|
+
if (items.length === 0 && !viaUser) {
|
|
26
59
|
out.scopeHint = 'No spaces returned via app — the bot likely lacks `wiki:wiki:readonly` scope, or has not been invited to any Wiki space. Run `npx feishu-user-plugin oauth` and ensure the wiki scope is granted; or invite the bot to the target Wiki space.';
|
|
27
60
|
}
|
|
28
61
|
return out;
|
|
@@ -104,7 +137,11 @@ module.exports = {
|
|
|
104
137
|
sdkFn: () => this.client.wiki.spaceNode.list({ path: { space_id: spaceId }, params: sdkParams }),
|
|
105
138
|
label: 'listWikiNodes',
|
|
106
139
|
});
|
|
107
|
-
|
|
140
|
+
// pageToken accompanies hasMore (2026-06-07 audit) — hasMore without the
|
|
141
|
+
// resume cursor stranded callers at the first 50 nodes forever.
|
|
142
|
+
const out = { items: res.data.items || [], hasMore: res.data.has_more, viaUser: !!res._viaUser };
|
|
143
|
+
if (res.data.page_token) out.pageToken = res.data.page_token;
|
|
144
|
+
return out;
|
|
108
145
|
},
|
|
109
146
|
|
|
110
147
|
// --- Wiki write (v1.3.7) ---
|