import { log } from "../../lib/log"; import type { Env } from "../../types"; import type { SendResult } from "../types"; const FEISHU_API = "https://open.feishu.cn"; const TOKEN_KEY = "feishu:token"; interface FeishuResponse { code?: number; msg?: string; data?: { message_id?: string } & Record; error?: { message?: string }; } async function feishuRequest( url: string, method: string, token: string, body: Record, label: string, ): Promise { const headers: Record = { Authorization: `Bearer ${token}`, "Content-Type": "application/json", }; let lastStatus = 0; let lastError = ""; for (let attempt = 0; attempt < 3; attempt++) { try { const res = await fetch(url, { method, headers, body: JSON.stringify(body), }); lastStatus = res.status; if (res.status === 429) { const retryAfter = Number(res.headers.get("retry-after") || "1"); lastError = `Rate limited (retry_after=${retryAfter})`; log.warn({ retryAfter, attempt, label }, "Feishu rate limited"); await new Promise((r) => setTimeout(r, retryAfter * 1000)); continue; } const data = (await res.json().catch(() => null)) as FeishuResponse | null; if (!res.ok) { lastError = data?.msg ?? data?.error?.message ?? `HTTP ${res.status}`; log.error({ status: res.status, err: lastError, label, attempts: attempt + 1 }, "Feishu API error"); return { ok: false, error: lastError, errorCode: res.status >= 500 ? "FEISHU_5XX" : "FEISHU_ERROR", status: res.status, attempts: attempt + 1, }; } if (data && typeof data.code === "number" && data.code !== 0) { lastError = data.msg ?? `Feishu code ${data.code}`; log.error({ code: data.code, err: lastError, label, attempts: attempt + 1 }, "Feishu business error"); return { ok: false, error: lastError, errorCode: `FEISHU_${data.code}`, status: res.status, attempts: attempt + 1, }; } return { ok: true, status: res.status, messageId: data?.data?.message_id ?? undefined, attempts: attempt + 1, }; } catch (err) { lastError = err instanceof Error ? err.message : String(err); log.error({ err, label, attempts: attempt + 1 }, "Failed to call Feishu API"); if (attempt === 2) { return { ok: false, error: lastError, errorCode: "NETWORK", status: lastStatus, attempts: attempt + 1, }; } await new Promise((r) => setTimeout(r, 500 * (attempt + 1))); } } return { ok: false, error: lastError || "Max retries exceeded", errorCode: "RETRIES", status: lastStatus, attempts: 3, }; } interface TokenResult { ok: boolean; token?: string; error?: string; errorCode?: string; status?: number; } export async function getTenantAccessToken(env: Env): Promise { const appId = env.FEISHU_APP_ID?.trim(); const appSecret = env.FEISHU_APP_SECRET?.trim(); if (!appId || !appSecret) { return { ok: false, error: "FEISHU_APP_ID/FEISHU_APP_SECRET not configured", errorCode: "NO_TOKEN" }; } const cached = await env.KV.get(TOKEN_KEY); if (cached) return { ok: true, token: cached }; const url = `${FEISHU_API}/open-apis/auth/v3/tenant_access_token/internal`; try { const res = await fetch(url, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ app_id: appId, app_secret: appSecret }), }); const data = (await res.json().catch(() => null)) as | { code?: number; msg?: string; tenant_access_token?: string; expire?: number } | null; if (!res.ok || !data || data.code !== 0 || !data.tenant_access_token) { const err = data?.msg ?? `HTTP ${res.status}`; return { ok: false, error: err, errorCode: "FEISHU_TOKEN", status: res.status }; } const ttl = Math.max(60, (data.expire ?? 7200) - 60); await env.KV.put(TOKEN_KEY, data.tenant_access_token, { expirationTtl: ttl }); return { ok: true, token: data.tenant_access_token }; } catch (err) { return { ok: false, error: err instanceof Error ? err.message : String(err), errorCode: "NETWORK", }; } } export async function sendMessage( token: string, chatId: string, content: Record, ): Promise { const url = `${FEISHU_API}/open-apis/im/v1/messages?receive_id_type=chat_id`; const body: Record = { receive_id: chatId, msg_type: "interactive", content: JSON.stringify(content), }; return feishuRequest(url, "POST", token, body, chatId); } export async function updateMessage( token: string, messageId: string, content: Record, ): Promise { const url = `${FEISHU_API}/open-apis/im/v1/messages/${messageId}`; const body: Record = { content: JSON.stringify(content) }; return feishuRequest(url, "PATCH", token, body, messageId); } export async function sendText( token: string, chatId: string, text: string, ): Promise { const url = `${FEISHU_API}/open-apis/im/v1/messages?receive_id_type=chat_id`; const body: Record = { receive_id: chatId, msg_type: "text", content: JSON.stringify({ text }), }; return feishuRequest(url, "POST", token, body, chatId); } export async function updateCard( token: string, cardToken: string, card: Record, ): Promise { const url = `${FEISHU_API}/open-apis/interactive/v1/config/update`; const body: Record = { token: cardToken, card }; return feishuRequest(url, "POST", token, body, "card"); }