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:
RhenCloud 2026-08-27 00:04:49 +08:00
parent 3437ac5513
commit 4b99f6d33d
No known key found for this signature in database
GPG key ID: A574A617378C4E0B
38 changed files with 2449 additions and 1412 deletions

View file

@ -0,0 +1,79 @@
import { describe, it, expect } from "bun:test";
import type { Env } from "../server/lib/types";
import { verifyFeishuSignature, handleFeishuWebhookRequest } from "../server/lib/drivers/feishu/updates";
function sign(secret: string, timestamp: string, nonce: string, body: string): Promise<string> {
return (async () => {
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(`${timestamp}\n${nonce}\n${body}`));
let binary = "";
const bytes = new Uint8Array(sig);
for (let i = 0; i < bytes.length; i++) binary += String.fromCharCode(bytes[i] ?? 0);
return btoa(binary);
})();
}
const stubKV = {
get: async () => null,
put: async () => undefined,
delete: async () => undefined,
} as unknown as KVNamespace<string>;
const stubDB = {} as unknown as D1Database;
const stubEnv: Env = {
GITHUB_WEBHOOK_SECRET: "",
FEISHU_APP_ID: "",
FEISHU_APP_SECRET: "",
KV: stubKV,
DB: stubDB,
};
describe("feishu signature", () => {
it("verifies a correct signature", async () => {
const secret = "s3cr3t";
const ts = "1700000000";
const nonce = "abc";
const body = JSON.stringify({ hello: "world" });
const sig = await sign(secret, ts, nonce, body);
expect(await verifyFeishuSignature(secret, ts, nonce, body, sig)).toBe(true);
});
it("rejects a wrong signature", async () => {
const secret = "s3cr3t";
const body = JSON.stringify({ hello: "world" });
const sig = await sign("other", "1", "2", body);
expect(await verifyFeishuSignature(secret, "1", "2", body, sig)).toBe(false);
});
});
describe("feishu webhook", () => {
it("answers the url_verification challenge", async () => {
const body = JSON.stringify({ type: "url_verification", challenge: "xyz123" });
const req = new Request("https://x/feishu/webhook", {
method: "POST",
headers: { "content-type": "application/json" },
body,
});
const res = await handleFeishuWebhookRequest(req, stubEnv);
expect(res.status).toBe(200);
const json = await res.json();
expect(json.challenge).toBe("xyz123");
});
it("rejects a bad signature with 401 when a secret is configured", async () => {
const env = { ...stubEnv, FEISHU_APP_SECRET: "s3cr3t" };
const body = JSON.stringify({ type: "other", challenge: "x" });
const req = new Request("https://x/feishu/webhook", {
method: "POST",
headers: { "content-type": "application/json", "x-lark-signature": "deadbeef" },
body,
});
const res = await handleFeishuWebhookRequest(req, env);
expect(res.status).toBe(401);
});
});