| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244 |
- import fs from "node:fs";
- import path from "node:path";
- import { pipeline } from "node:stream/promises";
- import { Readable } from "node:stream";
- const DEFAULT_DOMAIN = "https://open.feishu.cn";
- export class FeishuClient {
- /** @param {{ appId: string; appSecret: string; domain?: string }} options */
- constructor(options) {
- this.appId = options.appId;
- this.appSecret = options.appSecret;
- this.domain = (options.domain || DEFAULT_DOMAIN).replace(/\/$/, "");
- this.token = null;
- this.tokenExpireAt = 0;
- }
- static fromEnv() {
- const appId = process.env.FEISHU_APP_ID;
- const appSecret = process.env.FEISHU_APP_SECRET;
- if (!appId || !appSecret) {
- throw new Error("缺少 FEISHU_APP_ID 或 FEISHU_APP_SECRET 环境变量");
- }
- return new FeishuClient({
- appId,
- appSecret,
- domain: process.env.FEISHU_DOMAIN || DEFAULT_DOMAIN,
- });
- }
- async getTenantAccessToken() {
- const now = Date.now();
- if (this.token && now < this.tokenExpireAt - 60_000) {
- return this.token;
- }
- const res = await fetch(
- `${this.domain}/open-apis/auth/v3/tenant_access_token/internal`,
- {
- method: "POST",
- headers: { "Content-Type": "application/json; charset=utf-8" },
- body: JSON.stringify({
- app_id: this.appId,
- app_secret: this.appSecret,
- }),
- }
- );
- const data = await res.json();
- if (data.code !== 0) {
- throw new Error(`获取 tenant_access_token 失败: ${data.msg || res.status}`);
- }
- this.token = data.tenant_access_token;
- this.tokenExpireAt = now + (data.expire || 7200) * 1000;
- return this.token;
- }
- async request(method, apiPath, { query, body, raw = false } = {}) {
- const token = await this.getTenantAccessToken();
- const url = new URL(`${this.domain}${apiPath}`);
- if (query) {
- for (const [k, v] of Object.entries(query)) {
- if (v !== undefined && v !== null && v !== "") {
- url.searchParams.set(k, String(v));
- }
- }
- }
- const headers = { Authorization: `Bearer ${token}` };
- let payload = body;
- if (body && !(body instanceof FormData)) {
- headers["Content-Type"] = "application/json; charset=utf-8";
- payload = JSON.stringify(body);
- }
- const res = await fetch(url, { method, headers, body: payload });
- if (raw) {
- return res;
- }
- const data = await res.json();
- if (data.code !== 0) {
- throw new Error(`${apiPath} 失败 (${data.code}): ${data.msg}`);
- }
- return data.data;
- }
- async listFolder(folderToken, pageToken) {
- return this.request("GET", "/open-apis/drive/v1/files", {
- query: {
- folder_token: folderToken,
- page_size: 200,
- page_token: pageToken,
- },
- });
- }
- async getFileMeta(fileToken, docType) {
- const tryTypes = docType
- ? [docType]
- : ["file", "docx", "doc", "sheet", "bitable", "slides", "folder"];
- for (const type of tryTypes) {
- try {
- const data = await this.request(
- "POST",
- "/open-apis/drive/v1/metas/batch_query",
- {
- body: {
- request_docs: [{ doc_token: fileToken, doc_type: type }],
- },
- }
- );
- const meta = data.metas?.[0];
- if (meta) {
- return {
- ...meta,
- token: fileToken,
- type,
- name: meta.title || meta.name || "",
- };
- }
- } catch {
- // try next doc type
- }
- }
- throw new Error(`无法获取文件元数据: ${fileToken}`);
- }
- async downloadFile(fileToken, savePath) {
- const token = await this.getTenantAccessToken();
- const res = await fetch(
- `${this.domain}/open-apis/drive/v1/files/${fileToken}/download`,
- { headers: { Authorization: `Bearer ${token}` } }
- );
- if (!res.ok) {
- const text = await res.text();
- throw new Error(`下载失败 (${res.status}): ${text.slice(0, 300)}`);
- }
- await fs.promises.mkdir(path.dirname(savePath), { recursive: true });
- await pipeline(Readable.fromWeb(res.body), fs.createWriteStream(savePath));
- const stat = await fs.promises.stat(savePath);
- return { path: savePath, size: stat.size };
- }
- async uploadFile({ folderToken, localPath, fileName, replaceFileToken }) {
- const stat = await fs.promises.stat(localPath);
- const size = stat.size;
- let name = fileName || path.basename(localPath);
- let parentToken = folderToken;
- if (replaceFileToken) {
- const meta = await this.getFileMeta(replaceFileToken);
- parentToken = meta.parent_token || meta.parent_node || folderToken;
- if (!name || name === path.basename(localPath)) {
- name = meta.name || name;
- }
- }
- if (!parentToken) {
- throw new Error("需要 folderToken 或有效的 replaceFileToken(含 parent_token)");
- }
- const form = new FormData();
- form.append("file_name", name);
- form.append("parent_type", "explorer");
- form.append("parent_node", parentToken);
- form.append("size", String(size));
- form.append(
- "file",
- new Blob([await fs.promises.readFile(localPath)]),
- name
- );
- const data = await this.request("POST", "/open-apis/drive/v1/files/upload_all", {
- body: form,
- });
- if (replaceFileToken) {
- try {
- await this.deleteFile(replaceFileToken);
- } catch (err) {
- return {
- ...data,
- warning: `新文件已上传,但删除旧文件失败: ${err.message}`,
- };
- }
- }
- return data;
- }
- async deleteFile(fileToken) {
- return this.request("DELETE", `/open-apis/drive/v1/files/${fileToken}`, {});
- }
- /** 导出在线文档/表格为 Office 文件并下载到本地 */
- async exportAndDownload({ token, type, fileExtension, savePath }) {
- let task = await this.request("POST", "/open-apis/drive/v1/export_tasks", {
- body: {
- token,
- type,
- file_extension: fileExtension,
- },
- });
- const ticket = task.ticket;
- for (let i = 0; i < 60; i += 1) {
- await sleep(1500);
- const status = await this.request(
- "GET",
- `/open-apis/drive/v1/export_tasks/${ticket}`
- );
- if (status.result?.file_token) {
- return this.downloadFile(status.result.file_token, savePath);
- }
- const jobStatus = status.result?.job_status;
- if (jobStatus === 2 || jobStatus === 3) {
- throw new Error(`导出失败: ${JSON.stringify(status.result)}`);
- }
- }
- throw new Error("导出超时,请稍后重试");
- }
- }
- function sleep(ms) {
- return new Promise((resolve) => setTimeout(resolve, ms));
- }
- /** 根据飞书 file type 推断 export_tasks 的 type 参数 */
- export function inferExportType(feishuType) {
- const map = {
- docx: { type: "docx", extensions: ["docx", "pdf"] },
- doc: { type: "doc", extensions: ["docx", "pdf"] },
- sheet: { type: "sheet", extensions: ["xlsx", "csv"] },
- bitable: { type: "bitable", extensions: ["xlsx", "csv"] },
- slides: { type: "slides", extensions: ["pptx", "pdf"] },
- };
- return map[feishuType] || null;
- }
- export function resolveWorkspacePath(inputPath, workspaceRoot) {
- const root = workspaceRoot || process.cwd();
- const resolved = path.isAbsolute(inputPath)
- ? path.normalize(inputPath)
- : path.normalize(path.join(root, inputPath));
- if (!resolved.startsWith(path.normalize(root))) {
- throw new Error(`路径必须在工作区内: ${inputPath}`);
- }
- return resolved;
- }
|