// supabase/functions/feishu-doc-read/index.ts
import { serve } from "https://deno.land/std@0.190.0/http/server.ts";
import { createClient, type User } from "npm:@supabase/supabase-js@2";
const corsHeaders = {
"Access-Control-Allow-Origin": "*",
"Access-Control-Allow-Headers": "authorization, x-client-info, apikey, content-type, x-supabase-client-platform",
"Access-Control-Allow-Methods": "POST, OPTIONS",
};
type FeishuEnvelope<T> = {
code: number;
msg?: string;
data?: T;
};
type TenantAccessTokenEnvelope = FeishuEnvelope<never> & {
tenant_access_token?: string;
expire?: number;
};
type ParsedDocumentUrl = {
apiOrigin: "https://open.feishu.cn" | "https://open.larksuite.com";
sourceType: "docx" | "wiki";
token: string;
};
type CachedTenantToken = {
token: string;
expiresAt: number;
};
class HttpError extends Error {
constructor(
readonly status: number,
message: string,
readonly logCode: string,
) {
super(message);
this.name = "HttpError";
}
}
const tenantTokenCache = new Map<string, CachedTenantToken>();
const tenantTokenInflight = new Map<string, Promise<string>>();
function jsonResponse(body: unknown, status = 200) {
return new Response(JSON.stringify(body), {
status,
headers: { ...corsHeaders, "Content-Type": "application/json" },
});
}
function badRequest(message: string): never {
throw new HttpError(400, message, "BAD_REQUEST");
}
function parseDocumentUrl(input: unknown): ParsedDocumentUrl {
if (typeof input !== "string" || input.length > 2048) {
return badRequest("请提供有效的飞书文档链接");
}
let url: URL;
try {
url = new URL(input);
} catch {
return badRequest("请提供有效的飞书文档链接");
}
if (url.protocol !== "https:") return badRequest("飞书文档链接必须使用 HTTPS");
const host = url.hostname.toLowerCase();
const isFeishu = host === "feishu.cn" || host.endsWith(".feishu.cn");
const isLark = host === "larksuite.com" || host.endsWith(".larksuite.com");
if (!isFeishu && !isLark) return badRequest("仅支持飞书或 Lark 文档链接");
const pathSegments = url.pathname.split("/").filter(Boolean);
const [sourceType, token] = pathSegments;
if ((sourceType !== "docx" && sourceType !== "wiki") || !/^[A-Za-z0-9_-]+$/.test(token || "")) {
return badRequest("仅支持 Docx 或 Wiki 文档链接");
}
if (pathSegments.length !== 2) return badRequest("飞书文档链接路径不受支持");
return {
apiOrigin: isLark ? "https://open.larksuite.com" : "https://open.feishu.cn",
sourceType,
token,
};
}
async function requireAuthenticatedUser(req: Request): Promise<User> {
const authorization = req.headers.get("Authorization")?.trim() || "";
const match = authorization.match(/^Bearer\s+(.+)$/i);
if (!match) throw new HttpError(401, "请先登录后再读取飞书文档", "AUTH_HEADER_MISSING");
const supabaseUrl = Deno.env.get("SUPABASE_URL");
const supabaseAnonKey = Deno.env.get("SUPABASE_ANON_KEY");
if (!supabaseUrl || !supabaseAnonKey) {
throw new HttpError(503, "产品登录鉴权尚未配置", "SUPABASE_AUTH_CONFIG_MISSING");
}
const supabase = createClient(supabaseUrl, supabaseAnonKey, {
auth: { persistSession: false, autoRefreshToken: false },
});
const { data, error } = await supabase.auth.getUser(match[1]);
if (error || !data.user) {
throw new HttpError(401, "登录状态无效或已过期", "USER_JWT_INVALID");
}
return data.user;
}
function parseAllowedDocuments(): Set<string> {
const raw = Deno.env.get("SUPERUN_FEISHU_ALLOWED_DOCUMENTS") || "";
const entries = raw.split(",").map((item) => item.trim()).filter(Boolean);
if (entries.length === 0) {
throw new HttpError(503, "飞书文档白名单尚未配置", "DOCUMENT_ALLOWLIST_MISSING");
}
for (const entry of entries) {
if (!/^(docx|wiki):[A-Za-z0-9_-]+$/.test(entry)) {
throw new HttpError(503, "飞书文档白名单配置无效", "DOCUMENT_ALLOWLIST_INVALID");
}
}
return new Set(entries);
}
function assertDocumentAllowed(parsed: ParsedDocumentUrl) {
if (!parseAllowedDocuments().has(`${parsed.sourceType}:${parsed.token}`)) {
throw new HttpError(403, "当前产品未授权读取该飞书文档", "DOCUMENT_NOT_ALLOWED");
}
}
function mapFeishuHttpError(status: number): HttpError {
if (status === 403) return new HttpError(403, "飞书应用没有目标文档的访问权限", "FEISHU_FORBIDDEN");
if (status === 404) return new HttpError(404, "未找到目标飞书文档", "FEISHU_NOT_FOUND");
if (status === 429) return new HttpError(429, "飞书请求过于频繁,请稍后重试", "FEISHU_RATE_LIMITED");
return new HttpError(502, "飞书接口暂时不可用", `FEISHU_HTTP_${status}`);
}
function mapFeishuApiError(code: number): HttpError {
if ([99991672, 99991676, 99991679, 230027].includes(code)) {
return new HttpError(403, "飞书应用没有所需权限", `FEISHU_CODE_${code}`);
}
if ([99991400, 99991403].includes(code)) {
return new HttpError(429, "飞书请求过于频繁,请稍后重试", `FEISHU_CODE_${code}`);
}
return new HttpError(502, "飞书接口调用失败", `FEISHU_CODE_${code}`);
}
async function fetchWithTimeout(url: string, init: RequestInit): Promise<Response> {
try {
return await fetch(url, { ...init, signal: AbortSignal.timeout(20_000) });
} catch (error) {
if (error instanceof Error && error.name === "TimeoutError") {
throw new HttpError(504, "飞书接口请求超时", "FEISHU_TIMEOUT");
}
throw new HttpError(502, "无法连接飞书接口", "FEISHU_NETWORK_ERROR");
}
}
async function readJson<T>(response: Response): Promise<T> {
const text = await response.text();
try {
return JSON.parse(text) as T;
} catch {
if (!response.ok) {
throw mapFeishuHttpError(response.status);
}
throw new HttpError(502, "飞书接口返回了无法解析的响应", "FEISHU_INVALID_JSON");
}
}
async function feishuRequest<T>(url: string, init: RequestInit): Promise<T> {
const response = await fetchWithTimeout(url, init);
const payload = await readJson<FeishuEnvelope<T>>(response);
if (!response.ok) throw mapFeishuHttpError(response.status);
if (payload.code !== 0 || !payload.data) {
throw mapFeishuApiError(payload.code);
}
return payload.data;
}
async function getTenantAccessToken(apiOrigin: string, appId: string, appSecret: string) {
const cacheKey = `${apiOrigin}:${appId}`;
const cached = tenantTokenCache.get(cacheKey);
if (cached && cached.expiresAt > Date.now()) return cached.token;
const inflight = tenantTokenInflight.get(cacheKey);
if (inflight) return await inflight;
const request = (async () => {
const response = await fetchWithTimeout(`${apiOrigin}/open-apis/auth/v3/tenant_access_token/internal`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ app_id: appId, app_secret: appSecret }),
});
const payload = await readJson<TenantAccessTokenEnvelope>(response);
if (!response.ok) throw mapFeishuHttpError(response.status);
if (payload.code !== 0 || !payload.tenant_access_token) {
throw new HttpError(502, "获取飞书访问凭证失败", `FEISHU_TOKEN_CODE_${payload.code}`);
}
const expiresInSeconds = Number.isFinite(payload.expire) ? Number(payload.expire) : 7200;
const cacheTtlMs = Math.max(expiresInSeconds * 1000 - 5 * 60 * 1000, 30_000);
tenantTokenCache.set(cacheKey, {
token: payload.tenant_access_token,
expiresAt: Date.now() + cacheTtlMs,
});
return payload.tenant_access_token;
})();
tenantTokenInflight.set(cacheKey, request);
try {
return await request;
} finally {
if (tenantTokenInflight.get(cacheKey) === request) tenantTokenInflight.delete(cacheKey);
}
}
async function resolveDocumentToken(parsed: ParsedDocumentUrl, accessToken: string) {
if (parsed.sourceType === "docx") return { documentId: parsed.token, wikiTitle: undefined };
const query = new URLSearchParams({ token: parsed.token });
const data = await feishuRequest<{
node: { obj_token?: string; obj_type?: string; title?: string };
}>(`${parsed.apiOrigin}/open-apis/wiki/v2/spaces/get_node?${query}`, {
headers: { Authorization: `Bearer ${accessToken}` },
});
if (data.node.obj_type !== "docx" || !data.node.obj_token) {
throw new HttpError(
422,
"当前 Wiki 节点不是可读取的 Docx 文档",
"WIKI_OBJECT_TYPE_UNSUPPORTED",
);
}
return { documentId: data.node.obj_token, wikiTitle: data.node.title };
}
serve(async (req) => {
if (req.method === "OPTIONS") return new Response("ok", { headers: corsHeaders });
if (req.method !== "POST") return jsonResponse({ error: "Method not allowed" }, 405);
try {
await requireAuthenticatedUser(req);
const appId = Deno.env.get("SUPERUN_FEISHU_APP_ID");
const appSecret = Deno.env.get("SUPERUN_FEISHU_APP_SECRET");
if (!appId || !appSecret) {
throw new HttpError(503, "飞书插件尚未配置", "FEISHU_SECRET_MISSING");
}
let body: unknown;
try {
body = await req.json();
} catch {
return badRequest("请求体必须是有效的 JSON");
}
const documentUrl = body && typeof body === "object" && "documentUrl" in body
? (body as { documentUrl?: unknown }).documentUrl
: undefined;
const parsed = parseDocumentUrl(documentUrl);
assertDocumentAllowed(parsed);
const accessToken = await getTenantAccessToken(parsed.apiOrigin, appId, appSecret);
const { documentId, wikiTitle } = await resolveDocumentToken(parsed, accessToken);
const headers = { Authorization: `Bearer ${accessToken}` };
const [metadata, rawContent] = await Promise.all([
feishuRequest<{ document: { title?: string } }>(
`${parsed.apiOrigin}/open-apis/docx/v1/documents/${encodeURIComponent(documentId)}`,
{ headers },
),
feishuRequest<{ content?: string }>(
`${parsed.apiOrigin}/open-apis/docx/v1/documents/${encodeURIComponent(documentId)}/raw_content`,
{ headers },
),
]);
return jsonResponse({
title: metadata.document.title || wikiTitle || "",
documentId,
sourceType: parsed.sourceType,
content: rawContent.content || "",
});
} catch (error) {
const resolved = error instanceof HttpError
? error
: new HttpError(500, "读取飞书文档失败", "UNEXPECTED_ERROR");
console.error("[feishu-doc-read] failed", {
code: resolved.logCode,
status: resolved.status,
});
return jsonResponse({ error: resolved.message }, resolved.status);
}
});