@evandu/super-pi 0.1.151 → 0.1.153

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.
Files changed (36) hide show
  1. package/dist/client/.super-pi-client-sources.json +6 -4
  2. package/dist/client/assets/{CodeMirrorEditor-BhTJvXpU.js → CodeMirrorEditor-DmRMgD_e.js} +2 -2
  3. package/dist/client/assets/{dist-BOqRI6sA.js → dist-1ehoscoU.js} +1 -1
  4. package/dist/client/assets/{dist-BFAjSsad.js → dist-BAy39a02.js} +1 -1
  5. package/dist/client/assets/{dist-80xX2Yd8.js → dist-BIMTBXpD.js} +1 -1
  6. package/dist/client/assets/{dist-TwlTH8F1.js → dist-BPU0RZX2.js} +1 -1
  7. package/dist/client/assets/{dist-49TFFUQg.js → dist-BQvKL2T4.js} +1 -1
  8. package/dist/client/assets/{dist-B0gI-KRY.js → dist-BTfxEdeF.js} +1 -1
  9. package/dist/client/assets/{dist-Dq7hLZoB.js → dist-BWxApOy6.js} +1 -1
  10. package/dist/client/assets/{dist-DDdxjRBp.js → dist-BaagZpIV.js} +1 -1
  11. package/dist/client/assets/{dist-Bz1OET8s.js → dist-BjZSq94T.js} +1 -1
  12. package/dist/client/assets/{dist-DJdFGvR0.js → dist-BzOPeLJM.js} +1 -1
  13. package/dist/client/assets/{dist-0vbzT0fs.js → dist-C5OCcu2D.js} +1 -1
  14. package/dist/client/assets/{dist-CMiCGuFr.js → dist-CMNmeOX_.js} +1 -1
  15. package/dist/client/assets/{dist-CzCFepaD.js → dist-Cl_nos6D.js} +1 -1
  16. package/dist/client/assets/{dist-aOpTOMHI.js → dist-DE8_FyRQ.js} +1 -1
  17. package/dist/client/assets/{dist-BuDFattZ.js → dist-DM15RCDF.js} +1 -1
  18. package/dist/client/assets/{dist-BstoGC94.js → dist-DpK_uu3H.js} +1 -1
  19. package/dist/client/assets/{dist-SMKcq1ns.js → dist-QCoWWp1z.js} +1 -1
  20. package/dist/client/assets/{dist-CkatEAPx.js → dist-SHt_aVda.js} +1 -1
  21. package/dist/client/assets/{dist-B3Dy7iF_.js → dist-YiNVn-GQ.js} +1 -1
  22. package/dist/client/assets/{dist-B5nvkwVU.js → dist-h0HvU8ex.js} +1 -1
  23. package/dist/client/assets/{dist-Cwuref9o.js → dist-mY7dpaip.js} +1 -1
  24. package/dist/client/assets/{dist-Ce7WvAkm.js → dist-tpn03yX2.js} +1 -1
  25. package/dist/client/assets/{index-Csr3-JTY.js → index-Ce896Hh9.js} +16 -16
  26. package/dist/client/assets/index-DCLaWQin.css +1 -0
  27. package/dist/client/index.html +2 -2
  28. package/dist/server/terminal.d.ts +29 -4
  29. package/dist/server/terminal.js +38 -5
  30. package/package.json +6 -3
  31. package/dist/.super-pi-check-inputs.json +0 -4
  32. package/dist/client/assets/index-BMpqviwU.css +0 -1
  33. package/dist/server/feishu-config.d.ts +0 -68
  34. package/dist/server/feishu-config.js +0 -84
  35. package/dist/server/feishu.d.ts +0 -187
  36. package/dist/server/feishu.js +0 -695
@@ -1,695 +0,0 @@
1
- /**
2
- * THE FEISHU CLIENT: everything Super Pi knows how to do with a cloud document on Feishu.
3
- *
4
- * WHY THIS LAYER EXISTS AT ALL. Feishu's document API is not "read a file" and "write a file". A
5
- * document is a TREE OF BLOCKS, the API refuses to create a document with content in it, and the
6
- * official way to put Markdown into a document is a three-step dance (convert to blocks, then
7
- * insert them as nested blocks under the page block). An agent that had to know that would be
8
- * writing protocol details into every prompt. So the protocol lives here, once, and what is left
9
- * above it is "read this document" and "write this document".
10
- *
11
- * WHY AN HTTP CLIENT AND NOT THE OFFICIAL SDK. The official SDK (`@larksuiteoapi/node-sdk`) is a
12
- * good library, but it is a dependency of the PACKAGE, and this package's whole point is that it
13
- * installs and runs. Every call it makes is a plain HTTP call with a bearer token, so the four
14
- * endpoints this feature needs are four functions - and the credential handling (a token that
15
- * expires, a token that has to be refreshed, a rate limit that has to be waited out) is the part
16
- * that is worth writing carefully and testing on its own.
17
- *
18
- * WHAT THE IDENTITY IS. Everything here runs as the APPLICATION (`tenant_access_token`), never as
19
- * a person. That is the identity that needs no OAuth dance and no browser, and it is also the
20
- * identity that can only touch the documents the application has been made a collaborator on.
21
- * See `docs/FEISHU.md` for the setup that implies, because it is the one thing a user must do
22
- * before any of this works.
23
- */
24
- /** The tool that reads a Feishu document. */
25
- export const FEISHU_READ_TOOL_NAME = "feishu_doc_read";
26
- /** The tool that writes a Feishu document. */
27
- export const FEISHU_WRITE_TOOL_NAME = "feishu_doc_write";
28
- /**
29
- * The two hosts a domain is made of.
30
- *
31
- * The token endpoint is on a DIFFERENT host from the API (`accounts.` rather than `open.`), which
32
- * is the kind of detail that produces a confusing 404 the first time and never again.
33
- */
34
- const DOMAINS = {
35
- feishu: { api: "https://open.feishu.cn", accounts: "https://accounts.feishu.cn" },
36
- lark: { api: "https://open.larksuite.com", accounts: "https://accounts.larksuite.com" },
37
- };
38
- /**
39
- * The gap between two writes to Feishu.
40
- *
41
- * The document API allows THREE write calls per second per application AND per document, and the
42
- * refusal is a 429 in the middle of a three-call sequence (create, convert, insert) that a user
43
- * would otherwise never notice. 360ms is a little over the 333ms the limit implies, so the limit
44
- * is never what stops us.
45
- */
46
- const WRITE_INTERVAL_MS = 360;
47
- /** How long a token is treated as fresh. The real lifetime is two hours; this is the safety margin. */
48
- const TOKEN_SKEW_MS = 120_000;
49
- /** The most blocks one nested-block call may carry. */
50
- const MAX_DESCENDANTS_PER_CALL = 1000;
51
- /** The most blocks one list call returns. */
52
- const MAX_BLOCKS_PER_PAGE = 500;
53
- /**
54
- * THE BLOCK TYPE NUMBERS, and the key each type stores its payload under.
55
- *
56
- * The payload key is part of the wire format: a block of type 2 carries `text`, a block of type 3
57
- * carries `heading1`, and a reader that guessed the key would read an empty document. The table is
58
- * the official one, and the ones this code does not render are listed anyway so that an unknown
59
- * type can still be recognised and descended into.
60
- */
61
- const BLOCK_TYPE_KEYS = {
62
- 1: "page", 2: "text", 3: "heading1", 4: "heading2", 5: "heading3", 6: "heading4", 7: "heading5",
63
- 8: "heading6", 9: "heading7", 10: "heading8", 11: "heading9", 12: "ordered", 13: "bullet",
64
- 14: "code", 15: "quote", 17: "todo", 18: "bitable", 19: "callout", 21: "diagram", 22: "divider",
65
- 23: "file", 24: "grid", 25: "grid_column", 26: "image", 29: "mindnote", 30: "sheet", 31: "table",
66
- 32: "table_cell", 34: "quote_container",
67
- };
68
- /**
69
- * THE CODE LANGUAGES, by the number the API uses.
70
- *
71
- * Only the ones worth naming are here. A wrong name in a code fence is a cosmetic mistake, and a
72
- * fence with no name at all is also fine - so the fallback is an unnamed fence rather than a
73
- * guess.
74
- */
75
- const CODE_LANGUAGES = {
76
- 1: "text", 8: "c", 17: "cpp", 22: "csharp", 27: "go", 30: "java", 35: "javascript",
77
- 43: "kotlin", 49: "python", 56: "sql", 60: "shell", 63: "typescript", 67: "yaml", 75: "toml",
78
- };
79
- /**
80
- * A REFUSAL FROM FEISHU, with the code kept alongside the message.
81
- *
82
- * The code is kept because the message Feishu sends is written for a developer reading a log,
83
- * and the part a person needs - "add this application as a collaborator", "open this permission
84
- * point" - is not in it.
85
- */
86
- export class FeishuError extends Error {
87
- code;
88
- hint;
89
- constructor(code, message, hint) {
90
- super(message);
91
- this.code = code;
92
- this.hint = hint;
93
- this.name = "FeishuError";
94
- }
95
- }
96
- /**
97
- * WHAT TO DO ABOUT A FEISHU ERROR CODE, in the words of the person who has to act on it.
98
- *
99
- * These are the failures a user WILL hit, because they are the setup steps: the application has
100
- * no permission point, or the application has never been added to the document, or the
101
- * credentials are wrong. Each one names the place in the Feishu interface where it is fixed,
102
- * because "code 99991672" is not something anybody can act on.
103
- */
104
- const ERROR_HINTS = {
105
- 20002: "App Secret 不正确。请到「设置 → 飞书」核对 App ID 与 App Secret(开发者后台:凭证与基础信息)。",
106
- 20009: "这个应用没有安装到当前企业。请在飞书管理后台确认应用已被启用并发布。",
107
- 20042: "应用已被停用。请到飞书管理后台重新启用它。",
108
- 20068: "应用没有开通这个接口所需的权限点。请在开发者后台「权限管理」里开通后发布新版本。",
109
- 99991663: "访问凭证无效或已过期。Super Pi 会自动重新获取,请重试一次;若仍然失败,请检查应用状态。",
110
- 99991672: "应用缺少调用这个接口的权限点。请在开发者后台「权限管理」里开通后发布新版本。",
111
- 99991400: "触发了飞书的频率限制。Super Pi 会自动等待后重试。",
112
- 1069902: "当前应用没有这篇文档的权限。请在文档页面右上角「…」→「更多」→「添加文档应用」,把这个应用加为协作者(需要文档所有者或管理员操作)。",
113
- 1069908: "应用没有目标文件夹的权限。请把该文件夹分享给含有这个应用的群组。",
114
- 1069912: "目标文件夹不存在。",
115
- 131005: "这个知识库节点不存在,或应用没有它的权限。",
116
- 131006: "应用没有这个知识库的权限。",
117
- 1770002: "文档不存在,或已被删除。",
118
- 1770004: "文档的块数量已达上限。",
119
- 1770006: "块的父子关系不合法。",
120
- 1770007: "某个块的子块过多。",
121
- 1770014: "块的父子关系与文档结构不符。",
122
- 1770021: "文档版本过旧,请重新读取文档后再写。",
123
- };
124
- /**
125
- * The message a refusal is shown with: what Feishu said, and what to do about it.
126
- */
127
- export function describeFeishuError(code, message) {
128
- const hint = ERROR_HINTS[code];
129
- const base = `飞书接口拒绝了这次请求(错误码 ${code}):${message || "没有给出原因"}`;
130
- return hint ? `${base}。${hint}` : base;
131
- }
132
- /**
133
- * The document kinds this code cannot read, and why.
134
- *
135
- * A refusal that names the kind is worth much more than "unsupported": a person who pasted a
136
- * spreadsheet link knows exactly what to do next.
137
- */
138
- const UNSUPPORTED_KINDS = {
139
- sheets: "电子表格(sheet)暂不支持读写,请先用飞书把内容转成新版文档(docx)。",
140
- base: "旧版文档(doc)暂不支持读写,请先在飞书里把它升级为新版文档(docx)。",
141
- file: "云空间里的文件(file)不是文档,Super Pi 不读它。",
142
- bitable: "多维表格(bitable)暂不支持读写。",
143
- mindnote: "思维导图(mindnote)暂不支持读写。",
144
- slides: "幻灯片(slides)暂不支持读写。",
145
- docs: "旧版文档(doc)暂不支持读写,请先在飞书里把它升级为新版文档(docx)。",
146
- };
147
- /**
148
- * Reads a document reference: a full URL, or a bare token.
149
- *
150
- * WHY BARE TOKENS ARE ACCEPTED. A person reading a document's URL sees the token in it, and an
151
- * agent that has been handed `doxcn...` should not have to be told to wrap it in a URL first.
152
- */
153
- export function parseFeishuTarget(input) {
154
- const raw = String(input ?? "").trim();
155
- if (!raw)
156
- throw new Error("没有给出飞书文档的链接或 token。");
157
- if (raw.includes("://")) {
158
- let url;
159
- try {
160
- url = new URL(raw);
161
- }
162
- catch {
163
- throw new Error(`这不像一个飞书链接:${raw}`);
164
- }
165
- const parts = url.pathname.split("/").filter(Boolean);
166
- const kindIndex = parts.findIndex((part) => part in UNSUPPORTED_KINDS || part === "docx" || part === "wiki");
167
- if (kindIndex === -1)
168
- throw new Error(`这个链接不像飞书云文档:${raw}`);
169
- const kind = parts[kindIndex];
170
- const token = parts[kindIndex + 1];
171
- if (!token)
172
- throw new Error(`这个飞书链接里没有文档 token:${raw}`);
173
- if (kind in UNSUPPORTED_KINDS)
174
- throw new Error(UNSUPPORTED_KINDS[kind]);
175
- return { kind: kind === "wiki" ? "wiki" : "docx", token, host: url.hostname };
176
- }
177
- const bare = raw.replace(/^\/+/, "");
178
- if (bare.includes("/"))
179
- throw new Error(`这不像一个飞书文档的 token:${raw}`);
180
- if (bare.startsWith("wik"))
181
- return { kind: "wiki", token: bare };
182
- if (bare.startsWith("docc"))
183
- throw new Error(UNSUPPORTED_KINDS.base);
184
- if (bare.startsWith("sht"))
185
- throw new Error(UNSUPPORTED_KINDS.sheets);
186
- return { kind: "docx", token: bare };
187
- }
188
- /** The text of a text-like payload, with links and mentions rendered as Markdown. */
189
- function textOf(payload) {
190
- const holder = (payload ?? {});
191
- const elements = Array.isArray(holder.elements) ? holder.elements : [];
192
- let out = "";
193
- for (const element of elements) {
194
- if (element.text_run) {
195
- const content = element.text_run.content ?? "";
196
- const link = element.text_run.text_element_style?.link?.url;
197
- if (link) {
198
- // Feishu stores the URL encoded, and a decode that trips over a stray percent sign must not
199
- // lose the text: the link is decoration, the words are the content.
200
- let target = link;
201
- try {
202
- target = decodeURIComponent(link);
203
- }
204
- catch { /* keep the raw value */ }
205
- out += `[${content}](${target})`;
206
- }
207
- else {
208
- out += content;
209
- }
210
- }
211
- else if (element.mention_user) {
212
- out += `@${element.mention_user.user_id ?? "某人"}`;
213
- }
214
- else if (element.mention_doc) {
215
- out += `[文档:${element.mention_doc.title ?? element.mention_doc.token ?? ""}]`;
216
- }
217
- else if (element.equation) {
218
- out += `$${element.equation.content ?? ""}$`;
219
- }
220
- else if (element.file) {
221
- out += `[文件:${element.file.name ?? element.file.token ?? ""}]`;
222
- }
223
- else if (element.reminder) {
224
- out += "[提醒]";
225
- }
226
- }
227
- return out;
228
- }
229
- /** The payload of a block, whichever key its type stores it under. */
230
- function payloadOf(block) {
231
- const key = BLOCK_TYPE_KEYS[block.block_type];
232
- if (!key)
233
- return undefined;
234
- return block[key];
235
- }
236
- /** The plain text of a block, ignoring its children. */
237
- function blockText(block) {
238
- return textOf(payloadOf(block));
239
- }
240
- /**
241
- * The children of a block, in order.
242
- */
243
- function childrenOf(block) {
244
- return Array.isArray(block?.children) ? block.children : [];
245
- }
246
- /**
247
- * A table, rendered as a Markdown table.
248
- *
249
- * The cells arrive as a flat list in row-major order, and the shape of the table is in the table
250
- * block's own property. A table whose shape cannot be read is rendered as one column per cell,
251
- * which is wrong but not misleading: the words are all still there.
252
- */
253
- function renderTable(block, byId) {
254
- const cells = childrenOf(block).map((id) => byId.get(id)).filter((item) => Boolean(item));
255
- const property = block.table?.property;
256
- const columns = Math.max(1, property?.column_size ?? 1);
257
- const rows = [];
258
- for (const cell of cells) {
259
- const text = childrenOf(cell).map((id) => byId.get(id)).filter((item) => Boolean(item)).map(blockText).join(" ").trim();
260
- if (rows.length && rows[rows.length - 1].length < columns)
261
- rows[rows.length - 1].push(text);
262
- else
263
- rows.push([text]);
264
- }
265
- if (!rows.length)
266
- return [];
267
- const width = Math.max(columns, ...rows.map((row) => row.length));
268
- const pad = (row) => [...row, ...Array(Math.max(0, width - row.length)).fill("")];
269
- const lines = [`| ${pad(rows[0]).join(" | ")} |`, `| ${Array(width).fill("---").join(" | ")} |`];
270
- for (const row of rows.slice(1))
271
- lines.push(`| ${pad(row).join(" | ")} |`);
272
- return lines;
273
- }
274
- /**
275
- * Renders a document's blocks as Markdown.
276
- *
277
- * WHY MARKDOWN AND NOT THE RAW TEXT. `raw_content` gives the words without the shape: a heading
278
- * arrives as a heading's text, a list arrives as separate lines, and a reader cannot tell them
279
- * apart. The block tree is the document, and Markdown is the closest plain-text shape this side of
280
- * the product already understands.
281
- */
282
- export function blocksToMarkdown(blocks) {
283
- const byId = new Map();
284
- for (const block of blocks)
285
- byId.set(block.block_id, block);
286
- // The page block is the root of the tree: it has no parent, and its id is the document id.
287
- const root = blocks.find((block) => block.block_type === 1 && !block.parent_id) ?? blocks.find((block) => !block.parent_id);
288
- if (!root)
289
- return "";
290
- const lines = renderChildren(childrenOf(root), byId, 0);
291
- return lines.join("\n").replace(/\n{3,}/g, "\n\n").trim();
292
- }
293
- /**
294
- * Renders a list of blocks that hang under one parent.
295
- */
296
- function renderChildren(ids, byId, depth) {
297
- const out = [];
298
- let ordered = 0;
299
- for (const id of ids) {
300
- const block = byId.get(id);
301
- if (!block)
302
- continue;
303
- if (block.block_type === 12)
304
- ordered += 1;
305
- else
306
- ordered = 0;
307
- out.push(...renderBlock(block, byId, depth, ordered));
308
- }
309
- return out;
310
- }
311
- /**
312
- * Renders one block, and whatever hangs under it.
313
- */
314
- function renderBlock(block, byId, depth, orderedIndex) {
315
- const indent = " ".repeat(depth);
316
- const nested = (nextDepth = depth + 1) => renderChildren(childrenOf(block), byId, nextDepth);
317
- switch (block.block_type) {
318
- case 1: // page
319
- return renderChildren(childrenOf(block), byId, depth);
320
- case 2: { // text
321
- const text = blockText(block);
322
- return text ? [`${indent}${text}`, ...nested()] : nested();
323
- }
324
- case 3:
325
- case 4:
326
- case 5:
327
- case 6:
328
- case 7:
329
- case 8:
330
- case 9:
331
- case 10:
332
- case 11: { // heading1..9
333
- const level = block.block_type - 2;
334
- return [`${indent}${"#".repeat(level)} ${blockText(block)}`.trimEnd(), ...nested()];
335
- }
336
- case 12: // ordered
337
- return [`${indent}${orderedIndex}. ${blockText(block)}`.trimEnd(), ...nested()];
338
- case 13: // bullet
339
- return [`${indent}- ${blockText(block)}`.trimEnd(), ...nested()];
340
- case 14: { // code
341
- const language = CODE_LANGUAGES[(payloadOf(block)?.style?.language ?? 1)] ?? "";
342
- const text = blockText(block);
343
- return [`${indent}\`\`\`${language}`, ...text.split("\n"), `${indent}\`\`\``];
344
- }
345
- case 15: // quote
346
- return [`${indent}> ${blockText(block)}`.trimEnd(), ...nested()];
347
- case 17: { // todo
348
- const done = payloadOf(block)?.style?.done === true;
349
- return [`${indent}- [${done ? "x" : " "}] ${blockText(block)}`.trimEnd(), ...nested()];
350
- }
351
- case 19: // callout
352
- return [`${indent}> ${blockText(block)}`.trimEnd(), ...nested()];
353
- case 22: // divider
354
- return [`${indent}---`, ...nested()];
355
- case 23: { // file
356
- const file = block.file;
357
- return [`${indent}[文件:${file?.name ?? file?.token ?? ""}]`, ...nested()];
358
- }
359
- case 24:
360
- case 25: // grid, grid_column
361
- return nested();
362
- case 26: { // image
363
- const image = block.image;
364
- return [`${indent}![图片](${image?.token ?? ""})`];
365
- }
366
- case 30: // sheet
367
- return [`${indent}[电子表格]`, ...nested()];
368
- case 31: // table
369
- return [...renderTable(block, byId), ...nested()];
370
- case 32: // table cell, seen on its own
371
- return nested();
372
- case 34: { // quote container
373
- const quoted = renderChildren(childrenOf(block), byId, depth);
374
- return quoted.map((line) => (line ? `> ${line}` : ">"));
375
- }
376
- default: {
377
- const text = blockText(block);
378
- const children = nested();
379
- return text ? [`${indent}${text}`, ...children] : children;
380
- }
381
- }
382
- }
383
- const delay = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
384
- export class FeishuClient {
385
- options;
386
- token;
387
- /** Whether the newer OAuth token endpoint works for this application. */
388
- useLegacyAuth = false;
389
- /** The write queue. One write at a time, spaced by the interval the API allows. */
390
- writeChain = Promise.resolve();
391
- lastWriteAt = 0;
392
- constructor(options) {
393
- this.options = options;
394
- }
395
- get apiBase() {
396
- return DOMAINS[this.options.domain ?? "feishu"].api;
397
- }
398
- get accountsBase() {
399
- return DOMAINS[this.options.domain ?? "feishu"].accounts;
400
- }
401
- get fetchImpl() {
402
- return this.options.fetchImpl ?? fetch;
403
- }
404
- /**
405
- * THE ACCESS TOKEN, fetched once and reused.
406
- *
407
- * The lifetime is two hours and the API says to believe the number it returns rather than a
408
- * hardcoded one, so the expiry comes from the response and the token is renewed a couple of
409
- * minutes before it runs out.
410
- */
411
- async accessToken(force = false) {
412
- if (!force && this.token && this.token.expiresAt - Date.now() > TOKEN_SKEW_MS)
413
- return this.token.value;
414
- const token = this.useLegacyAuth ? await this.fetchLegacyToken() : await this.fetchOAuthToken();
415
- this.token = token;
416
- return token.value;
417
- }
418
- /**
419
- * The current OAuth endpoint: `POST https://accounts.feishu.cn/oauth/v3/token`.
420
- *
421
- * It is the one the documentation recommends. If it refuses - an older application, a tenant
422
- * that has not been migrated - the legacy endpoint is tried instead, and the client remembers
423
- * so the failing call is not made again on every token refresh.
424
- */
425
- async fetchOAuthToken() {
426
- const body = new URLSearchParams({
427
- grant_type: "client_credentials",
428
- client_id: this.options.appId,
429
- client_secret: this.options.appSecret,
430
- });
431
- let response;
432
- try {
433
- response = await this.fetchImpl(`${this.accountsBase}/oauth/v3/token`, {
434
- method: "POST",
435
- headers: { "content-type": "application/x-www-form-urlencoded" },
436
- body: body.toString(),
437
- });
438
- }
439
- catch (error) {
440
- // A network failure here says nothing about the credentials, so the legacy endpoint gets a
441
- // turn before the failure is reported.
442
- return this.fetchLegacyToken();
443
- }
444
- const payload = (await response.json().catch(() => ({})));
445
- const value = payload.access_token;
446
- if (!value || (payload.code !== undefined && payload.code !== 0)) {
447
- this.useLegacyAuth = true;
448
- try {
449
- return await this.fetchLegacyToken();
450
- }
451
- catch {
452
- const code = payload.code ?? response.status;
453
- throw new FeishuError(code, describeFeishuError(code, payload.msg ?? `获取访问凭证失败(HTTP ${response.status})`), ERROR_HINTS[code]);
454
- }
455
- }
456
- return { value, expiresAt: Date.now() + Math.max(60, payload.expires_in ?? 7200) * 1000 };
457
- }
458
- /**
459
- * The historical endpoint: `POST /open-apis/auth/v3/tenant_access_token/internal`.
460
- *
461
- * It is documented as superseded, but it is the endpoint every 自建应用 has worked with for
462
- * years, and a fallback that costs one request is cheaper than a feature that does not run.
463
- */
464
- async fetchLegacyToken() {
465
- const response = await this.fetchImpl(`${this.apiBase}/open-apis/auth/v3/tenant_access_token/internal`, {
466
- method: "POST",
467
- headers: { "content-type": "application/json; charset=utf-8" },
468
- body: JSON.stringify({ app_id: this.options.appId, app_secret: this.options.appSecret }),
469
- });
470
- const payload = (await response.json().catch(() => ({})));
471
- if (!payload.tenant_access_token || (payload.code !== undefined && payload.code !== 0)) {
472
- const code = payload.code ?? response.status;
473
- throw new FeishuError(code, describeFeishuError(code, payload.msg ?? `获取访问凭证失败(HTTP ${response.status})`), ERROR_HINTS[code]);
474
- }
475
- return { value: payload.tenant_access_token, expiresAt: Date.now() + Math.max(60, payload.expire ?? 7200) * 1000 };
476
- }
477
- /**
478
- * ONE CALL, with the bearer token attached.
479
- *
480
- * A 429 is waited out rather than reported: the API tells us how many seconds until the window
481
- * resets, and a write that gives up because it was 200ms early is a write that did not happen.
482
- */
483
- async request(method, path, body, options = {}) {
484
- const attempt = async () => {
485
- const token = await this.accessToken();
486
- const response = await this.fetchImpl(`${this.apiBase}${path}`, {
487
- method,
488
- headers: {
489
- Authorization: `Bearer ${token}`,
490
- "content-type": "application/json; charset=utf-8",
491
- },
492
- ...(body === undefined ? {} : { body: JSON.stringify(body) }),
493
- });
494
- const payload = (await response.json().catch(() => ({})));
495
- if (response.status === 429) {
496
- const reset = Number(response.headers.get("x-ogw-ratelimit-reset") ?? "1");
497
- await delay(Math.max(500, Math.min(10_000, (Number.isFinite(reset) ? reset : 1) * 1000)));
498
- throw new FeishuError(99991400, "触发了飞书的频率限制");
499
- }
500
- if (payload.code !== undefined && payload.code !== 0) {
501
- // A token that has expired is the one error worth a second attempt with a fresh one: the
502
- // credential was good an hour ago, and the fix is a new token, not a different request.
503
- if (payload.code === 99991663 || payload.code === 99991664) {
504
- await this.accessToken(true);
505
- }
506
- throw new FeishuError(payload.code, describeFeishuError(payload.code, payload.msg ?? ""), ERROR_HINTS[payload.code]);
507
- }
508
- if (!response.ok) {
509
- throw new FeishuError(response.status, payload.msg ?? `飞书接口返回 HTTP ${response.status}`);
510
- }
511
- return (payload.data ?? payload);
512
- };
513
- const withSlot = async () => {
514
- let lastError;
515
- for (let tries = 0; tries < 3; tries += 1) {
516
- try {
517
- return await attempt();
518
- }
519
- catch (error) {
520
- lastError = error;
521
- // Two errors are worth another attempt: a rate limit (the API says when to come back)
522
- // and an expired token (a fresh one was just fetched).
523
- if (error instanceof FeishuError && (error.code === 99991400 || error.code === 99991663 || error.code === 99991664) && tries < 2)
524
- continue;
525
- throw error;
526
- }
527
- }
528
- throw lastError;
529
- };
530
- return options.write ? this.withWriteSlot(withSlot) : withSlot();
531
- }
532
- /**
533
- * Runs one write, spaced from the last one.
534
- */
535
- async withWriteSlot(fn) {
536
- const result = this.writeChain.then(async () => {
537
- const wait = WRITE_INTERVAL_MS - (Date.now() - this.lastWriteAt);
538
- if (wait > 0)
539
- await delay(wait);
540
- this.lastWriteAt = Date.now();
541
- return fn();
542
- });
543
- this.writeChain = result.then(() => undefined, () => undefined);
544
- return result;
545
- }
546
- /** The basic information about a document: its title and its version. */
547
- async documentMeta(documentId) {
548
- const data = await this.request("GET", `/open-apis/docx/v1/documents/${encodeURIComponent(documentId)}`);
549
- const document = data?.document ?? {};
550
- return { documentId: document.document_id ?? documentId, title: document.title, revisionId: document.revision_id };
551
- }
552
- /** Every block of a document, across every page. */
553
- async documentBlocks(documentId) {
554
- const items = [];
555
- let pageToken;
556
- do {
557
- const query = new URLSearchParams({ page_size: String(MAX_BLOCKS_PER_PAGE) });
558
- if (pageToken)
559
- query.set("page_token", pageToken);
560
- const data = await this.request("GET", `/open-apis/docx/v1/documents/${encodeURIComponent(documentId)}/blocks?${query.toString()}`);
561
- items.push(...(data?.items ?? []));
562
- pageToken = data?.has_more && data.page_token ? data.page_token : undefined;
563
- } while (pageToken);
564
- return items;
565
- }
566
- /** The plain text of a document, without any structure. */
567
- async rawContent(documentId) {
568
- const data = await this.request("GET", `/open-apis/docx/v1/documents/${encodeURIComponent(documentId)}/raw_content`);
569
- return data?.content ?? "";
570
- }
571
- /** The blocks directly under the page block - the document's top level. */
572
- async rootChildren(documentId) {
573
- const ids = [];
574
- let pageToken;
575
- let answered = false;
576
- do {
577
- const query = new URLSearchParams({ page_size: String(MAX_BLOCKS_PER_PAGE) });
578
- if (pageToken)
579
- query.set("page_token", pageToken);
580
- const data = await this.request("GET", `/open-apis/docx/v1/documents/${encodeURIComponent(documentId)}/blocks/${encodeURIComponent(documentId)}/children?${query.toString()}`);
581
- if (!Array.isArray(data?.items))
582
- break;
583
- answered = true;
584
- ids.push(...data.items.map((item) => item.block_id));
585
- pageToken = data.has_more && data.page_token ? data.page_token : undefined;
586
- } while (pageToken);
587
- if (answered)
588
- return ids;
589
- // The dedicated children endpoint is the cheap way to ask this question; if the API declines
590
- // it, the full block list still knows the answer.
591
- const blocks = await this.documentBlocks(documentId);
592
- const root = blocks.find((block) => block.block_type === 1 && !block.parent_id);
593
- return root ? childrenOf(root) : [];
594
- }
595
- /** Creates an empty document. The content goes in afterwards, because the API has no other way. */
596
- async createDocument(title, folderToken) {
597
- const data = await this.request("POST", "/open-apis/docx/v1/documents", { ...(folderToken ? { folder_token: folderToken } : {}), title }, { write: true });
598
- const document = data?.document ?? {};
599
- if (!document.document_id)
600
- throw new FeishuError(-1, "飞书没有返回新文档的 id");
601
- return { documentId: document.document_id, revisionId: document.revision_id };
602
- }
603
- /** Resolves a knowledge-base node to the document behind it. */
604
- async wikiNode(token) {
605
- const query = new URLSearchParams({ token, obj_type: "wiki" });
606
- const data = await this.request("GET", `/open-apis/wiki/v2/spaces/get_node?${query.toString()}`);
607
- const node = data?.node ?? {};
608
- if (!node.obj_token || !node.obj_type)
609
- throw new FeishuError(131005, "知识库节点没有对应的云文档");
610
- return { objToken: node.obj_token, objType: node.obj_type, title: node.title };
611
- }
612
- /**
613
- * Turns Markdown into the blocks that represent it.
614
- *
615
- * The API will not create a document with content in it, and building the blocks by hand would
616
- * mean reimplementing the conversion Feishu already does - tables, code fences, todos and all.
617
- */
618
- async convertMarkdown(markdown) {
619
- const data = await this.request("POST", "/open-apis/docx/v1/documents/blocks/convert", { content_type: "markdown", content: markdown });
620
- const blocks = (data?.blocks ?? []).map(stripTableMergeInfo);
621
- const firstLevelBlockIds = data?.first_level_block_ids ?? [];
622
- return { blocks, firstLevelBlockIds };
623
- }
624
- /**
625
- * Inserts blocks under the page block.
626
- *
627
- * THE CHUNKING IS NOT AN OPTIMISATION. The API accepts at most 1000 blocks per call, and the
628
- * blocks of one subtree cannot be split across calls - so the top-level blocks are packed into
629
- * batches that each fit, and a document of any length goes in.
630
- */
631
- async insertBlocks(documentId, blocks, firstLevelBlockIds, index = -1) {
632
- if (!blocks.length)
633
- return undefined;
634
- const byId = new Map();
635
- for (const block of blocks)
636
- byId.set(block.block_id, block);
637
- const tops = firstLevelBlockIds.filter((id) => byId.has(id));
638
- const subtreeOf = (id) => {
639
- const block = byId.get(id);
640
- if (!block)
641
- return [];
642
- const out = [block];
643
- for (const child of childrenOf(block))
644
- out.push(...subtreeOf(child));
645
- return out;
646
- };
647
- const batches = [];
648
- let current = [];
649
- let currentTops = [];
650
- for (const top of tops) {
651
- const subtree = subtreeOf(top);
652
- if (current.length && current.length + subtree.length > MAX_DESCENDANTS_PER_CALL) {
653
- batches.push({ blocks: current, tops: currentTops });
654
- current = [];
655
- currentTops = [];
656
- }
657
- current.push(...subtree);
658
- currentTops.push(top);
659
- }
660
- if (current.length)
661
- batches.push({ blocks: current, tops: currentTops });
662
- // A conversion that produced blocks but no first-level list is a shape this client does not
663
- // understand; sending the blocks as their own top level is better than sending nothing.
664
- if (!batches.length)
665
- batches.push({ blocks: blocks.slice(0, MAX_DESCENDANTS_PER_CALL), tops: [] });
666
- let revision;
667
- for (const batch of batches) {
668
- const data = await this.request("POST", `/open-apis/docx/v1/documents/${encodeURIComponent(documentId)}/blocks/${encodeURIComponent(documentId)}/descendant`, { children_id: batch.tops.length ? batch.tops : batch.blocks.map((block) => block.block_id), descendants: batch.blocks, index }, { write: true });
669
- revision = data?.document_revision_id ?? revision;
670
- }
671
- return revision;
672
- }
673
- /** Deletes a range of the page block's children. The range is left-closed and right-open. */
674
- async deleteChildren(documentId, startIndex, endIndex) {
675
- if (endIndex <= startIndex)
676
- return undefined;
677
- const data = await this.request("DELETE", `/open-apis/docx/v1/documents/${encodeURIComponent(documentId)}/blocks/${encodeURIComponent(documentId)}/children/batch_delete`, { start_index: startIndex, end_index: endIndex }, { write: true });
678
- return data?.document_revision_id;
679
- }
680
- }
681
- /**
682
- * Removes the read-only merge information from a table block.
683
- *
684
- * The API rejects a table block that carries `merge_info`, and the conversion endpoint hands it
685
- * back with that field on it. The documentation says to strip it, so it is stripped.
686
- */
687
- export function stripTableMergeInfo(block) {
688
- if (block.block_type !== 31)
689
- return block;
690
- const table = block.table;
691
- if (!table || table.merge_info === undefined)
692
- return block;
693
- const { merge_info: _ignored, ...rest } = table;
694
- return { ...block, table: rest };
695
- }