Files
koc-loop/lib/feishu-client.ts

558 lines
16 KiB
TypeScript
Raw Permalink Normal View History

2026-07-30 12:06:41 +08:00
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 MAX_MEDIA_BYTES = 20_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 FeishuSourceRow = {
sourceRow: number;
title: string;
body: string;
images: FeishuSourceImage[];
};
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<T> = {
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<string, unknown> {
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") return "";
if (typeof value.text === "string") return value.text.trim();
if (typeof value.value === "string") return value.value.trim();
return "";
}
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;
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 score =
(titleIndex >= 0 ? 5 : 0) +
(bodyIndex >= 0 ? 5 : 0) +
(idIndex >= 0 ? 1 : 0) +
(tagsIndex >= 0 ? 1 : 0);
if (!best || score > best.score) {
best = {
rowIndex,
idIndex,
titleIndex,
bodyIndex,
tagsIndex,
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<number>();
const rows: FeishuSourceRow[] = [];
let maxImageCount = 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<string>();
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);
rows.push({ sourceRow, title, body, images });
}
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}`),
].filter(Boolean);
return {
columns: [...new Set(matchedColumns)],
rows,
};
}
async function jsonEnvelope<T>(
response: Response,
fallbackMessage: string,
): Promise<FeishuEnvelope<T>> {
const text = await response.text();
let payload: FeishuEnvelope<T>;
try {
payload = JSON.parse(text) as FeishuEnvelope<T>;
} 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<never>(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<T>(
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<T>(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<FeishuSource> {
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,
) {
const normalizedToken = bindingValue(fileToken);
if (!/^[A-Za-z0-9_-]{8,240}$/.test(normalizedToken)) {
throw new FeishuSourceError("飞书图片标识无效", 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(20_000),
},
);
const declaredSize = Number(response.headers.get("content-length"));
if (Number.isFinite(declaredSize) && declaredSize > MAX_MEDIA_BYTES) {
throw new FeishuSourceError("飞书图片超过20MB无法同步", 413);
}
if (!response.ok) {
throw new FeishuSourceError(
response.status === 403
? "飞书应用没有这张图片的下载权限"
: `下载飞书图片失败HTTP ${response.status}`,
response.status === 403 ? 403 : 502,
);
}
const bytes = await response.arrayBuffer();
if (bytes.byteLength > MAX_MEDIA_BYTES) {
throw new FeishuSourceError("飞书图片超过20MB无法同步", 413);
}
return {
bytes,
contentType:
response.headers.get("content-type")?.split(";")[0] ||
"application/octet-stream",
};
}
export function clearFeishuAccessTokenCacheForTests() {
cachedAccessToken = null;
}