feishu-user-plugin 1.3.14 → 1.3.15

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.14",
3
+ "version": "1.3.15",
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.14",
5
+ "version": "1.3.15",
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.14",
5
+ "version": "1.3.15",
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,23 @@ 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.15] - 2026-05-31
8
+
9
+ 两条增强:文档建表格不再让 agent 猜 block_type;UAT 频繁重新授权的根因(良性 refresh_token 轮换竞态被误判为撤销)修掉。无 schema 变化、无新工具(仍 85)、无 breaking API。升级后重启 Claude Code / Codex 自动拉 v1.3.15。
10
+
11
+ ### Added
12
+
13
+ - **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` 同步补建表 + 决策树指引。
14
+
15
+ ### Fixed
16
+
17
+ - **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`)
18
+
19
+ ### Test scenarios
20
+
21
+ - 多进程 / 多版本并发刷 UAT 时,良性轮换不再弹"授权操作通知"、不再提示重跑 oauth(`auth_time` 不跳)
22
+ - `manage_doc_block(action=create, table={rows:2,columns:2,cells:[["A","B"],["C","D"]]})` 在文档里生成 2×2 表、四格有内容、无空行
23
+
7
24
  ## [1.3.14] - 2026-05-21
8
25
 
9
26
  **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
3
  "mcpName": "io.github.EthanQC/feishu-user-plugin",
4
- "version": "1.3.14",
4
+ "version": "1.3.15",
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": {
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  name: feishu-user-plugin
3
- version: "1.3.14"
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.14: UAT refresh-lock moved to canonical home (Codex-only users now get cross-process mutex), invalid_grant flips identity to UAT_REVOKED with clear oauth re-run guidance, refresh-error redaction (no raw response body in Error.message), three-stage adoptPersistedUATIfNewer race-shield, cookie heartbeat ws-owner-gated (no more N-times API spam from 30+ concurrent sessions), OAuth/setup all fetch calls timeout-bounded, oauth-auto.js dead code with token leak removed, OAuth browser callback no longer displays token bytes, decodeTokenExpiry malformed-JWT warning deduped, Lark Desktop reactor cold-start debounce fixed, 27 new fixture-based tests (test-uat-lifecycle + test-cookie-heartbeat) plus the cross-process race test repaired, TROUBLESHOOTING.md grew 4 sections (frequent consent notification / UAT-not-refreshing / mixed-version upgrade window / migrate-then-stale-env)."
3
+ version: "1.3.15"
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.15: manage_doc_block gains a table create mode agents stop guessing the table block_type (tables are 31, not 40); the plugin builds the block_type=31 table, fills each cell by updating its auto-created text block (no stray empty blocks), resolves cells scoped (no whole-doc 500-block cap) and fails loud rather than silently dropping content. UAT refresh now self-heals a benign refresh_token rotation race: on invalid_grant it re-reads disk and adopts a peer's freshly-persisted valid token (snapshotting the sent token pre-await for the in-process hot-reload case) instead of false-flipping to UAT_REVOKED and pushing the user through a needless oauth re-consent."
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`
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: client._uatRefresh,
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);
@@ -63,6 +63,22 @@ module.exports = {
63
63
  return { items: res.data.items || [] };
64
64
  },
65
65
 
66
+ // Direct children of a single block — scoped, so it does not inherit the
67
+ // whole-document 500-block cap of getDocBlocks. Used by createDocTable to map
68
+ // a table's cells (and each cell's text block) reliably in large documents.
69
+ async getBlockChildren(documentId, blockId) {
70
+ const res = await this._asUserOrApp({
71
+ uatPath: `/open-apis/docx/v1/documents/${documentId}/blocks/${blockId}/children`,
72
+ query: { page_size: '500' },
73
+ sdkFn: () => this.client.docx.documentBlockChildren.get({
74
+ path: { document_id: documentId, block_id: blockId },
75
+ params: { page_size: 500 },
76
+ }),
77
+ label: 'getBlockChildren',
78
+ });
79
+ return { items: res.data.items || [] };
80
+ },
81
+
66
82
  async createDocBlock(documentId, parentBlockId, children, index) {
67
83
  const data = { children };
68
84
  if (index !== undefined) data.index = index;
@@ -79,6 +95,85 @@ module.exports = {
79
95
  return { blocks: res.data.children || [], fallbackWarning: res._fallbackWarning || null };
80
96
  },
81
97
 
98
+ // Create a Feishu docx table (block_type=31) and optionally fill its cells —
99
+ // so callers never have to know docx block types. Added after field reports
100
+ // of agents guessing the table block_type (40 is wrong; 31 table / 32 cell).
101
+ // Flow:
102
+ // 1) create the table block with row_size/column_size — Feishu auto-creates
103
+ // the table_cell (32) children (row-major) and gives each cell an empty
104
+ // text block.
105
+ // 2) read the table back to map cell_id -> its auto-created text block.
106
+ // 3) fill: UPDATE each cell's existing text block (clean — no stray empty
107
+ // block) when present, else CREATE a text block in the cell.
108
+ // `cells` is an optional row-major 2D array of plain strings.
109
+ // Returns { tableBlockId, cells:[[cellId,...],...], rows, columns, filled, viaUser, fallbackWarning }.
110
+ async createDocTable(documentId, parentBlockId, { rows, columns, cells, columnWidth, headerRow, headerColumn, index } = {}) {
111
+ rows = Number(rows); columns = Number(columns);
112
+ if (!Number.isInteger(rows) || !Number.isInteger(columns) || rows < 1 || columns < 1) {
113
+ throw new Error('createDocTable: rows and columns must be integers >= 1');
114
+ }
115
+ const property = { row_size: rows, column_size: columns };
116
+ if (Array.isArray(columnWidth) && columnWidth.length === columns) property.column_width = columnWidth;
117
+ if (headerRow) property.header_row = true;
118
+ if (headerColumn) property.header_column = true;
119
+ const createBody = { children: [{ block_type: 31, table: { property } }] };
120
+ if (index !== undefined) createBody.index = index;
121
+ const created = await this._asUserOrApp({
122
+ uatPath: `/open-apis/docx/v1/documents/${documentId}/blocks/${parentBlockId}/children`,
123
+ method: 'POST',
124
+ body: createBody,
125
+ sdkFn: () => this.client.docx.documentBlockChildren.create({
126
+ path: { document_id: documentId, block_id: parentBlockId },
127
+ data: createBody,
128
+ }),
129
+ label: 'createDocTable',
130
+ });
131
+ const tableCreated = (created.data.children || [])[0];
132
+ const tableBlockId = tableCreated?.block_id;
133
+ if (!tableBlockId) throw new Error(`createDocTable: no table block_id returned: ${JSON.stringify(created.data).slice(0, 400)}`);
134
+ const viaUser = !!created._viaUser;
135
+ const fallbackWarning = created._fallbackWarning || null;
136
+
137
+ // Resolve the cell IDs. Prefer the create response; else fetch the table
138
+ // block's children directly (scoped — NOT the whole-doc getDocBlocks, which
139
+ // caps at 500 blocks and would silently lose an appended table's cells in a
140
+ // large document). Fail loud rather than silently dropping requested content.
141
+ let flatCellIds = tableCreated.table?.cells || tableCreated.children || [];
142
+ if (flatCellIds.length < rows * columns) {
143
+ flatCellIds = ((await this.getBlockChildren(documentId, tableBlockId)).items || []).map(b => b.block_id);
144
+ }
145
+ if (flatCellIds.length < rows * columns) {
146
+ throw new Error(`createDocTable: created table ${tableBlockId} but resolved only ${flatCellIds.length}/${rows * columns} cells — aborting fill to avoid silently dropping content.`);
147
+ }
148
+ const grid = [];
149
+ for (let r = 0; r < rows; r++) grid.push(flatCellIds.slice(r * columns, (r + 1) * columns));
150
+
151
+ let filled = 0;
152
+ if (Array.isArray(cells)) {
153
+ for (let r = 0; r < rows; r++) {
154
+ for (let c = 0; c < columns; c++) {
155
+ const content = cells[r] ? cells[r][c] : undefined;
156
+ if (content === undefined || content === null || content === '') continue;
157
+ const cellId = grid[r][c];
158
+ if (!cellId) throw new Error(`createDocTable: missing cell id at row ${r}, col ${c}`);
159
+ // Each fresh cell auto-creates exactly one empty text block — UPDATE it
160
+ // (clean) rather than CREATE a second. Scoped per-cell fetch stays
161
+ // correct regardless of overall document size.
162
+ const cellChildren = (await this.getBlockChildren(documentId, cellId)).items || [];
163
+ const textChild = cellChildren.find(b => b.block_type === 2);
164
+ const elements = { elements: [{ text_run: { content: String(content) } }] };
165
+ if (textChild) {
166
+ await this.updateDocBlock(documentId, textChild.block_id, { update_text_elements: elements });
167
+ } else {
168
+ await this.createDocBlock(documentId, cellId, [{ block_type: 2, text: elements }]);
169
+ }
170
+ filled++;
171
+ }
172
+ }
173
+ }
174
+ return { tableBlockId, cells: grid, rows, columns, filled, viaUser, fallbackWarning };
175
+ },
176
+
82
177
  async updateDocBlock(documentId, blockId, updateBody) {
83
178
  const res = await this._asUserOrApp({
84
179
  uatPath: `/open-apis/docx/v1/documents/${documentId}/blocks/${blockId}`,
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;
@@ -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
package/src/tools/docs.js CHANGED
@@ -52,7 +52,7 @@ const schemas = [
52
52
  },
53
53
  {
54
54
  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 — five modes:\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 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.',
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 — 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
56
  inputSchema: {
57
57
  type: 'object',
58
58
  properties: {
@@ -69,6 +69,7 @@ const schemas = [
69
69
  file_path: { type: 'string', description: 'Local file path — create mode D (mutually exclusive with other create modes).' },
70
70
  file_token: { type: 'string', description: 'Pre-uploaded docx file token — create mode E, or update file-replace.' },
71
71
  update_body: { type: 'object', description: 'Generic update payload for action=update. E.g. {update_text_elements:{elements:[{text_run:{content:"new text"}}]}}.' },
72
+ 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
73
  },
73
74
  required: ['action', 'document_id'],
74
75
  },
@@ -121,8 +122,16 @@ const handlers = {
121
122
  switch (args.action) {
122
123
  case 'create': {
123
124
  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.');
125
+ const modes = [args.children, args.image_path, args.image_token, args.file_path, args.file_token, args.table].filter(Boolean);
126
+ if (modes.length > 1) return text('manage_doc_block(create): pass exactly ONE of children / image_path / image_token / file_path / file_token / table.');
127
+ if (args.table) {
128
+ const t = args.table;
129
+ return json(await official.createDocTable(docId, args.parent_block_id, {
130
+ rows: t.rows, columns: t.columns, cells: t.cells,
131
+ columnWidth: t.column_width, headerRow: t.header_row, headerColumn: t.header_column,
132
+ index: args.index,
133
+ }));
134
+ }
126
135
  if (args.image_path || args.image_token) {
127
136
  const r = await official.createDocBlockWithImage(docId, args.parent_block_id, {
128
137
  imagePath: args.image_path,
@@ -139,7 +148,7 @@ const handlers = {
139
148
  });
140
149
  return json(r);
141
150
  }
142
- if (!args.children) return text('manage_doc_block(create): children, image_path, image_token, file_path, or file_token is required.');
151
+ if (!args.children) return text('manage_doc_block(create): children, image_path, image_token, file_path, file_token, or table is required.');
143
152
  return json(await official.createDocBlock(docId, args.parent_block_id, args.children, args.index));
144
153
  }
145
154
  case 'update': {