Files
koc-loop/lib/feishu-client.ts
2026-07-30 12:06:41 +08:00

558 lines
16 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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;
}