const FEISHU_API_ORIGIN = "https://open.feishu.cn"; const MAX_SHEET_ROWS = 5_000; const MAX_SHEET_COLUMNS = 100; const MAX_CONTENT_ROWS = 1_000; const DEFAULT_MAX_MEDIA_BYTES = 200_000_000; export type FeishuBindings = { FEISHU_APP_ID?: string; FEISHU_APP_SECRET?: string; }; export type FeishuSourceImage = { index: number; fileToken: string; width: number | null; height: number | null; }; export type FeishuSourceVideo = { index: number; fileToken: string; name: string; mimeType: string; size: number | null; }; export type FeishuSourceRow = { sourceRow: number; title: string; body: string; images: FeishuSourceImage[]; videos: FeishuSourceVideo[]; }; export type FeishuSource = { url: string; wikiToken: string; spreadsheetToken: string; sheetId: string; sheetName: string; syncedAt: string; columns: string[]; rows: FeishuSourceRow[]; }; type FetchLike = typeof fetch; type FeishuEnvelope = { code?: number; msg?: string; data?: T; tenant_access_token?: string; expire?: number; }; type SheetInfo = { sheet_id?: string; title?: string; hidden?: boolean; resource_type?: string; grid_properties?: { row_count?: number; column_count?: number; }; }; type CachedAccessToken = { appId: string; token: string; expiresAt: number; }; let cachedAccessToken: CachedAccessToken | null = null; export class FeishuSourceError extends Error { status: number; constructor(message: string, status = 500) { super(message); this.name = "FeishuSourceError"; this.status = status; } } function bindingValue(value: unknown) { return String(value ?? "").trim(); } function providerMessage(value: unknown) { const message = String(value ?? "").trim(); return message .replace(/https?:\/\/[^\s"'<>]+/gi, "[飞书权限链接]") .slice(0, 240); } function normalizeHeader(value: unknown) { return cellText(value) .toLowerCase() .replace(/[\s()()【】[\]_\-—::/\\]+/g, ""); } function isRecord(value: unknown): value is Record { return value !== null && typeof value === "object" && !Array.isArray(value); } function cellText(value: unknown): string { if (value === null || value === undefined) return ""; if (typeof value === "string") return value.trim(); if (typeof value === "number" || typeof value === "boolean") { return String(value); } if (Array.isArray(value)) { return value .map(cellText) .filter(Boolean) .join(""); } if ( !isRecord(value) || value.type === "embed-image" || value.type === "attachment" ) return ""; if (typeof value.text === "string") return value.text.trim(); if (typeof value.value === "string") return value.value.trim(); return ""; } function extractVideos(value: unknown, output: FeishuSourceVideo[]) { if (Array.isArray(value)) { for (const item of value) extractVideos(item, output); return; } if (!isRecord(value)) return; const fileToken = bindingValue(value.fileToken ?? value.file_token); const mimeType = bindingValue(value.mimeType ?? value.mime_type); const name = bindingValue(value.text ?? value.name ?? value.file_name); const isVideo = value.type === "attachment" && (mimeType.startsWith("video/") || /\.(?:mp4|mov|m4v|webm)$/i.test(name)); if (isVideo && fileToken) { output.push({ index: 0, fileToken, name: name || "视频", mimeType: mimeType || "video/mp4", size: typeof value.size === "number" && Number.isFinite(value.size) ? value.size : null, }); } for (const child of Object.values(value)) { if (child !== value.fileToken && child !== value.file_token) { extractVideos(child, output); } } } function extractImages(value: unknown, output: FeishuSourceImage[]) { if (Array.isArray(value)) { for (const item of value) extractImages(item, output); return; } if (!isRecord(value)) return; const fileToken = bindingValue(value.fileToken ?? value.image_token); if (value.type === "embed-image" && fileToken) { output.push({ index: 0, fileToken, width: typeof value.width === "number" ? value.width : typeof value.image_width === "number" ? value.image_width : null, height: typeof value.height === "number" ? value.height : typeof value.image_height === "number" ? value.image_height : null, }); } for (const child of Object.values(value)) { if (child !== value.fileToken && child !== value.image_token) { extractImages(child, output); } } } function columnName(index: number) { let value = index + 1; let result = ""; while (value > 0) { const remainder = (value - 1) % 26; result = String.fromCharCode(65 + remainder) + result; value = Math.floor((value - 1) / 26); } return result; } function headerMatches(header: string, candidates: RegExp[]) { return candidates.some((candidate) => candidate.test(header)); } function findHeader(values: unknown[][]) { let best: | { rowIndex: number; idIndex: number; titleIndex: number; bodyIndex: number; tagsIndex: number; videoIndex: number; score: number; } | undefined; for (let rowIndex = 0; rowIndex < Math.min(values.length, 20); rowIndex += 1) { const headers = (values[rowIndex] ?? []).map(normalizeHeader); const idIndex = headers.findIndex((header) => headerMatches(header, [/^id$/, /作品id/, /序号/, /编号/]), ); const titleIndex = headers.findIndex((header) => headerMatches(header, [/标题/, /题目/]), ); const bodyIndex = headers.findIndex((header) => headerMatches(header, [/笔记内容/, /正文/, /文案/, /^内容$/]), ); const tagsIndex = headers.findIndex((header) => headerMatches(header, [/标签/, /话题/, /^tags?$/]), ); const videoIndex = headers.findIndex((header) => headerMatches(header, [/^视频\d*$/, /视频文件/, /视频素材/]), ); const score = (titleIndex >= 0 ? 5 : 0) + (bodyIndex >= 0 ? 5 : 0) + (videoIndex >= 0 ? 2 : 0) + (idIndex >= 0 ? 1 : 0) + (tagsIndex >= 0 ? 1 : 0); if (!best || score > best.score) { best = { rowIndex, idIndex, titleIndex, bodyIndex, tagsIndex, videoIndex, score, }; } } if (!best || best.titleIndex < 0 || best.bodyIndex < 0) { throw new FeishuSourceError( "没有找到“标题”和“正文/笔记内容”列,请检查飞书表头", 422, ); } return best; } function parseRows(values: unknown[][]) { const header = findHeader(values); const headerRow = values[header.rowIndex] ?? []; const usedSourceRows = new Set(); const rows: FeishuSourceRow[] = []; let maxImageCount = 0; let maxVideoCount = 0; for ( let rowIndex = header.rowIndex + 1; rowIndex < values.length && rows.length < MAX_CONTENT_ROWS; rowIndex += 1 ) { const row = values[rowIndex] ?? []; const title = cellText(row[header.titleIndex]); if (!title) continue; const rawBody = cellText(row[header.bodyIndex]); const tags = header.tagsIndex >= 0 ? cellText(row[header.tagsIndex]) : ""; const body = tags && !rawBody.includes(tags) ? [rawBody, tags].filter(Boolean).join("\n\n") : rawBody; const idValue = header.idIndex >= 0 ? Number.parseInt(cellText(row[header.idIndex]), 10) : Number.NaN; let sourceRow = Number.isInteger(idValue) && idValue > 0 ? idValue : rowIndex + 1; if (usedSourceRows.has(sourceRow)) sourceRow = rowIndex + 1; usedSourceRows.add(sourceRow); const collectedImages: FeishuSourceImage[] = []; for (const cell of row) extractImages(cell, collectedImages); const seenTokens = new Set(); const images = collectedImages .filter((image) => { if (seenTokens.has(image.fileToken)) return false; seenTokens.add(image.fileToken); return true; }) .map((image, imageIndex) => ({ ...image, index: imageIndex + 1 })); maxImageCount = Math.max(maxImageCount, images.length); const collectedVideos: FeishuSourceVideo[] = []; if (header.videoIndex >= 0) { extractVideos(row[header.videoIndex], collectedVideos); } const seenVideoTokens = new Set(); const videos = collectedVideos .filter((video) => { if (seenVideoTokens.has(video.fileToken)) return false; seenVideoTokens.add(video.fileToken); return true; }) .map((video, videoIndex) => ({ ...video, index: videoIndex + 1 })); maxVideoCount = Math.max(maxVideoCount, videos.length); rows.push({ sourceRow, title, body, images, videos }); } if (rows.length === 0) { throw new FeishuSourceError("表格中没有可导入的有效标题行", 422); } const matchedColumns = [ header.idIndex >= 0 ? cellText(headerRow[header.idIndex]) : "", cellText(headerRow[header.titleIndex]) || "标题", header.tagsIndex >= 0 ? cellText(headerRow[header.tagsIndex]) : "", cellText(headerRow[header.bodyIndex]) || "正文", ...Array.from({ length: maxImageCount }, (_, index) => `图片${index + 1}`), ...Array.from({ length: maxVideoCount }, (_, index) => `视频${index + 1}`), ].filter(Boolean); return { columns: [...new Set(matchedColumns)], rows, }; } async function jsonEnvelope( response: Response, fallbackMessage: string, ): Promise> { const text = await response.text(); let payload: FeishuEnvelope; try { payload = JSON.parse(text) as FeishuEnvelope; } catch { throw new FeishuSourceError( `${fallbackMessage}(飞书返回了异常响应)`, 502, ); } if (!response.ok || (typeof payload.code === "number" && payload.code !== 0)) { const code = Number(payload.code); const rawMessage = providerMessage(payload.msg); if ([99991672, 99991679].includes(code)) { throw new FeishuSourceError( "飞书应用缺少电子表格或知识库读取权限,请先在飞书开放平台开通权限", 403, ); } if ([131006, 1310213].includes(code) || response.status === 403) { throw new FeishuSourceError( "飞书应用没有这张表的访问权限,请在表格中添加该文档应用或将应用加入知识库", 403, ); } if ([131005, 1310214].includes(code) || response.status === 404) { throw new FeishuSourceError("没有找到对应的飞书表格", 404); } throw new FeishuSourceError( rawMessage ? `${fallbackMessage}:${rawMessage}` : `${fallbackMessage}(HTTP ${response.status})`, response.status >= 400 ? response.status : 502, ); } return payload; } function feishuConfig(bindings: FeishuBindings) { const appId = bindingValue(bindings.FEISHU_APP_ID); const appSecret = bindingValue(bindings.FEISHU_APP_SECRET); if (!appId || !appSecret) { throw new FeishuSourceError( "飞书 API 尚未配置,请先配置应用的 App ID 和 App Secret", 503, ); } return { appId, appSecret }; } async function accessToken( bindings: FeishuBindings, fetchImpl: FetchLike, ) { const { appId, appSecret } = feishuConfig(bindings); if ( cachedAccessToken?.appId === appId && cachedAccessToken.expiresAt > Date.now() + 60_000 ) { return cachedAccessToken.token; } const response = await fetchImpl( `${FEISHU_API_ORIGIN}/open-apis/auth/v3/tenant_access_token/internal`, { method: "POST", headers: { "Content-Type": "application/json; charset=utf-8" }, body: JSON.stringify({ app_id: appId, app_secret: appSecret }), signal: AbortSignal.timeout(12_000), }, ); const payload = await jsonEnvelope(response, "获取飞书访问凭证失败"); const token = bindingValue(payload.tenant_access_token); if (!token) { throw new FeishuSourceError("飞书没有返回有效访问凭证", 502); } cachedAccessToken = { appId, token, expiresAt: Date.now() + Math.max(300, Number(payload.expire) || 7_200) * 1_000, }; return token; } async function feishuGet( path: string, token: string, fetchImpl: FetchLike, fallbackMessage: string, params?: URLSearchParams, ) { const url = new URL(path, FEISHU_API_ORIGIN); if (params) url.search = params.toString(); const response = await fetchImpl(url.toString(), { headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json; charset=utf-8", }, signal: AbortSignal.timeout(15_000), }); const payload = await jsonEnvelope(response, fallbackMessage); if (!payload.data) { throw new FeishuSourceError(`${fallbackMessage}(响应缺少数据)`, 502); } return payload.data; } function parseSourceUrl(input: string) { let url: URL; try { url = new URL(input); } catch { throw new FeishuSourceError("飞书链接格式无效", 400); } const validHost = url.hostname === "feishu.cn" || url.hostname.endsWith(".feishu.cn") || url.hostname === "larksuite.com" || url.hostname.endsWith(".larksuite.com"); if (!["http:", "https:"].includes(url.protocol) || !validHost) { throw new FeishuSourceError("请粘贴飞书 Wiki 或电子表格链接", 400); } const match = url.pathname.match(/\/(wiki|sheets|spreadsheets)\/([^/?]+)/); if (!match) { throw new FeishuSourceError("请粘贴飞书 Wiki 或电子表格链接", 400); } return { url, kind: match[1], token: match[2], requestedSheetId: bindingValue(url.searchParams.get("sheet")), }; } export async function readFeishuSource( input: string, bindings: FeishuBindings, fetchImpl: FetchLike = fetch, ): Promise { const parsed = parseSourceUrl(input); const token = await accessToken(bindings, fetchImpl); let wikiToken = ""; let spreadsheetToken = parsed.token; if (parsed.kind === "wiki") { wikiToken = parsed.token; const nodeData = await feishuGet<{ node?: { obj_type?: string; obj_token?: string }; }>( "/open-apis/wiki/v2/spaces/get_node", token, fetchImpl, "读取飞书知识库节点失败", new URLSearchParams({ token: parsed.token }), ); if (nodeData.node?.obj_type !== "sheet" || !nodeData.node.obj_token) { throw new FeishuSourceError("该飞书 Wiki 链接不是电子表格", 422); } spreadsheetToken = nodeData.node.obj_token; } const sheetData = await feishuGet<{ sheets?: SheetInfo[] }>( `/open-apis/sheets/v3/spreadsheets/${encodeURIComponent(spreadsheetToken)}/sheets/query`, token, fetchImpl, "读取飞书工作表列表失败", ); const visibleSheets = (sheetData.sheets ?? []).filter( (sheet) => sheet.sheet_id && sheet.resource_type !== "bitable" && sheet.hidden !== true, ); let selectedSheet = parsed.requestedSheetId ? visibleSheets.find((sheet) => sheet.sheet_id === parsed.requestedSheetId) : undefined; if (!parsed.requestedSheetId) { if (visibleSheets.length === 1) { selectedSheet = visibleSheets[0]; } else if (visibleSheets.length > 1) { throw new FeishuSourceError( "该表格包含多个工作表,请打开目标工作表后复制带 sheet 参数的链接", 422, ); } } if (!selectedSheet?.sheet_id) { throw new FeishuSourceError("链接中的工作表不存在或已隐藏", 404); } const rowCount = Math.max( 1, Math.min( MAX_SHEET_ROWS, Number(selectedSheet.grid_properties?.row_count) || 200, ), ); const columnCount = Math.max( 1, Math.min( MAX_SHEET_COLUMNS, Number(selectedSheet.grid_properties?.column_count) || 26, ), ); const range = `${selectedSheet.sheet_id}!A1:${columnName(columnCount - 1)}${rowCount}`; const valuesParams = new URLSearchParams(); valuesParams.append("ranges", range); const valuesData = await feishuGet<{ valueRanges?: Array<{ values?: unknown[][] }>; }>( `/open-apis/sheets/v2/spreadsheets/${encodeURIComponent(spreadsheetToken)}/values_batch_get`, token, fetchImpl, "读取飞书表格内容失败", valuesParams, ); const values = valuesData.valueRanges?.[0]?.values; if (!Array.isArray(values)) { throw new FeishuSourceError("飞书表格没有返回可读取的单元格", 422); } const parsedRows = parseRows(values); return { url: parsed.url.toString(), wikiToken, spreadsheetToken, sheetId: selectedSheet.sheet_id, sheetName: bindingValue(selectedSheet.title) || "未命名工作表", syncedAt: new Date().toISOString(), columns: parsedRows.columns, rows: parsedRows.rows, }; } export async function downloadFeishuMedia( fileToken: string, bindings: FeishuBindings, fetchImpl: FetchLike = fetch, options: { maxBytes?: number; label?: string; timeoutMs?: number } = {}, ) { const maxBytes = Math.max(1, options.maxBytes ?? DEFAULT_MAX_MEDIA_BYTES); const label = bindingValue(options.label) || "素材"; const normalizedToken = bindingValue(fileToken); if (!/^[A-Za-z0-9_-]{8,240}$/.test(normalizedToken)) { throw new FeishuSourceError(`飞书${label}标识无效`, 400); } const token = await accessToken(bindings, fetchImpl); const response = await fetchImpl( `${FEISHU_API_ORIGIN}/open-apis/drive/v1/medias/${encodeURIComponent(normalizedToken)}/download`, { headers: { Authorization: `Bearer ${token}` }, signal: AbortSignal.timeout(options.timeoutMs ?? 120_000), }, ); const declaredSize = Number(response.headers.get("content-length")); if (Number.isFinite(declaredSize) && declaredSize > maxBytes) { throw new FeishuSourceError(`飞书${label}文件过大,无法同步`, 413); } if (!response.ok) { throw new FeishuSourceError( response.status === 403 ? `飞书应用没有这个${label}的下载权限` : `下载飞书${label}失败(HTTP ${response.status})`, response.status === 403 ? 403 : 502, ); } const bytes = await response.arrayBuffer(); if (bytes.byteLength > maxBytes) { throw new FeishuSourceError(`飞书${label}文件过大,无法同步`, 413); } return { bytes, contentType: response.headers.get("content-type")?.split(";")[0] || "application/octet-stream", }; } export function clearFeishuAccessTokenCacheForTests() { cachedAccessToken = null; }