WebHooker/server/lib/drivers/feishu/rest.ts
RhenCloud 4b99f6d33d
feat(feishu): add Feishu inbound webhook, /gh commands and card actions
- add feishu_links D1 table and link store helpers
- implement X-Lark-Signature verification, url_verification, /gh login|logout|comment|merge|close and card.action.trigger Merge/Close
- render interactive cards with clickable title link, inline links and callback buttons (no whole-card card_link)
- bind Feishu account in OAuth callback
- document event subscription and required scopes
2026-08-27 00:07:05 +08:00

194 lines
5.8 KiB
TypeScript

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<string, unknown>;
error?: { message?: string };
}
async function feishuRequest(
url: string,
method: string,
token: string,
body: Record<string, unknown>,
label: string,
): Promise<SendResult> {
const headers: Record<string, string> = {
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<TokenResult> {
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<string, unknown>,
): Promise<SendResult> {
const url = `${FEISHU_API}/open-apis/im/v1/messages?receive_id_type=chat_id`;
const body: Record<string, unknown> = {
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<string, unknown>,
): Promise<SendResult> {
const url = `${FEISHU_API}/open-apis/im/v1/messages/${messageId}`;
const body: Record<string, unknown> = { content: JSON.stringify(content) };
return feishuRequest(url, "PATCH", token, body, messageId);
}
export async function sendText(
token: string,
chatId: string,
text: string,
): Promise<SendResult> {
const url = `${FEISHU_API}/open-apis/im/v1/messages?receive_id_type=chat_id`;
const body: Record<string, unknown> = {
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<string, unknown>,
): Promise<SendResult> {
const url = `${FEISHU_API}/open-apis/interactive/v1/config/update`;
const body: Record<string, unknown> = { token: cardToken, card };
return feishuRequest(url, "POST", token, body, "card");
}