mirror of
https://github.com/ReCloudStudio/WebHooker.git
synced 2026-09-22 16:11:29 +00:00
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
This commit is contained in:
parent
3437ac5513
commit
4b99f6d33d
38 changed files with 2449 additions and 1412 deletions
|
|
@ -29,7 +29,7 @@ export const filterSchema = v.object({
|
|||
});
|
||||
|
||||
export const routeTargetSchema = v.object({
|
||||
platform: v.optional(v.picklist(["discord", "telegram"])),
|
||||
platform: v.optional(v.picklist(["discord", "telegram", "feishu"])),
|
||||
channelId: v.optional(v.string()),
|
||||
threadId: v.optional(v.string()),
|
||||
chatId: v.optional(v.string()),
|
||||
|
|
|
|||
|
|
@ -192,9 +192,11 @@ export async function dispatchEvent(
|
|||
? target.topicId
|
||||
? `${target.chatId}/${target.topicId}`
|
||||
: (target.chatId ?? "")
|
||||
: target.threadId
|
||||
? `${target.channelId}/${target.threadId}`
|
||||
: (target.channelId ?? "");
|
||||
: target.platform === "feishu"
|
||||
? (target.chatId ?? "")
|
||||
: target.threadId
|
||||
? `${target.channelId}/${target.threadId}`
|
||||
: (target.channelId ?? "");
|
||||
|
||||
const base: {
|
||||
ts: number;
|
||||
|
|
|
|||
37
server/lib/drivers/feishu/index.ts
Normal file
37
server/lib/drivers/feishu/index.ts
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
import type { RouteTarget, Env, NeutralMessage } from "../../types";
|
||||
import type { PlatformDriver, SendResult } from "../types";
|
||||
import { getTenantAccessToken, sendMessage, updateMessage } from "./rest";
|
||||
import { renderNeutralMessage } from "./render";
|
||||
|
||||
export class FeishuDriver implements PlatformDriver {
|
||||
readonly id = "feishu";
|
||||
|
||||
async send(message: NeutralMessage, target: RouteTarget, env: Env): Promise<SendResult> {
|
||||
const chatId = target.chatId ?? "";
|
||||
if (!chatId) {
|
||||
return { ok: false, error: "target.chatId is required", errorCode: "NO_TARGET" };
|
||||
}
|
||||
const tokenRes = await getTenantAccessToken(env);
|
||||
if (!tokenRes.ok || !tokenRes.token) {
|
||||
return { ok: false, error: tokenRes.error ?? "No token", errorCode: tokenRes.errorCode ?? "NO_TOKEN" };
|
||||
}
|
||||
return sendMessage(tokenRes.token, chatId, renderNeutralMessage(message));
|
||||
}
|
||||
|
||||
async edit(
|
||||
message: NeutralMessage,
|
||||
target: RouteTarget,
|
||||
env: Env,
|
||||
messageId: string,
|
||||
): Promise<SendResult> {
|
||||
const chatId = target.chatId ?? "";
|
||||
if (!chatId) {
|
||||
return { ok: false, error: "target.chatId is required", errorCode: "NO_TARGET" };
|
||||
}
|
||||
const tokenRes = await getTenantAccessToken(env);
|
||||
if (!tokenRes.ok || !tokenRes.token) {
|
||||
return { ok: false, error: tokenRes.error ?? "No token", errorCode: tokenRes.errorCode ?? "NO_TOKEN" };
|
||||
}
|
||||
return updateMessage(tokenRes.token, messageId, renderNeutralMessage(message));
|
||||
}
|
||||
}
|
||||
153
server/lib/drivers/feishu/render.ts
Normal file
153
server/lib/drivers/feishu/render.ts
Normal file
|
|
@ -0,0 +1,153 @@
|
|||
import type { NeutralMessage } from "../../types";
|
||||
import { cap, splitMessageTitle } from "../../formatters/helpers";
|
||||
|
||||
const MAX_HEADER_TITLE = 100;
|
||||
const MAX_MARKDOWN = 4000;
|
||||
const MAX_NOTE = 500;
|
||||
|
||||
type FeishuTemplate =
|
||||
| "blue"
|
||||
| "green"
|
||||
| "red"
|
||||
| "yellow"
|
||||
| "orange"
|
||||
| "purple"
|
||||
| "indigo"
|
||||
| "wathet"
|
||||
| "lime"
|
||||
| "grey";
|
||||
|
||||
function colorToTemplate(color?: number): FeishuTemplate {
|
||||
if (color == null) return "blue";
|
||||
const r = (color >> 16) & 0xff;
|
||||
const g = (color >> 8) & 0xff;
|
||||
const b = color & 0xff;
|
||||
const max = Math.max(r, g, b);
|
||||
if (max < 80) return "grey";
|
||||
if (r > g + b && g < 100) return "red";
|
||||
if (g > r + b && g > 150) return "green";
|
||||
if (b > r + g) return "indigo";
|
||||
if (r > 180 && g > 120 && b < 80) return "orange";
|
||||
if (r > 200 && g > 180 && b < 120) return "yellow";
|
||||
if (r > 120 && g < 80 && b > 120) return "purple";
|
||||
if (r > 160 && g > 180 && b > 200) return "wathet";
|
||||
if (g > 150 && r > 150 && b < 80) return "lime";
|
||||
return "blue";
|
||||
}
|
||||
|
||||
function mdText(content: string): { tag: "lark_md"; content: string } {
|
||||
return { tag: "lark_md", content: cap(content, MAX_MARKDOWN) };
|
||||
}
|
||||
|
||||
function divMarkdown(content: string): { tag: "div"; text: { tag: "lark_md"; content: string } } {
|
||||
return { tag: "div", text: mdText(content) };
|
||||
}
|
||||
|
||||
function formatTimestamp(ts?: string): string {
|
||||
if (!ts) return "";
|
||||
const d = new Date(ts);
|
||||
if (Number.isNaN(d.getTime())) return ts;
|
||||
const pad = (n: number): string => String(n).padStart(2, "0");
|
||||
return `${d.getUTCFullYear()}-${pad(d.getUTCMonth() + 1)}-${pad(d.getUTCDate())} ${pad(d.getUTCHours())}:${pad(d.getUTCMinutes())} UTC`;
|
||||
}
|
||||
|
||||
export function renderNeutralMessage(message: NeutralMessage): Record<string, unknown> {
|
||||
const { head, subject } = splitMessageTitle(message.title);
|
||||
|
||||
const elements: Record<string, unknown>[] = [];
|
||||
|
||||
if (message.url) {
|
||||
elements.push(divMarkdown(`**[${head}](${message.url})**`));
|
||||
}
|
||||
|
||||
if (message.author?.name) {
|
||||
const author = message.author.url
|
||||
? `[${message.author.name}](${message.author.url})`
|
||||
: message.author.name;
|
||||
elements.push(divMarkdown(`**${author}**`));
|
||||
}
|
||||
|
||||
if (subject || message.url || message.description) {
|
||||
const parts: string[] = [];
|
||||
if (subject) {
|
||||
parts.push(`**${subject}**`);
|
||||
} else if (message.url) {
|
||||
parts.push(`**${message.title}**`);
|
||||
}
|
||||
if (message.description) {
|
||||
parts.push(message.description);
|
||||
}
|
||||
if (parts.length) {
|
||||
elements.push(divMarkdown(parts.join("\n\n")));
|
||||
}
|
||||
}
|
||||
|
||||
if (message.fields?.length) {
|
||||
const lines = message.fields.map((f) => `**${f.name}**: ${f.value}`);
|
||||
elements.push(divMarkdown(lines.join("\n\n")));
|
||||
}
|
||||
|
||||
const meta: string[] = [];
|
||||
if (message.forge?.name) {
|
||||
meta.push(message.forge.url ? `[${message.forge.name}](${message.forge.url})` : message.forge.name);
|
||||
}
|
||||
if (message.footer) meta.push(message.footer);
|
||||
const ts = formatTimestamp(message.timestamp);
|
||||
if (ts) meta.push(ts);
|
||||
|
||||
if (meta.length) {
|
||||
if (elements.length) elements.push({ tag: "hr" });
|
||||
elements.push({
|
||||
tag: "note",
|
||||
elements: [{ tag: "lark_md", content: cap(meta.join(" · "), MAX_NOTE) }],
|
||||
});
|
||||
}
|
||||
|
||||
if (message.url && !message.actions?.length) {
|
||||
elements.push({
|
||||
tag: "action",
|
||||
actions: [
|
||||
{
|
||||
tag: "button",
|
||||
text: { tag: "plain_text", content: "Open" },
|
||||
type: "primary",
|
||||
multi_url: {
|
||||
url: message.url,
|
||||
pc_url: message.url,
|
||||
android_url: message.url,
|
||||
ios_url: message.url,
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
if (message.actions?.length) {
|
||||
elements.push({
|
||||
tag: "action",
|
||||
actions: message.actions.map((action) => ({
|
||||
tag: "button",
|
||||
text: { tag: "plain_text", content: action.label },
|
||||
type: action.style === "danger" ? "danger" : action.style === "primary" ? "primary" : "default",
|
||||
action_id: action.id,
|
||||
value: { v: action.id },
|
||||
})),
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
config: {
|
||||
wide_screen_mode: true,
|
||||
enable_forward: true,
|
||||
update_multi: true,
|
||||
},
|
||||
header: {
|
||||
title: {
|
||||
tag: "plain_text",
|
||||
content: cap(head, MAX_HEADER_TITLE),
|
||||
},
|
||||
template: colorToTemplate(message.color),
|
||||
},
|
||||
elements,
|
||||
};
|
||||
}
|
||||
194
server/lib/drivers/feishu/rest.ts
Normal file
194
server/lib/drivers/feishu/rest.ts
Normal file
|
|
@ -0,0 +1,194 @@
|
|||
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");
|
||||
}
|
||||
335
server/lib/drivers/feishu/updates.ts
Normal file
335
server/lib/drivers/feishu/updates.ts
Normal file
|
|
@ -0,0 +1,335 @@
|
|||
import type { Env } from "../../types";
|
||||
import { log } from "../../lib/log";
|
||||
import { getOAuthURL, commentAsUser, mergePullRequestAsUser, closePullRequestAsUser } from "../../github/oauth";
|
||||
import { getFeishuLink, removeFeishuLink } from "../../github/store";
|
||||
import { getTenantAccessToken, sendText, updateCard } from "./rest";
|
||||
|
||||
const FEISHU_API = "https://open.feishu.cn";
|
||||
|
||||
const GITHUB_TARGET_RE = /github\.com\/([^/\s]+)\/([^/\s]+)\/(?:issues|pull)\/(\d+)/;
|
||||
const BTN_PREFIX = "ghpr|";
|
||||
|
||||
interface FeishuSender {
|
||||
sender_id?: { open_id?: string; union_id?: string };
|
||||
}
|
||||
interface FeishuMessage {
|
||||
chat_id?: string;
|
||||
content?: string;
|
||||
}
|
||||
interface FeishuAction {
|
||||
action_id?: string;
|
||||
value?: { v?: string };
|
||||
}
|
||||
interface FeishuEvent {
|
||||
sender?: FeishuSender;
|
||||
message?: FeishuMessage;
|
||||
action?: FeishuAction;
|
||||
operator?: { operator_id?: { open_id?: string } };
|
||||
token?: string;
|
||||
open_message_id?: string;
|
||||
}
|
||||
|
||||
function splitThree(rest: string): [string, string, string] {
|
||||
const parts = rest.split("|");
|
||||
const number = parts.pop() ?? "";
|
||||
const repo = parts.pop() ?? "";
|
||||
const owner = parts.pop() ?? "";
|
||||
return [owner, repo, number];
|
||||
}
|
||||
|
||||
function timingSafeEqual(a: string, b: string): boolean {
|
||||
if (a.length !== b.length) return false;
|
||||
let diff = 0;
|
||||
for (let i = 0; i < a.length; i++) {
|
||||
diff |= a.charCodeAt(i) ^ b.charCodeAt(i);
|
||||
}
|
||||
return diff === 0;
|
||||
}
|
||||
|
||||
async function hmacSha256Base64(secret: string, payload: string): Promise<string> {
|
||||
const key = await crypto.subtle.importKey(
|
||||
"raw",
|
||||
new TextEncoder().encode(secret),
|
||||
{ name: "HMAC", hash: "SHA-256" },
|
||||
false,
|
||||
["sign"],
|
||||
);
|
||||
const sig = await crypto.subtle.sign("HMAC", key, new TextEncoder().encode(payload));
|
||||
let binary = "";
|
||||
const bytes = new Uint8Array(sig);
|
||||
for (let i = 0; i < bytes.length; i++) binary += String.fromCharCode(bytes[i] ?? 0);
|
||||
return btoa(binary);
|
||||
}
|
||||
|
||||
export async function verifyFeishuSignature(
|
||||
appSecret: string,
|
||||
timestamp: string,
|
||||
nonce: string,
|
||||
body: string,
|
||||
signature: string,
|
||||
): Promise<boolean> {
|
||||
const raw = `${timestamp}\n${nonce}\n${body}`;
|
||||
const computed = await hmacSha256Base64(appSecret, raw);
|
||||
return timingSafeEqual(computed, signature);
|
||||
}
|
||||
|
||||
function describeError(err: unknown): string {
|
||||
if (err instanceof Error) {
|
||||
const msg = err.message;
|
||||
if (msg.includes("GITHUB_TOKEN_EXPIRED")) return "GitHub 绑定已过期,请重新用 /gh login 绑定。";
|
||||
if (msg.includes("GITHUB_FORBIDDEN")) return "没有权限操作该仓库(GitHub 返回 403),请确认你的账号权限。";
|
||||
if (msg.includes("GITHUB_NOT_FOUND")) return "未找到对应的 GitHub 资源(404)。";
|
||||
return msg || "操作失败";
|
||||
}
|
||||
return "操作失败";
|
||||
}
|
||||
|
||||
function extractTarget(text: string): { owner: string; repo: string; number: number } | null {
|
||||
const m = text.match(GITHUB_TARGET_RE);
|
||||
if (!m) return null;
|
||||
const number = Number(m[3] ?? "");
|
||||
if (!Number.isInteger(number)) return null;
|
||||
return { owner: m[1] ?? "", repo: m[2] ?? "", number };
|
||||
}
|
||||
|
||||
async function replyToken(env: Env, chatId: string, text: string): Promise<void> {
|
||||
const tokenRes = await getTenantAccessToken(env);
|
||||
if (!tokenRes.ok || !tokenRes.token) {
|
||||
log.warn({ err: tokenRes.error }, "Feishu token unavailable for reply");
|
||||
return;
|
||||
}
|
||||
await sendText(tokenRes.token, chatId, text);
|
||||
}
|
||||
|
||||
async function handleLogin(env: Env, openId: string, chatId: string): Promise<void> {
|
||||
const state = crypto.randomUUID();
|
||||
const pending = {
|
||||
redirectTo: "/admin",
|
||||
expiresAt: Date.now() + 10 * 60 * 1000,
|
||||
feishuUserId: openId,
|
||||
feishuChatId: chatId,
|
||||
};
|
||||
await env.KV.put(`state:${state}`, JSON.stringify(pending), { expirationTtl: 600 });
|
||||
const url = getOAuthURL(env.GITHUB_CLIENT_ID ?? "", state);
|
||||
await replyToken(env, chatId, [
|
||||
"点击下方链接绑定 GitHub 账号:",
|
||||
url,
|
||||
"绑定后可在飞书里用 /gh comment 评论、/gh merge 合并、/gh close 关闭 PR。",
|
||||
].join("\n"));
|
||||
}
|
||||
|
||||
async function handleLogout(env: Env, openId: string, chatId: string): Promise<void> {
|
||||
const linked = await getFeishuLink(env.DB, openId);
|
||||
if (!linked) {
|
||||
await replyToken(env, chatId, "你还没有绑定 GitHub 账号。");
|
||||
return;
|
||||
}
|
||||
await removeFeishuLink(env.DB, openId);
|
||||
await replyToken(env, chatId, "已解绑 GitHub 账号。");
|
||||
}
|
||||
|
||||
async function handleComment(env: Env, openId: string, chatId: string, text: string): Promise<void> {
|
||||
const githubUserId = await getFeishuLink(env.DB, openId);
|
||||
if (!githubUserId) {
|
||||
await replyToken(env, chatId, "请先 /gh login 绑定 GitHub 账号。");
|
||||
return;
|
||||
}
|
||||
const target = extractTarget(text);
|
||||
if (!target) {
|
||||
await replyToken(env, chatId, "请在消息里带上 PR/Issue 链接,例如:/gh comment https://github.com/o/r/pull/7 看起来不错");
|
||||
return;
|
||||
}
|
||||
const ghIdx = text.indexOf("/gh");
|
||||
const rest = text.slice(ghIdx + 3).trim();
|
||||
const parts = rest.split(/\s+/);
|
||||
const linkIdx = parts.findIndex((p) => p.includes("github.com"));
|
||||
const body = parts.slice(linkIdx + 1).join(" ").trim() || "(来自飞书)";
|
||||
try {
|
||||
const res = await commentAsUser(env.KV, githubUserId, target.owner, target.repo, target.number, body);
|
||||
await replyToken(env, chatId, `✅ 已评论:[查看](${res.htmlUrl})(@${res.login})`);
|
||||
} catch (err) {
|
||||
await replyToken(env, chatId, `❌ ${describeError(err)}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleMergeOrClose(
|
||||
env: Env,
|
||||
openId: string,
|
||||
chatId: string,
|
||||
action: "merge" | "close",
|
||||
body: string,
|
||||
): Promise<void> {
|
||||
const githubUserId = await getFeishuLink(env.DB, openId);
|
||||
if (!githubUserId) {
|
||||
await replyToken(env, chatId, "请先 /gh login 绑定 GitHub 账号。");
|
||||
return;
|
||||
}
|
||||
const target = extractTarget(body);
|
||||
if (!target) {
|
||||
await replyToken(env, chatId, action === "merge"
|
||||
? "请在消息里带上 PR 链接,例如:/gh merge https://github.com/o/r/pull/7"
|
||||
: "请在消息里带上 PR 链接,例如:/gh close https://github.com/o/r/pull/7");
|
||||
return;
|
||||
}
|
||||
try {
|
||||
if (action === "merge") {
|
||||
await mergePullRequestAsUser(env.KV, githubUserId, target.owner, target.repo, target.number);
|
||||
} else {
|
||||
await closePullRequestAsUser(env.KV, githubUserId, target.owner, target.repo, target.number);
|
||||
}
|
||||
await replyToken(env, chatId, `✅ 已${action === "merge" ? "合并" : "关闭"} ${target.owner}/${target.repo}#${target.number}`);
|
||||
} catch (err) {
|
||||
await replyToken(env, chatId, `❌ ${describeError(err)}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleMessage(env: Env, event: FeishuEvent): Promise<void> {
|
||||
const sender = event.sender ?? {};
|
||||
const openId = sender.sender_id?.open_id ?? sender.sender_id?.union_id ?? "";
|
||||
const message = event.message ?? {};
|
||||
const chatId = message.chat_id ?? "";
|
||||
let text = "";
|
||||
if (message.content) {
|
||||
try {
|
||||
const content = JSON.parse(message.content) as { text?: string };
|
||||
text = content.text ?? "";
|
||||
} catch {
|
||||
text = "";
|
||||
}
|
||||
}
|
||||
const idx = text.indexOf("/gh");
|
||||
if (idx < 0) return;
|
||||
const rest = text.slice(idx + 3).trim();
|
||||
const sub = rest.split(/\s+/)[0] ?? "";
|
||||
switch (sub) {
|
||||
case "login":
|
||||
await handleLogin(env, openId, chatId);
|
||||
break;
|
||||
case "logout":
|
||||
await handleLogout(env, openId, chatId);
|
||||
break;
|
||||
case "comment":
|
||||
await handleComment(env, openId, chatId, text);
|
||||
break;
|
||||
case "merge":
|
||||
await handleMergeOrClose(env, openId, chatId, "merge", text);
|
||||
break;
|
||||
case "close":
|
||||
await handleMergeOrClose(env, openId, chatId, "close", text);
|
||||
break;
|
||||
default:
|
||||
await replyToken(env, chatId, "未知指令。可用:/gh login | logout | comment <链接> <内容> | merge <链接> | close <链接>");
|
||||
}
|
||||
}
|
||||
|
||||
function buildResultCard(ok: boolean, message: string): Record<string, unknown> {
|
||||
return {
|
||||
config: { wide_screen_mode: true },
|
||||
elements: [
|
||||
{
|
||||
tag: "div",
|
||||
text: { tag: "lark_md", content: ok ? `✅ ${message}` : `❌ ${message}` },
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
async function handleCardAction(env: Env, payload: Record<string, unknown>): Promise<void> {
|
||||
const event = (payload.event ?? {}) as FeishuEvent;
|
||||
const action = event.action ?? {};
|
||||
const actionId = action.action_id ?? action.value?.v ?? "";
|
||||
const openId =
|
||||
event.operator?.operator_id?.open_id ?? event.sender?.sender_id?.open_id ?? "";
|
||||
const cardToken = event.token ?? "";
|
||||
const openMessageId = event.open_message_id ?? "";
|
||||
|
||||
if (!actionId.startsWith(BTN_PREFIX)) return;
|
||||
|
||||
const isMerge = actionId.startsWith(`${BTN_PREFIX}merge|`);
|
||||
const isClose = actionId.startsWith(`${BTN_PREFIX}close|`);
|
||||
|
||||
if (isMerge || isClose) {
|
||||
const [owner, repo, numberStr] = splitThree(actionId.slice(BTN_PREFIX.length));
|
||||
const number = Number(numberStr);
|
||||
if (!owner || !repo || !Number.isInteger(number)) {
|
||||
await replyCard(env, cardToken, buildResultCard(false, "无效的按钮数据"));
|
||||
return;
|
||||
}
|
||||
const githubUserId = await getFeishuLink(env.DB, openId);
|
||||
if (!githubUserId) {
|
||||
await replyCard(env, cardToken, buildResultCard(false, "请先 /gh login 绑定 GitHub 账号"));
|
||||
return;
|
||||
}
|
||||
try {
|
||||
if (isMerge) {
|
||||
await mergePullRequestAsUser(env.KV, githubUserId, owner, repo, number);
|
||||
} else {
|
||||
await closePullRequestAsUser(env.KV, githubUserId, owner, repo, number);
|
||||
}
|
||||
await replyCard(
|
||||
env,
|
||||
cardToken,
|
||||
buildResultCard(true, `${isMerge ? "已合并" : "已关闭"} ${owner}/${repo}#${number}`),
|
||||
);
|
||||
} catch (err) {
|
||||
await replyCard(env, cardToken, buildResultCard(false, describeError(err)));
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (openMessageId) {
|
||||
await replyCard(env, cardToken, buildResultCard(false, "未知按钮"));
|
||||
}
|
||||
}
|
||||
|
||||
async function replyCard(env: Env, cardToken: string, card: Record<string, unknown>): Promise<void> {
|
||||
const tokenRes = await getTenantAccessToken(env);
|
||||
if (!tokenRes.ok || !tokenRes.token) return;
|
||||
await updateCard(tokenRes.token, cardToken, card);
|
||||
}
|
||||
|
||||
export async function handleFeishuWebhookRequest(request: Request, env: Env): Promise<Response> {
|
||||
const rawBody = await request.text();
|
||||
let payload: Record<string, unknown>;
|
||||
try {
|
||||
payload = JSON.parse(rawBody);
|
||||
} catch {
|
||||
return new Response("invalid json", { status: 400 });
|
||||
}
|
||||
|
||||
const header = payload.header as Record<string, unknown> | undefined;
|
||||
const eventPart = payload.event as Record<string, unknown> | undefined;
|
||||
const type = (payload.type as string) ?? header?.event_type ?? eventPart?.type ?? "";
|
||||
|
||||
if (type === "url_verification") {
|
||||
return Response.json({ challenge: (payload.challenge as string) ?? "" });
|
||||
}
|
||||
|
||||
const appSecret = env.FEISHU_APP_SECRET?.trim() ?? "";
|
||||
const signature = request.headers.get("x-lark-signature") ?? "";
|
||||
const timestamp = request.headers.get("x-lark-timestamp") ?? "";
|
||||
const nonce = request.headers.get("x-lark-nonce") ?? "";
|
||||
if (appSecret && signature) {
|
||||
const ok = await verifyFeishuSignature(appSecret, timestamp, nonce, rawBody, signature);
|
||||
if (!ok) {
|
||||
log.warn("Feishu signature verification failed");
|
||||
return new Response("invalid signature", { status: 401 });
|
||||
}
|
||||
}
|
||||
|
||||
const eventType =
|
||||
(header?.event_type as string) ?? eventPart?.type ?? type;
|
||||
try {
|
||||
if (eventType === "im.message.receive_v1" || eventType === "message") {
|
||||
await handleMessage(env, (payload.event ?? payload) as FeishuEvent);
|
||||
} else if (eventType === "card.action.trigger") {
|
||||
await handleCardAction(env, payload);
|
||||
}
|
||||
} catch (err) {
|
||||
log.error({ err }, "Feishu webhook handling failed");
|
||||
}
|
||||
|
||||
return new Response("ok", { status: 200 });
|
||||
}
|
||||
|
||||
export { FEISHU_API };
|
||||
|
|
@ -2,10 +2,12 @@ import type { RouteTarget } from "../types";
|
|||
import type { PlatformDriver } from "./types";
|
||||
import { DiscordDriver } from "./discord";
|
||||
import { TelegramDriver } from "./telegram";
|
||||
import { FeishuDriver } from "./feishu";
|
||||
|
||||
const drivers: Record<string, PlatformDriver> = {
|
||||
discord: new DiscordDriver(),
|
||||
telegram: new TelegramDriver(),
|
||||
feishu: new FeishuDriver(),
|
||||
};
|
||||
|
||||
export function getDriver(target: RouteTarget): PlatformDriver {
|
||||
|
|
|
|||
|
|
@ -119,3 +119,34 @@ export async function removeTelegramLink(db: D1Database, telegramUserId: string)
|
|||
.bind(telegramUserId)
|
||||
.run();
|
||||
}
|
||||
|
||||
export async function saveFeishuLink(
|
||||
db: D1Database,
|
||||
feishuUserId: string,
|
||||
githubUserId: string,
|
||||
): Promise<void> {
|
||||
await db
|
||||
.prepare(
|
||||
"INSERT OR REPLACE INTO feishu_links (feishu_user_id, github_user_id) VALUES (?, ?)",
|
||||
)
|
||||
.bind(feishuUserId, githubUserId)
|
||||
.run();
|
||||
}
|
||||
|
||||
export async function getFeishuLink(
|
||||
db: D1Database,
|
||||
feishuUserId: string,
|
||||
): Promise<string | null> {
|
||||
const { results } = await db
|
||||
.prepare("SELECT github_user_id FROM feishu_links WHERE feishu_user_id = ?")
|
||||
.bind(feishuUserId)
|
||||
.all<{ github_user_id: string }>();
|
||||
return results[0]?.github_user_id ?? null;
|
||||
}
|
||||
|
||||
export async function removeFeishuLink(db: D1Database, feishuUserId: string): Promise<void> {
|
||||
await db
|
||||
.prepare("DELETE FROM feishu_links WHERE feishu_user_id = ?")
|
||||
.bind(feishuUserId)
|
||||
.run();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import type { Env, WebhookEvent, WebhookProvider } from "../types";
|
||||
import type { Config, Env, Group, WebhookEvent, WebhookProvider } from "../types";
|
||||
import { dispatchEvent } from "../core/dispatch";
|
||||
import { loadConfig } from "../config";
|
||||
import { loadGroups } from "../web/groups";
|
||||
|
|
@ -6,6 +6,7 @@ import { log } from "../lib/log";
|
|||
import {
|
||||
DELIVERY_DLQ,
|
||||
type DeliveryMessage,
|
||||
type DispatchSummary,
|
||||
classifyDelivery,
|
||||
deliveryStateKey,
|
||||
discardPayload,
|
||||
|
|
@ -18,6 +19,12 @@ import {
|
|||
export async function handleQueueBatch(
|
||||
batch: MessageBatch<DeliveryMessage>,
|
||||
env: Env,
|
||||
dispatch: (
|
||||
config: Config,
|
||||
event: WebhookEvent,
|
||||
env: Env,
|
||||
groups?: Group[],
|
||||
) => Promise<DispatchSummary> = dispatchEvent,
|
||||
): Promise<void> {
|
||||
for (const message of batch.messages) {
|
||||
const body = message.body;
|
||||
|
|
@ -26,7 +33,7 @@ export async function handleQueueBatch(
|
|||
message.ack();
|
||||
continue;
|
||||
}
|
||||
await processMessage(env, body, message);
|
||||
await processMessage(env, body, message, dispatch);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -34,6 +41,12 @@ async function processMessage(
|
|||
env: Env,
|
||||
body: DeliveryMessage,
|
||||
message: Message<DeliveryMessage>,
|
||||
dispatch: (
|
||||
config: Config,
|
||||
event: WebhookEvent,
|
||||
env: Env,
|
||||
groups?: Group[],
|
||||
) => Promise<DispatchSummary>,
|
||||
): Promise<void> {
|
||||
const key = deliveryStateKey(body.provider, body.groupId, body.deliveryId);
|
||||
const prior = await getDeliveryState(env, key);
|
||||
|
|
@ -59,7 +72,7 @@ async function processMessage(
|
|||
config.routes = config.routes.filter((r) => r.groupId === body.groupId);
|
||||
}
|
||||
const groups = await loadGroups(env.KV);
|
||||
const summary = await dispatchEvent(config, event, env, groups);
|
||||
const summary = await dispatch(config, event, env, groups);
|
||||
const { failed, retryable } = classifyDelivery(summary);
|
||||
|
||||
if (!failed) {
|
||||
|
|
|
|||
|
|
@ -58,9 +58,9 @@ export function classifyDelivery(summary: DispatchSummary): {
|
|||
}
|
||||
|
||||
export function retryDelay(attempt: number): number {
|
||||
if (attempt < 1) return RETRY_DELAYS_SECONDS[0];
|
||||
if (attempt < 1) return RETRY_DELAYS_SECONDS[0] ?? 5;
|
||||
const idx = Math.min(attempt - 1, RETRY_DELAYS_SECONDS.length - 1);
|
||||
return RETRY_DELAYS_SECONDS[idx];
|
||||
return RETRY_DELAYS_SECONDS[idx] ?? 600;
|
||||
}
|
||||
|
||||
function scopeKey(provider: string, groupId: string | undefined, deliveryId: string): string {
|
||||
|
|
|
|||
|
|
@ -13,6 +13,8 @@ export interface Env {
|
|||
TELEGRAM_TOKEN?: string;
|
||||
TELEGRAM_WEBHOOK_SECRET?: string;
|
||||
TELEGRAM_RICH_HEADER_HOST?: string;
|
||||
FEISHU_APP_ID?: string;
|
||||
FEISHU_APP_SECRET?: string;
|
||||
/**
|
||||
* When enabled ("1"/"true"), GitHub users without any group access get a
|
||||
* personal group on first login instead of being blocked.
|
||||
|
|
@ -43,7 +45,7 @@ export interface Config {
|
|||
}
|
||||
|
||||
export interface RouteTarget {
|
||||
platform?: "discord" | "telegram";
|
||||
platform?: "discord" | "telegram" | "feishu";
|
||||
channelId?: string;
|
||||
threadId?: string;
|
||||
chatId?: string;
|
||||
|
|
|
|||
|
|
@ -234,10 +234,10 @@ function validateTarget(
|
|||
target: Record<string, unknown>,
|
||||
): { ok: true; target: Route["targets"][number] } | { ok: false; error: string } {
|
||||
const platform = target.platform === undefined ? "discord" : target.platform;
|
||||
if (platform !== "discord" && platform !== "telegram") {
|
||||
return { ok: false, error: `${label}.platform must be "discord" or "telegram"` };
|
||||
if (platform !== "discord" && platform !== "telegram" && platform !== "feishu") {
|
||||
return { ok: false, error: `${label}.platform must be "discord", "telegram" or "feishu"` };
|
||||
}
|
||||
if (platform === "telegram") {
|
||||
if (platform === "telegram" || platform === "feishu") {
|
||||
if (typeof target.chatId !== "string" || target.chatId.trim().length === 0)
|
||||
return { ok: false, error: `${label}.chatId is required` };
|
||||
if (target.topicId !== undefined && typeof target.topicId !== "string") {
|
||||
|
|
@ -254,9 +254,9 @@ function validateTarget(
|
|||
ok: true,
|
||||
target: {
|
||||
platform,
|
||||
channelId: platform === "telegram" ? undefined : (target.channelId as string),
|
||||
threadId: platform === "telegram" ? undefined : ((target.threadId as string) ?? undefined),
|
||||
chatId: platform === "telegram" ? (target.chatId as string) : undefined,
|
||||
channelId: platform === "discord" ? (target.channelId as string) : undefined,
|
||||
threadId: platform === "discord" ? ((target.threadId as string) ?? undefined) : undefined,
|
||||
chatId: platform === "telegram" || platform === "feishu" ? (target.chatId as string) : undefined,
|
||||
topicId: platform === "telegram" ? ((target.topicId as string) ?? undefined) : undefined,
|
||||
},
|
||||
};
|
||||
|
|
|
|||
|
|
@ -13,7 +13,7 @@ import {
|
|||
handleOAuthCallback as handleGithubOAuthCallback,
|
||||
getInstallationAccount,
|
||||
} from "../github/oauth";
|
||||
import { removeToken, saveDiscordLink, saveTelegramLink } from "../github/store";
|
||||
import { removeToken, saveDiscordLink, saveTelegramLink, saveFeishuLink } from "../github/store";
|
||||
import { createAdminSession, adminCookie, getAdminSession } from "./session";
|
||||
import {
|
||||
loadGroups,
|
||||
|
|
@ -27,6 +27,7 @@ import {
|
|||
import { clientIp } from "./auth";
|
||||
import { recordAudit } from "../lib/audit";
|
||||
import { sendMessage } from "../drivers/telegram/rest";
|
||||
import { getTenantAccessToken, sendText as sendFeishuText } from "../drivers/feishu/rest";
|
||||
import { cfEnv } from "../cf";
|
||||
import { initConfigStore } from "../config";
|
||||
import type { Env, Group } from "../types";
|
||||
|
|
@ -37,6 +38,8 @@ interface PendingState {
|
|||
discordUserId?: string;
|
||||
telegramUserId?: string;
|
||||
telegramChatId?: string;
|
||||
feishuUserId?: string;
|
||||
feishuChatId?: string;
|
||||
}
|
||||
|
||||
function linkedPage(login: string): string {
|
||||
|
|
@ -337,6 +340,22 @@ export async function handleOAuthCallback(event: H3Event): Promise<unknown> {
|
|||
return { ok: true, telegramUserId: pending.telegramUserId, login: result.login };
|
||||
}
|
||||
|
||||
// Feishu account-linking flow: bind the Feishu user to this GitHub account.
|
||||
if (pending.feishuUserId) {
|
||||
await saveFeishuLink(env.DB, pending.feishuUserId, result.userId);
|
||||
if (pending.feishuChatId) {
|
||||
const tokenRes = await getTenantAccessToken(env);
|
||||
if (tokenRes.ok && tokenRes.token) {
|
||||
await sendFeishuText(
|
||||
tokenRes.token,
|
||||
pending.feishuChatId,
|
||||
`✅ GitHub 账号已绑定:**@${result.login}**。现在可以用 /gh comment 评论了。`,
|
||||
).catch(() => undefined);
|
||||
}
|
||||
}
|
||||
return { ok: true, feishuUserId: pending.feishuUserId, login: result.login };
|
||||
}
|
||||
|
||||
const isBrowser = (getHeader(event, "accept") ?? "").includes("text/html");
|
||||
if (isBrowser) {
|
||||
// Invite accept flow: the redirect target is the invite page, which
|
||||
|
|
|
|||
14
server/routes/feishu/webhook.post.ts
Normal file
14
server/routes/feishu/webhook.post.ts
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
import { readRawBody } from "h3";
|
||||
import { handleFeishuWebhookRequest } from "../../lib/drivers/feishu/updates";
|
||||
import { cfEnv, rawRequest } from "../../lib/cf";
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
const source = rawRequest(event);
|
||||
const body = await readRawBody(event, "utf8");
|
||||
const request = new Request(source.url, {
|
||||
method: source.method,
|
||||
headers: source.headers,
|
||||
body,
|
||||
});
|
||||
return handleFeishuWebhookRequest(request, cfEnv(event));
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue