mirror of
https://github.com/ReCloudStudio/WebHooker.git
synced 2026-09-22 16:11:29 +00:00
- 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
194 lines
5.8 KiB
TypeScript
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");
|
|
}
|