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
79
tests/feishu-inbound.test.ts
Normal file
79
tests/feishu-inbound.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
import { describe, it, expect, beforeEach, mock } from "bun:test";
|
||||
import { describe, it, expect, beforeEach } from "bun:test";
|
||||
import { handleQueueBatch } from "../server/lib/queue/consumer";
|
||||
import { invalidateConfigCache } from "../server/lib/config";
|
||||
import { invalidateGroupsCache } from "../server/lib/web/groups";
|
||||
|
|
@ -8,12 +8,10 @@ import type { DeliveryMessage, DispatchSummary } from "../server/lib/queue/deliv
|
|||
let summary: DispatchSummary;
|
||||
let dispatchCalls: number;
|
||||
|
||||
mock.module("../server/lib/core/dispatch", () => ({
|
||||
dispatchEvent: async (): Promise<DispatchSummary> => {
|
||||
dispatchCalls += 1;
|
||||
return summary;
|
||||
},
|
||||
}));
|
||||
async function fakeDispatch(..._args: unknown[]): Promise<DispatchSummary> {
|
||||
dispatchCalls += 1;
|
||||
return summary;
|
||||
}
|
||||
|
||||
function createMockKV(): { kv: KVNamespace; store: Map<string, string> } {
|
||||
const store = new Map<string, string>();
|
||||
|
|
@ -95,7 +93,7 @@ describe("handleQueueBatch", () => {
|
|||
it("marks DLQ messages dead and acks them", async () => {
|
||||
const { kv, store } = createMockKV();
|
||||
const msg = makeMessage(body());
|
||||
await handleQueueBatch(makeBatch("webhooker-delivery-dlq", [msg]), createEnv(kv));
|
||||
await handleQueueBatch(makeBatch("webhooker-delivery-dlq", [msg]), createEnv(kv), fakeDispatch);
|
||||
expect(msg.acked).toBe(true);
|
||||
expect(JSON.parse(store.get(STATE_KEY)!)).toMatchObject({ status: "dead" });
|
||||
});
|
||||
|
|
@ -104,7 +102,7 @@ describe("handleQueueBatch", () => {
|
|||
const { kv, store } = createMockKV();
|
||||
summary = { attempts: 1, failures: [] };
|
||||
const msg = makeMessage(body());
|
||||
await handleQueueBatch(makeBatch("webhooker-delivery", [msg]), createEnv(kv));
|
||||
await handleQueueBatch(makeBatch("webhooker-delivery", [msg]), createEnv(kv), fakeDispatch);
|
||||
expect(msg.acked).toBe(true);
|
||||
expect(msg.retried).toBeNull();
|
||||
expect(JSON.parse(store.get(STATE_KEY)!)).toMatchObject({ status: "delivered" });
|
||||
|
|
@ -114,7 +112,7 @@ describe("handleQueueBatch", () => {
|
|||
const { kv, store } = createMockKV();
|
||||
summary = { attempts: 2, failures: [{ target: "c1", errorCode: "DISCORD_5XX" }] };
|
||||
const msg = makeMessage(body(), 1);
|
||||
await handleQueueBatch(makeBatch("webhooker-delivery", [msg]), createEnv(kv));
|
||||
await handleQueueBatch(makeBatch("webhooker-delivery", [msg]), createEnv(kv), fakeDispatch);
|
||||
expect(msg.acked).toBe(false);
|
||||
expect(msg.retried).toEqual({ delaySeconds: 5 });
|
||||
expect(JSON.parse(store.get(STATE_KEY)!)).toMatchObject({ status: "retrying" });
|
||||
|
|
@ -124,7 +122,7 @@ describe("handleQueueBatch", () => {
|
|||
const { kv, store } = createMockKV();
|
||||
summary = { attempts: 2, failures: [{ target: "c1", errorCode: "DISCORD_ERROR" }] };
|
||||
const msg = makeMessage(body());
|
||||
await handleQueueBatch(makeBatch("webhooker-delivery", [msg]), createEnv(kv));
|
||||
await handleQueueBatch(makeBatch("webhooker-delivery", [msg]), createEnv(kv), fakeDispatch);
|
||||
expect(msg.acked).toBe(true);
|
||||
expect(msg.retried).toBeNull();
|
||||
expect(JSON.parse(store.get(STATE_KEY)!)).toMatchObject({ status: "failed" });
|
||||
|
|
@ -134,7 +132,7 @@ describe("handleQueueBatch", () => {
|
|||
const { kv, store } = createMockKV();
|
||||
store.set(STATE_KEY, JSON.stringify({ status: "delivered", at: Date.now() }));
|
||||
const msg = makeMessage(body());
|
||||
await handleQueueBatch(makeBatch("webhooker-delivery", [msg]), createEnv(kv));
|
||||
await handleQueueBatch(makeBatch("webhooker-delivery", [msg]), createEnv(kv), fakeDispatch);
|
||||
expect(msg.acked).toBe(true);
|
||||
expect(dispatchCalls).toBe(0);
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue