feishu-client.js 7.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244
  1. import fs from "node:fs";
  2. import path from "node:path";
  3. import { pipeline } from "node:stream/promises";
  4. import { Readable } from "node:stream";
  5. const DEFAULT_DOMAIN = "https://open.feishu.cn";
  6. export class FeishuClient {
  7. /** @param {{ appId: string; appSecret: string; domain?: string }} options */
  8. constructor(options) {
  9. this.appId = options.appId;
  10. this.appSecret = options.appSecret;
  11. this.domain = (options.domain || DEFAULT_DOMAIN).replace(/\/$/, "");
  12. this.token = null;
  13. this.tokenExpireAt = 0;
  14. }
  15. static fromEnv() {
  16. const appId = process.env.FEISHU_APP_ID;
  17. const appSecret = process.env.FEISHU_APP_SECRET;
  18. if (!appId || !appSecret) {
  19. throw new Error("缺少 FEISHU_APP_ID 或 FEISHU_APP_SECRET 环境变量");
  20. }
  21. return new FeishuClient({
  22. appId,
  23. appSecret,
  24. domain: process.env.FEISHU_DOMAIN || DEFAULT_DOMAIN,
  25. });
  26. }
  27. async getTenantAccessToken() {
  28. const now = Date.now();
  29. if (this.token && now < this.tokenExpireAt - 60_000) {
  30. return this.token;
  31. }
  32. const res = await fetch(
  33. `${this.domain}/open-apis/auth/v3/tenant_access_token/internal`,
  34. {
  35. method: "POST",
  36. headers: { "Content-Type": "application/json; charset=utf-8" },
  37. body: JSON.stringify({
  38. app_id: this.appId,
  39. app_secret: this.appSecret,
  40. }),
  41. }
  42. );
  43. const data = await res.json();
  44. if (data.code !== 0) {
  45. throw new Error(`获取 tenant_access_token 失败: ${data.msg || res.status}`);
  46. }
  47. this.token = data.tenant_access_token;
  48. this.tokenExpireAt = now + (data.expire || 7200) * 1000;
  49. return this.token;
  50. }
  51. async request(method, apiPath, { query, body, raw = false } = {}) {
  52. const token = await this.getTenantAccessToken();
  53. const url = new URL(`${this.domain}${apiPath}`);
  54. if (query) {
  55. for (const [k, v] of Object.entries(query)) {
  56. if (v !== undefined && v !== null && v !== "") {
  57. url.searchParams.set(k, String(v));
  58. }
  59. }
  60. }
  61. const headers = { Authorization: `Bearer ${token}` };
  62. let payload = body;
  63. if (body && !(body instanceof FormData)) {
  64. headers["Content-Type"] = "application/json; charset=utf-8";
  65. payload = JSON.stringify(body);
  66. }
  67. const res = await fetch(url, { method, headers, body: payload });
  68. if (raw) {
  69. return res;
  70. }
  71. const data = await res.json();
  72. if (data.code !== 0) {
  73. throw new Error(`${apiPath} 失败 (${data.code}): ${data.msg}`);
  74. }
  75. return data.data;
  76. }
  77. async listFolder(folderToken, pageToken) {
  78. return this.request("GET", "/open-apis/drive/v1/files", {
  79. query: {
  80. folder_token: folderToken,
  81. page_size: 200,
  82. page_token: pageToken,
  83. },
  84. });
  85. }
  86. async getFileMeta(fileToken, docType) {
  87. const tryTypes = docType
  88. ? [docType]
  89. : ["file", "docx", "doc", "sheet", "bitable", "slides", "folder"];
  90. for (const type of tryTypes) {
  91. try {
  92. const data = await this.request(
  93. "POST",
  94. "/open-apis/drive/v1/metas/batch_query",
  95. {
  96. body: {
  97. request_docs: [{ doc_token: fileToken, doc_type: type }],
  98. },
  99. }
  100. );
  101. const meta = data.metas?.[0];
  102. if (meta) {
  103. return {
  104. ...meta,
  105. token: fileToken,
  106. type,
  107. name: meta.title || meta.name || "",
  108. };
  109. }
  110. } catch {
  111. // try next doc type
  112. }
  113. }
  114. throw new Error(`无法获取文件元数据: ${fileToken}`);
  115. }
  116. async downloadFile(fileToken, savePath) {
  117. const token = await this.getTenantAccessToken();
  118. const res = await fetch(
  119. `${this.domain}/open-apis/drive/v1/files/${fileToken}/download`,
  120. { headers: { Authorization: `Bearer ${token}` } }
  121. );
  122. if (!res.ok) {
  123. const text = await res.text();
  124. throw new Error(`下载失败 (${res.status}): ${text.slice(0, 300)}`);
  125. }
  126. await fs.promises.mkdir(path.dirname(savePath), { recursive: true });
  127. await pipeline(Readable.fromWeb(res.body), fs.createWriteStream(savePath));
  128. const stat = await fs.promises.stat(savePath);
  129. return { path: savePath, size: stat.size };
  130. }
  131. async uploadFile({ folderToken, localPath, fileName, replaceFileToken }) {
  132. const stat = await fs.promises.stat(localPath);
  133. const size = stat.size;
  134. let name = fileName || path.basename(localPath);
  135. let parentToken = folderToken;
  136. if (replaceFileToken) {
  137. const meta = await this.getFileMeta(replaceFileToken);
  138. parentToken = meta.parent_token || meta.parent_node || folderToken;
  139. if (!name || name === path.basename(localPath)) {
  140. name = meta.name || name;
  141. }
  142. }
  143. if (!parentToken) {
  144. throw new Error("需要 folderToken 或有效的 replaceFileToken(含 parent_token)");
  145. }
  146. const form = new FormData();
  147. form.append("file_name", name);
  148. form.append("parent_type", "explorer");
  149. form.append("parent_node", parentToken);
  150. form.append("size", String(size));
  151. form.append(
  152. "file",
  153. new Blob([await fs.promises.readFile(localPath)]),
  154. name
  155. );
  156. const data = await this.request("POST", "/open-apis/drive/v1/files/upload_all", {
  157. body: form,
  158. });
  159. if (replaceFileToken) {
  160. try {
  161. await this.deleteFile(replaceFileToken);
  162. } catch (err) {
  163. return {
  164. ...data,
  165. warning: `新文件已上传,但删除旧文件失败: ${err.message}`,
  166. };
  167. }
  168. }
  169. return data;
  170. }
  171. async deleteFile(fileToken) {
  172. return this.request("DELETE", `/open-apis/drive/v1/files/${fileToken}`, {});
  173. }
  174. /** 导出在线文档/表格为 Office 文件并下载到本地 */
  175. async exportAndDownload({ token, type, fileExtension, savePath }) {
  176. let task = await this.request("POST", "/open-apis/drive/v1/export_tasks", {
  177. body: {
  178. token,
  179. type,
  180. file_extension: fileExtension,
  181. },
  182. });
  183. const ticket = task.ticket;
  184. for (let i = 0; i < 60; i += 1) {
  185. await sleep(1500);
  186. const status = await this.request(
  187. "GET",
  188. `/open-apis/drive/v1/export_tasks/${ticket}`
  189. );
  190. if (status.result?.file_token) {
  191. return this.downloadFile(status.result.file_token, savePath);
  192. }
  193. const jobStatus = status.result?.job_status;
  194. if (jobStatus === 2 || jobStatus === 3) {
  195. throw new Error(`导出失败: ${JSON.stringify(status.result)}`);
  196. }
  197. }
  198. throw new Error("导出超时,请稍后重试");
  199. }
  200. }
  201. function sleep(ms) {
  202. return new Promise((resolve) => setTimeout(resolve, ms));
  203. }
  204. /** 根据飞书 file type 推断 export_tasks 的 type 参数 */
  205. export function inferExportType(feishuType) {
  206. const map = {
  207. docx: { type: "docx", extensions: ["docx", "pdf"] },
  208. doc: { type: "doc", extensions: ["docx", "pdf"] },
  209. sheet: { type: "sheet", extensions: ["xlsx", "csv"] },
  210. bitable: { type: "bitable", extensions: ["xlsx", "csv"] },
  211. slides: { type: "slides", extensions: ["pptx", "pdf"] },
  212. };
  213. return map[feishuType] || null;
  214. }
  215. export function resolveWorkspacePath(inputPath, workspaceRoot) {
  216. const root = workspaceRoot || process.cwd();
  217. const resolved = path.isAbsolute(inputPath)
  218. ? path.normalize(inputPath)
  219. : path.normalize(path.join(root, inputPath));
  220. if (!resolved.startsWith(path.normalize(root))) {
  221. throw new Error(`路径必须在工作区内: ${inputPath}`);
  222. }
  223. return resolved;
  224. }