Initial commit: KOC LOOP platform
This commit is contained in:
557
lib/feishu-client.ts
Normal file
557
lib/feishu-client.ts
Normal file
@@ -0,0 +1,557 @@
|
||||
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;
|
||||
}
|
||||
Reference in New Issue
Block a user