> ## Documentation Index
> Fetch the complete documentation index at: https://docs.superun.ai/llms.txt
> Use this file to discover all available pages before exploring further.

# 飞书文档读取

> 在用户应用的 Supabase Edge Function 中鉴权并读取白名单内的飞书 Docx 与 Wiki 文档纯文本。

## 背景与使用场景

### 这个能力做什么

飞书文档读取能力让用户生成的应用读取有权访问的新版飞书文档（Docx）纯文本。它支持两种输入：

* `https://{tenant}.feishu.cn/docx/{document_token}` 形式的 Docx 链接。
* `https://{tenant}.feishu.cn/wiki/{node_token}` 形式的知识库链接；先解析节点，再读取底层 Docx。

首版只读取标题和纯文本，不处理图片、附件、表格、画板、电子表格、多维表格或旧版 Doc。

### 与其他飞书 Skill 一致的架构

本能力是用户应用运行时能力，不是 Agent 工具。调用链与飞书消息、通讯录等现有 Skill 一致：

```text theme={null}
用户应用前端
    │ supabase.functions.invoke("feishu-doc-read")
    ▼
用户 Supabase 项目的 Edge Function
    │ tenant_access_token + Feishu OpenAPI
    ▼
飞书开放平台
```

不要把 App Secret 或 access token 返回给浏览器，也不要在 Gateway、LLM Gateway 或 Agent 中新增飞书文档代理。

## 一、前置配置

### 1.1 环境变量

复用基础 `FEISHU` 插件已经写入用户 Supabase 项目的前两个 secrets，并为文档读取能力额外配置服务端文档白名单：

| 变量                                 | 位置                   | 用途                                            |
| ---------------------------------- | -------------------- | --------------------------------------------- |
| `SUPERUN_FEISHU_APP_ID`            | Edge Function Secret | 飞书企业自建应用 App ID                               |
| `SUPERUN_FEISHU_APP_SECRET`        | Edge Function Secret | 飞书企业自建应用 App Secret                           |
| `SUPERUN_FEISHU_ALLOWED_DOCUMENTS` | Edge Function Secret | 允许读取的文档，逗号分隔，例如 `docx:doxcnxxx,wiki:wikcnxxx` |

不需要额外的平台 API key。App Secret 和文档白名单都通过安全配置界面写入 Edge Function Secrets，不要让用户在对话中粘贴。

### 1.2 产品访问策略

完整模板默认采用两层授权：

1. 调用者必须是 Supabase Auth 的真实登录用户；公开的 anon key 不能代替用户身份。
2. 文档必须命中 `SUPERUN_FEISHU_ALLOWED_DOCUMENTS`。白名单按原始链接填写：Docx 使用 `docx:{document_token}`，Wiki 使用 `wiki:{node_token}`。

该默认策略表示“所有已登录产品用户都能读取白名单文档”。如果产品需要按用户、组织或角色进一步隔离，必须把模板中的白名单检查替换为数据库授权查询，并使用当前登录用户的 `user.id` 做条件；不能只依赖飞书应用本身的资源权限。

### 1.3 飞书权限

在飞书开放平台为企业自建应用开通并发布：

| 权限 key                   | 类型     | 用途                     |
| ------------------------ | ------ | ---------------------- |
| `docx:document:readonly` | tenant | 获取新版文档基本信息与纯文本         |
| `wiki:node:retrieve`     | tenant | 将 Wiki 节点解析为底层文档 token |

```json theme={null}
{
  "scopes": {
    "tenant": ["docx:document:readonly", "wiki:node:retrieve"],
    "user": []
  }
}
```

权限审批后必须发布应用新版本。应用还必须具有目标文档或知识库节点的实际访问权限；只有 API scope 并不等于可以读取任意私有文档。

## 二、飞书 API

### 2.1 获取 tenant\_access\_token

```text theme={null}
POST /open-apis/auth/v3/tenant_access_token/internal
Body: { "app_id": "...", "app_secret": "..." }
```

### 2.2 解析 Wiki 节点

```text theme={null}
GET /open-apis/wiki/v2/spaces/get_node?token={wiki_node_token}
Authorization: Bearer {tenant_access_token}
```

读取 `data.node.obj_token` 和 `data.node.obj_type`。首版仅接受 `obj_type=docx`。

### 2.3 获取文档基本信息与纯文本

```text theme={null}
GET /open-apis/docx/v1/documents/{document_id}
GET /open-apis/docx/v1/documents/{document_id}/raw_content
Authorization: Bearer {tenant_access_token}
```

标题来自基本信息接口，纯文本来自 `data.content`。

## 三、完整 Edge Function

```toml theme={null}
# supabase/config.toml
[functions.feishu-doc-read]
verify_jwt = true
```

```typescript theme={null}
// 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);
  }
});
```

前端只向 Edge Function 传文档链接：

```typescript theme={null}
const { data, error } = await supabase.functions.invoke("feishu-doc-read", {
  body: { documentUrl },
});
if (error) throw error;
```

`supabase.functions.invoke` 必须在用户登录后调用，以便自动发送用户 session JWT。未登录页面不要调用该函数。

## 四、本地测试

可以在用户项目中启动 Supabase 本地栈并单独运行函数：

```bash theme={null}
supabase start
supabase functions serve feishu-doc-read --env-file supabase/.env.local
```

`supabase/.env.local` 只保存在本地，不提交到 Git：

```dotenv theme={null}
SUPERUN_FEISHU_APP_ID=cli_xxx
SUPERUN_FEISHU_APP_SECRET=xxx
SUPERUN_FEISHU_ALLOWED_DOCUMENTS=docx:doxcnxxx,wiki:wikcnxxx
```

先从本地登录会话中取得用户 access token，再用非法域名验证本地校验；不要使用公开的 anon key：

```bash theme={null}
curl -i http://127.0.0.1:54321/functions/v1/feishu-doc-read \
  -H "Authorization: Bearer ${LOCAL_SUPABASE_USER_JWT}" \
  -H "Content-Type: application/json" \
  -d '{"documentUrl":"https://example.com/docx/test"}'
```

真实文档联调需要有效的飞书应用凭证、已发布权限，以及应用对目标文档的实际访问权。不要为了验证模板在共享环境中提交真实凭证。

## 五、验收清单

1. Docx 链接返回标题和纯文本。
2. Wiki 链接先解析节点，底层为 Docx 时返回内容，其他类型给出明确提示。
3. 非飞书域名、非 HTTPS、非法 token 和不支持的路径在 Edge Function 内被拒绝。
4. 未登录、仅携带 anon key、未命中文档白名单的请求分别返回 401 / 403，且不会请求飞书。
5. 浏览器包、日志、响应和数据库中没有 App Secret 或 access token。
6. 凭证仅来自 `SUPERUN_FEISHU_APP_ID` / `SUPERUN_FEISHU_APP_SECRET`，文档范围仅来自服务端白名单。
7. 连续读取复用未过期的 `tenant_access_token`；并发冷启动请求只发起一次 token 获取。
8. 飞书权限、限频、上游故障和超时不会被统一伪装成 400。
9. 实现没有新增 Gateway、LLM Gateway 或 Agent tool 调用。
