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.
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "feishu-user-plugin",
3
- "version": "1.3.16",
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.16",
5
+ "version": "1.3.17",
6
6
  "author": {
7
7
  "name": "EthanQC"
8
8
  },
@@ -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.16",
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.16",
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
- // NOTE: getDocBlocks fetches up to 500 blocks (no pagination). Docs longer
95
- // than that produce a truncated fixture / undercount. Out of scope for this
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].slice(0, 200) };
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.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."
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 completenessget_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` 才代表拿到了完整块树
@@ -13,7 +13,7 @@
13
13
  2. 找到节点后可用 `read_doc` 读取其文档内容
14
14
 
15
15
  ### 浏览节点
16
- 1. 用 `list_wiki_nodes` 列出指定空间的节点树
16
+ 1. 用 `list_wiki_nodes` 列出指定空间的节点树(每页 50 个;`hasMore:true` 时把返回的 `pageToken` 传回 `page_token` 翻页)
17
17
  2. 可传 `parent_node_token` 浏览子节点
18
18
 
19
19
  ## 示例
@@ -116,7 +116,12 @@ module.exports = {
116
116
  }),
117
117
  label: 'searchRecords',
118
118
  });
119
- return { items: res.data.items || [], total: res.data.total, hasMore: res.data.has_more };
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
- async getDocBlocks(documentId) {
74
- const res = await this._asUserOrApp({
75
- uatPath: `/open-apis/docx/v1/documents/${documentId}/blocks`,
76
- query: { page_size: '500' },
77
- sdkFn: () => this.client.docx.documentBlock.list({ path: { document_id: documentId }, params: { page_size: 500 } }),
78
- label: 'getDocBlocks',
79
- });
80
- return { items: res.data.items || [] };
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
- // Returns { tableBlockId, cells:[[cellId,...],...], rows, columns, filled, viaUser, fallbackWarning }.
127
- async createDocTable(documentId, parentBlockId, { rows, columns, cells, columnWidth, headerRow, headerColumn, index } = {}) {
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
- 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 }]);
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
- return { tableBlockId, cells: grid, rows, columns, filled, viaUser, fallbackWarning };
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
- const uploaded = await this.uploadMedia(imagePath, blockId, 'docx_image');
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
- await this._asUserOrApp({
264
- uatPath: `/open-apis/docx/v1/documents/${documentId}/blocks/${blockId}`,
265
- method: 'PATCH',
266
- body: patch,
267
- sdkFn: () => this.client.docx.documentBlock.patch({
268
- path: { document_id: documentId, block_id: blockId },
269
- data: patch,
270
- }),
271
- label: 'createDocBlockWithImage.replaceImage',
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
- const uploaded = await this.uploadMedia(filePath, blockId, 'docx_file');
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
- await this._asUserOrApp({
346
- uatPath: `/open-apis/docx/v1/documents/${documentId}/blocks/${blockId}`,
347
- method: 'PATCH',
348
- body: patch,
349
- sdkFn: () => this.client.docx.documentBlock.patch({
350
- path: { document_id: documentId, block_id: blockId },
351
- data: patch,
352
- }),
353
- label: 'createDocBlockWithFile.replaceFile',
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 res = await this._asUserOrApp({
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
- return { invalidIds: res.data.invalid_id_list || [] };
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
- const res = await this._asUserOrApp({
15
- uatPath: '/open-apis/wiki/v2/spaces?page_size=50',
16
- method: 'GET',
17
- sdkFn: () => this.client.wiki.space.list({ params: { page_size: 50 } }),
18
- label: 'listSpaces',
19
- });
20
- const items = res.data.items || [];
21
- const out = { items, viaUser: !!res._viaUser };
22
- if (res._fallbackWarning) out.fallbackWarning = res._fallbackWarning;
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 && !res._viaUser) {
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
- return { items: res.data.items || [], hasMore: res.data.has_more, viaUser: !!res._viaUser };
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) ---