mirror of
https://github.com/ReCloudStudio/WebHooker.git
synced 2026-09-23 00:21:28 +00:00
initial commit
This commit is contained in:
commit
512d4b01d5
55 changed files with 6430 additions and 0 deletions
60
src/__tests__/token-store.test.ts
Normal file
60
src/__tests__/token-store.test.ts
Normal file
|
|
@ -0,0 +1,60 @@
|
|||
import { describe, it, expect, beforeEach, afterEach } from "bun:test";
|
||||
import { existsSync, unlinkSync, mkdirSync } from "fs";
|
||||
import { dirname } from "path";
|
||||
import {
|
||||
saveToken,
|
||||
getToken,
|
||||
removeToken,
|
||||
findUserIdByToken,
|
||||
initTokenStore,
|
||||
} from "../token-store";
|
||||
|
||||
const TEST_STORE = "./data/test-tokens.json";
|
||||
|
||||
beforeEach(() => {
|
||||
const dir = dirname(TEST_STORE);
|
||||
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
|
||||
if (existsSync(TEST_STORE)) unlinkSync(TEST_STORE);
|
||||
initTokenStore(TEST_STORE);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
if (existsSync(TEST_STORE)) unlinkSync(TEST_STORE);
|
||||
});
|
||||
|
||||
describe("token-store", () => {
|
||||
it("saves and retrieves token", () => {
|
||||
saveToken("user1", "token-abc", 3600);
|
||||
expect(getToken("user1")).toBe("token-abc");
|
||||
});
|
||||
|
||||
it("returns null for expired token", () => {
|
||||
saveToken("user1", "token-abc", -1);
|
||||
expect(getToken("user1")).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null for nonexistent user", () => {
|
||||
expect(getToken("nobody")).toBeNull();
|
||||
});
|
||||
|
||||
it("removes token", () => {
|
||||
saveToken("user1", "token-abc", 3600);
|
||||
removeToken("user1");
|
||||
expect(getToken("user1")).toBeNull();
|
||||
});
|
||||
|
||||
it("finds userId by token", () => {
|
||||
saveToken("user1", "token-abc", 3600);
|
||||
expect(findUserIdByToken("token-abc")).toBe("user1");
|
||||
});
|
||||
|
||||
it("returns null for unknown token", () => {
|
||||
expect(findUserIdByToken("unknown")).toBeNull();
|
||||
});
|
||||
|
||||
it("persists across reload", () => {
|
||||
saveToken("user1", "token-abc", 3600);
|
||||
initTokenStore(TEST_STORE);
|
||||
expect(getToken("user1")).toBe("token-abc");
|
||||
});
|
||||
});
|
||||
172
src/__tests__/webhook.test.ts
Normal file
172
src/__tests__/webhook.test.ts
Normal file
|
|
@ -0,0 +1,172 @@
|
|||
import { describe, it, expect } from "bun:test";
|
||||
import { createHmac } from "crypto";
|
||||
import { verifySignature, parseEvent, matchRoute } from "../webhook";
|
||||
import type { Route, WebhookEvent } from "../types";
|
||||
|
||||
function sign(body: string, secret: string): string {
|
||||
return `sha256=${createHmac("sha256", secret).update(body).digest("hex")}`;
|
||||
}
|
||||
|
||||
describe("verifySignature", () => {
|
||||
const secret = "test-secret";
|
||||
|
||||
it("returns true for valid signature", () => {
|
||||
const body = '{"hello":"world"}';
|
||||
expect(verifySignature(body, sign(body, secret), secret)).toBe(true);
|
||||
});
|
||||
|
||||
it("returns false for invalid signature", () => {
|
||||
expect(verifySignature("body", "sha256=invalid", secret)).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false for missing signature", () => {
|
||||
expect(verifySignature("body", undefined, secret)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("parseEvent", () => {
|
||||
it("parses valid push event", () => {
|
||||
const headers = { "x-github-event": "push", "x-hub-signature-256": "sha256=abc" };
|
||||
const body = JSON.stringify({ ref: "refs/heads/main", commits: [] });
|
||||
const event = parseEvent(headers, body);
|
||||
expect(event).not.toBeNull();
|
||||
expect(event!.event).toBe("push");
|
||||
expect(event!.payload.ref).toBe("refs/heads/main");
|
||||
});
|
||||
|
||||
it("returns null for missing event header", () => {
|
||||
expect(parseEvent({}, "{}")).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null for invalid JSON", () => {
|
||||
expect(parseEvent({ "x-github-event": "push" }, "not json")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("matchRoute", () => {
|
||||
const baseRoute: Route = {
|
||||
id: "test",
|
||||
name: "Test",
|
||||
enabled: true,
|
||||
filters: [],
|
||||
target: { channelId: "123" },
|
||||
};
|
||||
|
||||
it("matches all events when no filters", () => {
|
||||
const event: WebhookEvent = { event: "push", payload: {} };
|
||||
expect(matchRoute(baseRoute, event)).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects disabled routes", () => {
|
||||
const route = { ...baseRoute, enabled: false };
|
||||
const event: WebhookEvent = { event: "push", payload: {} };
|
||||
expect(matchRoute(route, event)).toBe(false);
|
||||
});
|
||||
|
||||
it("matches event filter", () => {
|
||||
const route = { ...baseRoute, filters: [{ type: "event" as const, match: "push" }] };
|
||||
expect(matchRoute(route, { event: "push", payload: {} })).toBe(true);
|
||||
expect(matchRoute(route, { event: "issues", payload: {} })).toBe(false);
|
||||
});
|
||||
|
||||
it("matches event exclude filter", () => {
|
||||
const route = {
|
||||
...baseRoute,
|
||||
filters: [{ type: "event" as const, match: "push", exclude: true }],
|
||||
};
|
||||
expect(matchRoute(route, { event: "push", payload: {} })).toBe(false);
|
||||
expect(matchRoute(route, { event: "issues", payload: {} })).toBe(true);
|
||||
});
|
||||
|
||||
it("matches repo filter", () => {
|
||||
const route = { ...baseRoute, filters: [{ type: "repo" as const, match: "owner/repo" }] };
|
||||
const event: WebhookEvent = {
|
||||
event: "push",
|
||||
payload: { repository: { full_name: "owner/repo" } },
|
||||
};
|
||||
expect(matchRoute(route, event)).toBe(true);
|
||||
});
|
||||
|
||||
it("matches actor filter", () => {
|
||||
const route = { ...baseRoute, filters: [{ type: "actor" as const, match: "octocat" }] };
|
||||
const event: WebhookEvent = { event: "push", payload: { sender: { login: "octocat" } } };
|
||||
expect(matchRoute(route, event)).toBe(true);
|
||||
});
|
||||
|
||||
it("matches action filter", () => {
|
||||
const route = { ...baseRoute, filters: [{ type: "action" as const, match: "opened" }] };
|
||||
const event: WebhookEvent = { event: "issues", payload: { action: "opened" } };
|
||||
expect(matchRoute(route, event)).toBe(true);
|
||||
});
|
||||
|
||||
it("matches branch filter for push events", () => {
|
||||
const route = { ...baseRoute, filters: [{ type: "branch" as const, match: "main" }] };
|
||||
const event: WebhookEvent = { event: "push", payload: { ref: "refs/heads/main" } };
|
||||
expect(matchRoute(route, event)).toBe(true);
|
||||
expect(matchRoute(route, { event: "push", payload: { ref: "refs/heads/dev" } })).toBe(false);
|
||||
});
|
||||
|
||||
it("matches branch filter for pull_request events", () => {
|
||||
const route = { ...baseRoute, filters: [{ type: "branch" as const, match: "feature-x" }] };
|
||||
const event: WebhookEvent = {
|
||||
event: "pull_request",
|
||||
payload: { pull_request: { head: { ref: "feature-x" } } },
|
||||
};
|
||||
expect(matchRoute(route, event)).toBe(true);
|
||||
expect(
|
||||
matchRoute(route, {
|
||||
event: "pull_request",
|
||||
payload: { pull_request: { head: { ref: "other" } } },
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("matches keyword filter with regex", () => {
|
||||
const route = {
|
||||
...baseRoute,
|
||||
filters: [{ type: "keyword" as const, match: "fix(es|ed)\\s+bug" }],
|
||||
};
|
||||
const event: WebhookEvent = {
|
||||
event: "push",
|
||||
payload: { commits: [{ message: "fixes bug #123" }] },
|
||||
};
|
||||
expect(matchRoute(route, event)).toBe(true);
|
||||
expect(
|
||||
matchRoute(route, { event: "push", payload: { commits: [{ message: "adds feature" }] } }),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("matches keyword filter with plain text fallback", () => {
|
||||
const route = { ...baseRoute, filters: [{ type: "keyword" as const, match: "deploy" }] };
|
||||
const event: WebhookEvent = {
|
||||
event: "push",
|
||||
payload: { commits: [{ message: "deploy to prod" }] },
|
||||
};
|
||||
expect(matchRoute(route, event)).toBe(true);
|
||||
});
|
||||
|
||||
it("matches multiple filters (AND logic)", () => {
|
||||
const route = {
|
||||
...baseRoute,
|
||||
filters: [
|
||||
{ type: "event" as const, match: "push" },
|
||||
{ type: "actor" as const, match: "octocat" },
|
||||
],
|
||||
};
|
||||
const event: WebhookEvent = { event: "push", payload: { sender: { login: "octocat" } } };
|
||||
expect(matchRoute(route, event)).toBe(true);
|
||||
expect(matchRoute(route, { event: "push", payload: { sender: { login: "other" } } })).toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
it("matches array of patterns", () => {
|
||||
const route = {
|
||||
...baseRoute,
|
||||
filters: [{ type: "event" as const, match: ["push", "pull_request"] }],
|
||||
};
|
||||
expect(matchRoute(route, { event: "push", payload: {} })).toBe(true);
|
||||
expect(matchRoute(route, { event: "pull_request", payload: {} })).toBe(true);
|
||||
expect(matchRoute(route, { event: "issues", payload: {} })).toBe(false);
|
||||
});
|
||||
});
|
||||
94
src/action-routes.ts
Normal file
94
src/action-routes.ts
Normal file
|
|
@ -0,0 +1,94 @@
|
|||
import { Hono } from "hono";
|
||||
import { getUserOctokit } from "./github-oauth";
|
||||
import { findUserIdByToken } from "./token-store";
|
||||
import type { Env } from "./types";
|
||||
|
||||
function extractBearerToken(c: {
|
||||
req: { header: (name: string) => string | undefined };
|
||||
}): string | null {
|
||||
const auth = c.req.header("authorization");
|
||||
if (!auth?.startsWith("Bearer ")) return null;
|
||||
return auth.slice(7);
|
||||
}
|
||||
|
||||
export function createActionRoutes(): Hono<{ Bindings: Env }> {
|
||||
const app = new Hono<{ Bindings: Env }>();
|
||||
|
||||
app.post("/api/comment", async (c) => {
|
||||
const token = extractBearerToken(c);
|
||||
if (!token) return c.json({ error: "Missing authorization" }, 401);
|
||||
const userId = await findUserIdByToken(c.env.KV, token);
|
||||
if (!userId) return c.json({ error: "Invalid or expired token" }, 401);
|
||||
|
||||
const body = await c.req.json<{
|
||||
owner: string;
|
||||
repo: string;
|
||||
issueNumber: number;
|
||||
body: string;
|
||||
}>();
|
||||
const octokit = await getUserOctokit(userId, c.env.KV);
|
||||
if (!octokit) return c.json({ error: "Not authorized" }, 401);
|
||||
|
||||
await octokit.rest.issues.createComment({
|
||||
owner: body.owner,
|
||||
repo: body.repo,
|
||||
issue_number: body.issueNumber,
|
||||
body: body.body,
|
||||
});
|
||||
|
||||
return c.json({ ok: true });
|
||||
});
|
||||
|
||||
app.post("/api/merge", async (c) => {
|
||||
const token = extractBearerToken(c);
|
||||
if (!token) return c.json({ error: "Missing authorization" }, 401);
|
||||
const userId = await findUserIdByToken(c.env.KV, token);
|
||||
if (!userId) return c.json({ error: "Invalid or expired token" }, 401);
|
||||
|
||||
const body = await c.req.json<{
|
||||
owner: string;
|
||||
repo: string;
|
||||
pullNumber: number;
|
||||
method?: "merge" | "squash" | "rebase";
|
||||
}>();
|
||||
const octokit = await getUserOctokit(userId, c.env.KV);
|
||||
if (!octokit) return c.json({ error: "Not authorized" }, 401);
|
||||
|
||||
await octokit.rest.pulls.merge({
|
||||
owner: body.owner,
|
||||
repo: body.repo,
|
||||
pull_number: body.pullNumber,
|
||||
merge_method: body.method ?? "squash",
|
||||
});
|
||||
|
||||
return c.json({ ok: true });
|
||||
});
|
||||
|
||||
app.post("/api/react", async (c) => {
|
||||
const token = extractBearerToken(c);
|
||||
if (!token) return c.json({ error: "Missing authorization" }, 401);
|
||||
const userId = await findUserIdByToken(c.env.KV, token);
|
||||
if (!userId) return c.json({ error: "Invalid or expired token" }, 401);
|
||||
|
||||
const body = await c.req.json<{
|
||||
owner: string;
|
||||
repo: string;
|
||||
issueNumber: number;
|
||||
reaction: string;
|
||||
}>();
|
||||
const octokit = await getUserOctokit(userId, c.env.KV);
|
||||
if (!octokit) return c.json({ error: "Not authorized" }, 401);
|
||||
|
||||
await octokit.rest.reactions.createForIssue({
|
||||
owner: body.owner,
|
||||
repo: body.repo,
|
||||
issue_number: body.issueNumber,
|
||||
content: body.reaction as
|
||||
"+1" | "-1" | "laugh" | "confused" | "heart" | "hooray" | "rocket" | "eyes",
|
||||
});
|
||||
|
||||
return c.json({ ok: true });
|
||||
});
|
||||
|
||||
return app;
|
||||
}
|
||||
90
src/config.ts
Normal file
90
src/config.ts
Normal file
|
|
@ -0,0 +1,90 @@
|
|||
import type { Env, Config, Route } from "./types";
|
||||
import { log } from "./log";
|
||||
|
||||
const DEFAULT_ROUTES: Route[] = [
|
||||
{
|
||||
id: "all-push",
|
||||
name: "All Push Events",
|
||||
enabled: true,
|
||||
filters: [{ type: "event", match: "push" }],
|
||||
target: { channelId: "" },
|
||||
},
|
||||
{
|
||||
id: "pull-requests",
|
||||
name: "Pull Requests",
|
||||
enabled: true,
|
||||
filters: [{ type: "event", match: "pull_request" }],
|
||||
target: { channelId: "" },
|
||||
},
|
||||
{
|
||||
id: "issues",
|
||||
name: "Issues",
|
||||
enabled: true,
|
||||
filters: [{ type: "event", match: "issues" }],
|
||||
target: { channelId: "" },
|
||||
},
|
||||
{
|
||||
id: "issue-comments",
|
||||
name: "Issue Comments",
|
||||
enabled: true,
|
||||
filters: [{ type: "event", match: "issue_comment" }],
|
||||
target: { channelId: "" },
|
||||
},
|
||||
{
|
||||
id: "workflow-runs",
|
||||
name: "Workflow Runs",
|
||||
enabled: true,
|
||||
filters: [{ type: "event", match: "workflow_run" }],
|
||||
target: { channelId: "" },
|
||||
},
|
||||
{
|
||||
id: "releases",
|
||||
name: "Releases",
|
||||
enabled: true,
|
||||
filters: [{ type: "event", match: "release" }],
|
||||
target: { channelId: "" },
|
||||
},
|
||||
{
|
||||
id: "branch-activity",
|
||||
name: "Branch Create/Delete",
|
||||
enabled: true,
|
||||
filters: [{ type: "event", match: ["create", "delete"] }],
|
||||
target: { channelId: "" },
|
||||
},
|
||||
];
|
||||
|
||||
export async function loadConfig(env: Env): Promise<Config> {
|
||||
let routes = DEFAULT_ROUTES;
|
||||
|
||||
try {
|
||||
const stored = await env.KV.get("config:routes", "json");
|
||||
if (stored) {
|
||||
routes = stored as Route[];
|
||||
}
|
||||
} catch (err) {
|
||||
log.warn({ err }, "Failed to load routes from KV, using defaults");
|
||||
}
|
||||
|
||||
const defaultChannelId = env.DISCORD_CHANNEL_ID ?? "";
|
||||
|
||||
routes = routes.map((r) => ({
|
||||
...r,
|
||||
target: { ...r.target, channelId: r.target.channelId || defaultChannelId },
|
||||
}));
|
||||
|
||||
return {
|
||||
baseUrl: env.BASE_URL ?? "https://webhooker.example.workers.dev",
|
||||
github: {
|
||||
webhookSecret: env.GITHUB_WEBHOOK_SECRET,
|
||||
appId: Number(env.GITHUB_APP_ID ?? 0),
|
||||
privateKey: env.GITHUB_PRIVATE_KEY ?? "",
|
||||
clientId: env.GITHUB_CLIENT_ID ?? "",
|
||||
clientSecret: env.GITHUB_CLIENT_SECRET ?? "",
|
||||
},
|
||||
discord: {
|
||||
token: env.DISCORD_TOKEN ?? "",
|
||||
defaultChannelId,
|
||||
},
|
||||
routes,
|
||||
};
|
||||
}
|
||||
259
src/discord-gateway.ts
Normal file
259
src/discord-gateway.ts
Normal file
|
|
@ -0,0 +1,259 @@
|
|||
import { log } from "./log";
|
||||
import type { Env } from "./types";
|
||||
|
||||
interface ChannelInfo {
|
||||
id: string;
|
||||
name: string;
|
||||
type: number;
|
||||
guild_id?: string;
|
||||
}
|
||||
|
||||
interface GuildInfo {
|
||||
id: string;
|
||||
name: string;
|
||||
channels: ChannelInfo[];
|
||||
}
|
||||
|
||||
interface SendMessageBody {
|
||||
channelId: string;
|
||||
message: unknown;
|
||||
threadId?: string;
|
||||
}
|
||||
|
||||
const DISCORD_API = "https://discord.com/api/v10";
|
||||
const GATEWAY_URL = "wss://gateway.discord.gg/?v=10&encoding=json";
|
||||
const HEARTBEAT_INTERVAL_BUFFER = 5000;
|
||||
const RECONNECT_DELAY = 5000;
|
||||
const ALARM_INTERVAL = 30;
|
||||
|
||||
export class DiscordGateway {
|
||||
private state: DurableObjectState;
|
||||
private env: Env;
|
||||
private socket: WebSocket | null = null;
|
||||
private heartbeatInterval: number | null = null;
|
||||
private heartbeatTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
private lastSequence: number | null = null;
|
||||
private sessionId: string | null = null;
|
||||
private guilds: Map<string, GuildInfo> = new Map();
|
||||
private token: string | null = null;
|
||||
private connecting = false;
|
||||
|
||||
constructor(state: DurableObjectState, env: Env) {
|
||||
this.state = state;
|
||||
this.env = env;
|
||||
}
|
||||
|
||||
async fetch(request: Request): Promise<Response> {
|
||||
const body = (await request.json()) as {
|
||||
action: string;
|
||||
token?: string;
|
||||
} & Record<string, unknown>;
|
||||
|
||||
switch (body.action) {
|
||||
case "start": {
|
||||
this.token = body.token as string;
|
||||
if (this.connecting || this.socket) {
|
||||
return new Response(JSON.stringify({ ok: true, status: "already_connected" }));
|
||||
}
|
||||
this.connect();
|
||||
return new Response(JSON.stringify({ ok: true }));
|
||||
}
|
||||
case "send": {
|
||||
const { channelId, message, threadId } = body as unknown as SendMessageBody;
|
||||
const result = await this.postMessage(channelId, message, threadId);
|
||||
return new Response(JSON.stringify(result));
|
||||
}
|
||||
case "status": {
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
connected: this.socket?.readyState === WebSocket.OPEN,
|
||||
sessionId: this.sessionId,
|
||||
guildCount: this.guilds.size,
|
||||
}),
|
||||
);
|
||||
}
|
||||
default:
|
||||
return new Response(JSON.stringify({ error: "Unknown action" }), { status: 400 });
|
||||
}
|
||||
}
|
||||
|
||||
private connect(): void {
|
||||
if (!this.token) return;
|
||||
this.connecting = true;
|
||||
|
||||
try {
|
||||
this.socket = new WebSocket(GATEWAY_URL);
|
||||
|
||||
this.socket.addEventListener("message", (event) => {
|
||||
this.handleMessage(event.data as string);
|
||||
});
|
||||
|
||||
this.socket.addEventListener("close", () => {
|
||||
this.connecting = false;
|
||||
this.socket = null;
|
||||
this.clearHeartbeat();
|
||||
log.warn("Gateway disconnected, reconnecting...");
|
||||
setTimeout(() => this.connect(), RECONNECT_DELAY);
|
||||
});
|
||||
|
||||
this.socket.addEventListener("error", (err) => {
|
||||
log.error({ err }, "Gateway WebSocket error");
|
||||
});
|
||||
} catch (err) {
|
||||
this.connecting = false;
|
||||
log.error({ err }, "Failed to connect to Gateway");
|
||||
setTimeout(() => this.connect(), RECONNECT_DELAY);
|
||||
}
|
||||
}
|
||||
|
||||
private handleMessage(data: string): void {
|
||||
const msg = JSON.parse(data) as {
|
||||
op: number;
|
||||
d: unknown;
|
||||
s: number | null;
|
||||
t: string | null;
|
||||
};
|
||||
|
||||
if (msg.s !== null) this.lastSequence = msg.s;
|
||||
|
||||
switch (msg.op) {
|
||||
case 0:
|
||||
this.handleDispatch(msg.t!, msg.d);
|
||||
break;
|
||||
case 10:
|
||||
this.handleHello(msg.d as { heartbeat_interval: number });
|
||||
break;
|
||||
case 11:
|
||||
break;
|
||||
case 7:
|
||||
this.reconnect();
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
private handleHello(d: { heartbeat_interval: number }): void {
|
||||
this.heartbeatInterval = d.heartbeat_interval;
|
||||
this.heartbeat();
|
||||
this.identify();
|
||||
}
|
||||
|
||||
private heartbeat(): void {
|
||||
this.clearHeartbeat();
|
||||
if (!this.socket || this.socket.readyState !== WebSocket.OPEN) return;
|
||||
|
||||
this.socket.send(JSON.stringify({ op: 1, d: this.lastSequence }));
|
||||
|
||||
if (this.heartbeatInterval) {
|
||||
this.heartbeatTimer = setTimeout(
|
||||
() => this.heartbeat(),
|
||||
this.heartbeatInterval + HEARTBEAT_INTERVAL_BUFFER,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private clearHeartbeat(): void {
|
||||
if (this.heartbeatTimer) {
|
||||
clearTimeout(this.heartbeatTimer);
|
||||
this.heartbeatTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
private identify(): void {
|
||||
if (!this.socket || !this.token) return;
|
||||
this.socket.send(
|
||||
JSON.stringify({
|
||||
op: 2,
|
||||
d: {
|
||||
token: this.token,
|
||||
intents: 1 << 0,
|
||||
},
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
private handleDispatch(event: string, data: unknown): void {
|
||||
const d = data as Record<string, unknown>;
|
||||
switch (event) {
|
||||
case "READY":
|
||||
this.sessionId = d.session_id as string;
|
||||
log.info({ user: (d.user as { username?: string })?.username }, "Gateway READY");
|
||||
break;
|
||||
case "GUILD_CREATE": {
|
||||
const guild = d as unknown as GuildInfo;
|
||||
this.guilds.set(guild.id, guild);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private reconnect(): void {
|
||||
this.clearHeartbeat();
|
||||
if (this.socket) {
|
||||
this.socket.close();
|
||||
this.socket = null;
|
||||
}
|
||||
this.connecting = false;
|
||||
setTimeout(() => this.connect(), RECONNECT_DELAY);
|
||||
}
|
||||
|
||||
private findChannel(channelId: string): { channel: ChannelInfo | null; guild: GuildInfo | null } {
|
||||
for (const guild of this.guilds.values()) {
|
||||
const ch = guild.channels.find((c) => c.id === channelId);
|
||||
if (ch) return { channel: ch, guild };
|
||||
}
|
||||
return { channel: null, guild: null };
|
||||
}
|
||||
|
||||
private async postMessage(
|
||||
channelId: string,
|
||||
message: unknown,
|
||||
threadId?: string,
|
||||
): Promise<{ ok: boolean; error?: string }> {
|
||||
const url = threadId
|
||||
? `${DISCORD_API}/channels/${threadId}/messages`
|
||||
: `${DISCORD_API}/channels/${channelId}/messages`;
|
||||
|
||||
for (let attempt = 0; attempt < 3; attempt++) {
|
||||
try {
|
||||
const res = await fetch(url, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: `Bot ${this.token}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify(message),
|
||||
});
|
||||
|
||||
if (res.status === 429) {
|
||||
const rateLimit = (await res.json()) as { retry_after?: number };
|
||||
const retryAfter = (rateLimit.retry_after ?? 1) * 1000;
|
||||
log.warn({ retryAfter, attempt }, "Rate limited");
|
||||
await new Promise((r) => setTimeout(r, retryAfter));
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!res.ok) {
|
||||
const err = await res.text();
|
||||
log.error({ status: res.status, err, channelId }, "Discord API error");
|
||||
return { ok: false, error: err };
|
||||
}
|
||||
|
||||
return { ok: true };
|
||||
} catch (err) {
|
||||
log.error({ err, attempt, channelId }, "Failed to send message");
|
||||
if (attempt === 2) return { ok: false, error: String(err) };
|
||||
await new Promise((r) => setTimeout(r, 1000 * (attempt + 1)));
|
||||
}
|
||||
}
|
||||
|
||||
return { ok: false, error: "Max retries exceeded" };
|
||||
}
|
||||
|
||||
async alarm(): Promise<void> {
|
||||
if (!this.socket || this.socket.readyState !== WebSocket.OPEN) {
|
||||
log.info("Alarm: restarting Gateway connection");
|
||||
this.connect();
|
||||
}
|
||||
await this.state.storage.setAlarm(Date.now() + ALARM_INTERVAL * 1000);
|
||||
}
|
||||
}
|
||||
84
src/discord.ts
Normal file
84
src/discord.ts
Normal file
|
|
@ -0,0 +1,84 @@
|
|||
import type { Config, FormattedMessage, WebhookEvent, Env } from "./types";
|
||||
import { formatEvent } from "./formatter";
|
||||
import { matchRoute } from "./webhook";
|
||||
import { log } from "./log";
|
||||
import { loadTranslations } from "./i18n";
|
||||
|
||||
async function getGatewayProxy(env: Env): Promise<DurableObjectStub> {
|
||||
const id = env.DISCORD_GATEWAY.idFromName("discord-gateway");
|
||||
return env.DISCORD_GATEWAY.get(id);
|
||||
}
|
||||
|
||||
export async function initGateway(env: Env): Promise<void> {
|
||||
if (!env.DISCORD_TOKEN) return;
|
||||
const stub = await getGatewayProxy(env);
|
||||
await stub.fetch(
|
||||
new Request("https://do.internal", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ action: "start", token: env.DISCORD_TOKEN }),
|
||||
}),
|
||||
);
|
||||
log.info("Discord Gateway DO started");
|
||||
}
|
||||
|
||||
export async function dispatchEvent(config: Config, event: WebhookEvent, env: Env): Promise<void> {
|
||||
for (const route of config.routes) {
|
||||
if (!matchRoute(route, event)) continue;
|
||||
|
||||
try {
|
||||
const tr = await loadTranslations(route.lang ?? "en", env.KV);
|
||||
const message = formatEvent(route, event, tr);
|
||||
await sendWithRetry(route.target.channelId, message, env, route.target.threadId);
|
||||
} catch (err) {
|
||||
log.error({ routeId: route.id, err }, "Route failed");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function sleep(ms: number): Promise<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
async function sendWithRetry(
|
||||
channelId: string,
|
||||
message: FormattedMessage,
|
||||
env: Env,
|
||||
threadId?: string,
|
||||
maxRetries = 3,
|
||||
): Promise<void> {
|
||||
for (let attempt = 0; attempt <= maxRetries; attempt++) {
|
||||
try {
|
||||
await sendToChannel(channelId, message, env, threadId);
|
||||
return;
|
||||
} catch (err: unknown) {
|
||||
const error = err as Record<string, unknown>;
|
||||
const isRateLimit = error.code === 50035 || error.status === 429;
|
||||
if (isRateLimit && attempt < maxRetries) {
|
||||
const retryAfter = ((error.retry_after as number) ?? (attempt + 1) * 2) as number;
|
||||
log.warn({ retryAfter, attempt, maxRetries }, "Rate limited, retrying");
|
||||
await sleep(retryAfter * 1000);
|
||||
continue;
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function sendToChannel(
|
||||
channelId: string,
|
||||
message: FormattedMessage,
|
||||
env: Env,
|
||||
threadId?: string,
|
||||
): Promise<void> {
|
||||
const stub = await getGatewayProxy(env);
|
||||
const res = await stub.fetch(
|
||||
new Request("https://do.internal", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ action: "send", channelId, message, threadId }),
|
||||
}),
|
||||
);
|
||||
const result = (await res.json()) as { ok: boolean; error?: string };
|
||||
if (!result.ok) {
|
||||
throw new Error(result.error ?? "Send failed");
|
||||
}
|
||||
}
|
||||
1446
src/formatter.ts
Normal file
1446
src/formatter.ts
Normal file
File diff suppressed because it is too large
Load diff
51
src/github-oauth.ts
Normal file
51
src/github-oauth.ts
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
import { Octokit } from "octokit";
|
||||
import { saveToken, getToken } from "./token-store";
|
||||
|
||||
export function getOAuthURL(clientId: string, state: string): string {
|
||||
return `https://github.com/login/oauth/authorize?client_id=${clientId}&scope=repo&state=${state}`;
|
||||
}
|
||||
|
||||
export async function handleOAuthCallback(
|
||||
clientId: string,
|
||||
clientSecret: string,
|
||||
code: string,
|
||||
_state: string,
|
||||
kv: KVNamespace,
|
||||
): Promise<{ userId: string; login: string } | null> {
|
||||
const res = await fetch("https://github.com/login/oauth/access_token", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
Accept: "application/vnd.github+json",
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
client_id: clientId,
|
||||
client_secret: clientSecret,
|
||||
code,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!res.ok) return null;
|
||||
|
||||
const data = (await res.json()) as {
|
||||
access_token?: string;
|
||||
token_type?: string;
|
||||
scope?: string;
|
||||
error?: string;
|
||||
};
|
||||
|
||||
if (!data.access_token) return null;
|
||||
|
||||
const octokit = new Octokit({ auth: data.access_token });
|
||||
const { data: user } = await octokit.rest.users.getAuthenticated();
|
||||
|
||||
await saveToken(kv, user.id.toString(), data.access_token, 3600 * 10);
|
||||
|
||||
return { userId: user.id.toString(), login: user.login };
|
||||
}
|
||||
|
||||
export async function getUserOctokit(userId: string, kv: KVNamespace): Promise<Octokit | null> {
|
||||
const token = await getToken(kv, userId);
|
||||
if (!token) return null;
|
||||
return new Octokit({ auth: token });
|
||||
}
|
||||
58
src/i18n.ts
Normal file
58
src/i18n.ts
Normal file
|
|
@ -0,0 +1,58 @@
|
|||
import { en } from "./locales/en";
|
||||
|
||||
type NestedStrings = { [key: string]: string | NestedStrings };
|
||||
export type Translations = NestedStrings;
|
||||
|
||||
function getByPath(obj: Record<string, unknown>, path: string): string | undefined {
|
||||
const parts = path.split(".");
|
||||
let current: unknown = obj;
|
||||
for (const part of parts) {
|
||||
if (current == null || typeof current !== "object") return undefined;
|
||||
current = (current as Record<string, unknown>)[part];
|
||||
}
|
||||
return typeof current === "string" ? current : undefined;
|
||||
}
|
||||
|
||||
function interpolate(template: string, params: Record<string, string | number>): string {
|
||||
return template.replace(/\{(\w+)\}/g, (_, key: string) => {
|
||||
return key in params ? String(params[key]) : `{${key}}`;
|
||||
});
|
||||
}
|
||||
|
||||
const cache = new Map<string, Translations>();
|
||||
|
||||
export async function loadTranslations(
|
||||
lang: string,
|
||||
kv?: { get<T>(key: string, type: "json"): Promise<T | null> },
|
||||
): Promise<Translations> {
|
||||
if (lang === "en") return en;
|
||||
|
||||
const cached = cache.get(lang);
|
||||
if (cached) return cached;
|
||||
|
||||
if (!kv) return en;
|
||||
|
||||
try {
|
||||
const stored = await kv.get<Partial<Translations>>(`i18n:${lang}`, "json");
|
||||
if (stored) {
|
||||
const merged = { ...en, ...stored } as Translations;
|
||||
cache.set(lang, merged);
|
||||
return merged;
|
||||
}
|
||||
} catch {
|
||||
// KV read failed, fall back to EN
|
||||
}
|
||||
|
||||
return en;
|
||||
}
|
||||
|
||||
export function t(
|
||||
key: string,
|
||||
params?: Record<string, string | number>,
|
||||
lang?: string | null,
|
||||
translations?: Translations,
|
||||
): string {
|
||||
const dict = translations ?? en;
|
||||
const raw = getByPath(dict as Record<string, unknown>, key) ?? getByPath(en as Record<string, unknown>, key) ?? key;
|
||||
return params ? interpolate(raw, params) : raw;
|
||||
}
|
||||
23
src/index.ts
Normal file
23
src/index.ts
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
import { createServer } from "./server";
|
||||
import { initGateway } from "./discord";
|
||||
import { DiscordGateway } from "./discord-gateway";
|
||||
import type { Env } from "./types";
|
||||
import { log } from "./log";
|
||||
|
||||
export { DiscordGateway };
|
||||
|
||||
const app = createServer();
|
||||
|
||||
export default {
|
||||
async fetch(request: Request, env: Env): Promise<Response> {
|
||||
return app.fetch(request, env);
|
||||
},
|
||||
|
||||
async scheduled(_event: ScheduledEvent, env: Env): Promise<void> {
|
||||
try {
|
||||
await initGateway(env);
|
||||
} catch (err) {
|
||||
log.error({ err }, "Gateway init from cron failed");
|
||||
}
|
||||
},
|
||||
};
|
||||
173
src/locales/en.ts
Normal file
173
src/locales/en.ts
Normal file
|
|
@ -0,0 +1,173 @@
|
|||
export const en = {
|
||||
actions: {
|
||||
opened: "Opened",
|
||||
closed: "Closed",
|
||||
reopened: "Reopened",
|
||||
synchronized: "Synchronized",
|
||||
edited: "Edited",
|
||||
labeled: "Labeled",
|
||||
unlabeled: "Unlabeled",
|
||||
assigned: "Assigned",
|
||||
unassigned: "Unassigned",
|
||||
converted_to_draft: "Converted to Draft",
|
||||
ready_for_review: "Ready for Review",
|
||||
completed: "Completed",
|
||||
published: "Published",
|
||||
created: "Created",
|
||||
deleted: "Deleted",
|
||||
started: "Started",
|
||||
added: "Added",
|
||||
removed: "Removed",
|
||||
submitted: "Submitted",
|
||||
dismissed: "Dismissed",
|
||||
approved: "Approved",
|
||||
changes_requested: "Changes Requested",
|
||||
answered: "Answered",
|
||||
unanswered: "Unanswered",
|
||||
pinned: "Pinned",
|
||||
unpinned: "Unpinned",
|
||||
transferred: "Transferred",
|
||||
locked: "Locked",
|
||||
unlocked: "Unlocked",
|
||||
renamed: "Renamed",
|
||||
archived: "Archived",
|
||||
unarchived: "Unarchived",
|
||||
fixed: "Fixed",
|
||||
appeared_in_branch: "Appeared in Branch",
|
||||
reopened_by_user: "Reopened by User",
|
||||
closed_by_user: "Closed by User",
|
||||
},
|
||||
fields: {
|
||||
branch: "Branch",
|
||||
changes: "Changes",
|
||||
labels: "Labels",
|
||||
assignees: "Assignees",
|
||||
milestone: "Milestone",
|
||||
status: "Status",
|
||||
run: "Run",
|
||||
duration: "Duration",
|
||||
type: "Type",
|
||||
name: "Name",
|
||||
description: "Description",
|
||||
file: "File",
|
||||
commit: "Commit",
|
||||
environment: "Environment",
|
||||
url: "URL",
|
||||
severity: "Severity",
|
||||
rule: "Rule",
|
||||
package: "Package",
|
||||
vulnerable_range: "Vulnerable Range",
|
||||
summary: "Summary",
|
||||
progress: "Progress",
|
||||
due: "Due",
|
||||
number: "Number",
|
||||
color: "Color",
|
||||
label: "Label",
|
||||
transferred: "Transferred",
|
||||
renamed: "Renamed",
|
||||
details: "Details",
|
||||
branch_tag: "Branch/Tag",
|
||||
},
|
||||
common: {
|
||||
footer: "{repo}",
|
||||
unknown: "unknown",
|
||||
no_message: "no message",
|
||||
repository: "repository",
|
||||
untitled: "Untitled",
|
||||
github: "GitHub",
|
||||
and_n_more: "... and {count} more commits",
|
||||
n_files: "{count} files",
|
||||
},
|
||||
events: {
|
||||
push: {
|
||||
force_push: "⚠️ **Force push**",
|
||||
branch_created: "🆕 Branch created",
|
||||
commits_pushed: "**{count}** commit{s} pushed to `{ref}`",
|
||||
view_comparison: "[View comparison]({url})",
|
||||
added: "+{count} added",
|
||||
removed: "-{count} removed",
|
||||
modified: "~{count} modified",
|
||||
title: "Pushed {count} commit{s} to {repo}",
|
||||
},
|
||||
pr: {
|
||||
action_pr: "{emoji} **{action}** pull request",
|
||||
title: "{repo}#{number}: {title}",
|
||||
},
|
||||
issues: {
|
||||
action_issue: "{emoji} **{action}** issue",
|
||||
title: "{repo}#{number}: {title}",
|
||||
},
|
||||
issue_comment: {
|
||||
comment_on: "Comment on {repo}#{number}: {title}",
|
||||
action_comment: "💬 **{action}** comment",
|
||||
},
|
||||
workflow_run: {
|
||||
title: "{name} — {conclusion}",
|
||||
},
|
||||
release: {
|
||||
action_release: "{emoji} **{action}** release `{tag}`",
|
||||
title: "{name} — {repo}",
|
||||
},
|
||||
create: {
|
||||
title: "{emoji} Created {type} `{ref}` in {repo}",
|
||||
},
|
||||
delete: {
|
||||
title: "{emoji} Deleted {type} `{ref}` in {repo}",
|
||||
},
|
||||
star: {
|
||||
starred: "⭐ starred",
|
||||
unstarred: "💫 unstarred",
|
||||
},
|
||||
fork: {
|
||||
title: "🍴 Forked {repo} to {forkee}",
|
||||
},
|
||||
check_run: {
|
||||
title: "{name} — {conclusion}",
|
||||
},
|
||||
pr_review: {
|
||||
action_review: "{emoji} **{action}** review",
|
||||
title: "Review on {repo}#{number}: {title}",
|
||||
},
|
||||
pr_review_comment: {
|
||||
action_inline: "💬 **{action}** inline comment",
|
||||
title: "Review comment on {repo}#{number}",
|
||||
line: " (line {position})",
|
||||
},
|
||||
commit_comment: {
|
||||
action_comment: "💬 **{action}**",
|
||||
title: "Comment on commit `{sha}` in {repo}",
|
||||
},
|
||||
deployment: {
|
||||
title: "Deployment to `{env}` — {state}",
|
||||
},
|
||||
member: {
|
||||
title: "{emoji} {action} collaborator: {name}",
|
||||
},
|
||||
label: {
|
||||
title: "{emoji} Label {action}: {name}",
|
||||
},
|
||||
milestone: {
|
||||
title: "{emoji} Milestone {action}: {title}",
|
||||
},
|
||||
discussion: {
|
||||
title: "{emoji} Discussion #{number}: {title}",
|
||||
action_discussion: "{action} discussion{category}",
|
||||
},
|
||||
discussion_comment: {
|
||||
comment_on: "Comment on Discussion #{number}: {title}",
|
||||
action_comment: "💬 **{action}** comment",
|
||||
},
|
||||
repository: {
|
||||
title: "📦 Repository {action}: {repo}",
|
||||
},
|
||||
code_scanning: {
|
||||
title: "🔍 Code Scanning: {action}",
|
||||
},
|
||||
dependabot: {
|
||||
title: "🛡️ Dependabot: {action}",
|
||||
},
|
||||
generic: {
|
||||
title: "{event}{action}",
|
||||
},
|
||||
},
|
||||
};
|
||||
173
src/locales/zh.ts
Normal file
173
src/locales/zh.ts
Normal file
|
|
@ -0,0 +1,173 @@
|
|||
export const zh = {
|
||||
actions: {
|
||||
opened: "已打开",
|
||||
closed: "已关闭",
|
||||
reopened: "重新打开",
|
||||
synchronized: "已同步",
|
||||
edited: "已编辑",
|
||||
labeled: "已添加标签",
|
||||
unlabeled: "已移除标签",
|
||||
assigned: "已分配",
|
||||
unassigned: "已取消分配",
|
||||
converted_to_draft: "已转为草稿",
|
||||
ready_for_review: "可供审查",
|
||||
completed: "已完成",
|
||||
published: "已发布",
|
||||
created: "已创建",
|
||||
deleted: "已删除",
|
||||
started: "已开始",
|
||||
added: "已添加",
|
||||
removed: "已移除",
|
||||
submitted: "已提交",
|
||||
dismissed: "已忽略",
|
||||
approved: "已批准",
|
||||
changes_requested: "请求修改",
|
||||
answered: "已回答",
|
||||
unanswered: "未回答",
|
||||
pinned: "已置顶",
|
||||
unpinned: "已取消置顶",
|
||||
transferred: "已转移",
|
||||
locked: "已锁定",
|
||||
unlocked: "已解锁",
|
||||
renamed: "已重命名",
|
||||
archived: "已归档",
|
||||
unarchived: "已取消归档",
|
||||
fixed: "已修复",
|
||||
appeared_in_branch: "出现在分支中",
|
||||
reopened_by_user: "用户重新打开",
|
||||
closed_by_user: "用户关闭",
|
||||
},
|
||||
fields: {
|
||||
branch: "分支",
|
||||
changes: "变更",
|
||||
labels: "标签",
|
||||
assignees: "指派人",
|
||||
milestone: "里程碑",
|
||||
status: "状态",
|
||||
run: "运行",
|
||||
duration: "耗时",
|
||||
type: "类型",
|
||||
name: "名称",
|
||||
description: "描述",
|
||||
file: "文件",
|
||||
commit: "提交",
|
||||
environment: "环境",
|
||||
url: "链接",
|
||||
severity: "严重程度",
|
||||
rule: "规则",
|
||||
package: "包",
|
||||
vulnerable_range: "受影响版本",
|
||||
summary: "摘要",
|
||||
progress: "进度",
|
||||
due: "截止日期",
|
||||
number: "编号",
|
||||
color: "颜色",
|
||||
label: "标签",
|
||||
transferred: "已转移",
|
||||
renamed: "已重命名",
|
||||
details: "详情",
|
||||
branch_tag: "分支/标签",
|
||||
},
|
||||
common: {
|
||||
footer: "{repo}",
|
||||
unknown: "未知",
|
||||
no_message: "无消息",
|
||||
repository: "仓库",
|
||||
untitled: "无标题",
|
||||
github: "GitHub",
|
||||
and_n_more: "... 还有 {count} 个提交",
|
||||
n_files: "{count} 个文件",
|
||||
},
|
||||
events: {
|
||||
push: {
|
||||
force_push: "⚠️ **强制推送**",
|
||||
branch_created: "🆕 分支已创建",
|
||||
commits_pushed: "**{count}** 个提交已推送到 `{ref}`",
|
||||
view_comparison: "[查看比较]({url})",
|
||||
added: "+{count} 新增",
|
||||
removed: "-{count} 删除",
|
||||
modified: "~{count} 修改",
|
||||
title: "推送了 {count} 个提交到 {repo}",
|
||||
},
|
||||
pr: {
|
||||
action_pr: "{emoji} **{action}** 拉取请求",
|
||||
title: "{repo}#{number}: {title}",
|
||||
},
|
||||
issues: {
|
||||
action_issue: "{emoji} **{action}** 议题",
|
||||
title: "{repo}#{number}: {title}",
|
||||
},
|
||||
issue_comment: {
|
||||
comment_on: "{repo}#{number} 的评论: {title}",
|
||||
action_comment: "💬 **{action}** 评论",
|
||||
},
|
||||
workflow_run: {
|
||||
title: "{name} — {conclusion}",
|
||||
},
|
||||
release: {
|
||||
action_release: "{emoji} **{action}** 发布 `{tag}`",
|
||||
title: "{name} — {repo}",
|
||||
},
|
||||
create: {
|
||||
title: "{emoji} 已创建{type} `{ref}` 于 {repo}",
|
||||
},
|
||||
delete: {
|
||||
title: "{emoji} 已删除{type} `{ref}` 于 {repo}",
|
||||
},
|
||||
star: {
|
||||
starred: "⭐ 已加星标",
|
||||
unstarred: "💫 已取消星标",
|
||||
},
|
||||
fork: {
|
||||
title: "🍴 已将 {repo} 复刻到 {forkee}",
|
||||
},
|
||||
check_run: {
|
||||
title: "{name} — {conclusion}",
|
||||
},
|
||||
pr_review: {
|
||||
action_review: "{emoji} **{action}** 审查",
|
||||
title: "{repo}#{number} 的审查: {title}",
|
||||
},
|
||||
pr_review_comment: {
|
||||
action_inline: "💬 **{action}** 行内评论",
|
||||
title: "{repo}#{number} 的审查评论",
|
||||
line: " (第 {position} 行)",
|
||||
},
|
||||
commit_comment: {
|
||||
action_comment: "💬 **{action}**",
|
||||
title: "{repo} 中提交 `{sha}` 的评论",
|
||||
},
|
||||
deployment: {
|
||||
title: "部署到 `{env}` — {state}",
|
||||
},
|
||||
member: {
|
||||
title: "{emoji} {action} 协作者: {name}",
|
||||
},
|
||||
label: {
|
||||
title: "{emoji} 标签 {action}: {name}",
|
||||
},
|
||||
milestone: {
|
||||
title: "{emoji} 里程碑 {action}: {title}",
|
||||
},
|
||||
discussion: {
|
||||
title: "{emoji} 讨论 #{number}: {title}",
|
||||
action_discussion: "{action} 讨论{category}",
|
||||
},
|
||||
discussion_comment: {
|
||||
comment_on: "讨论 #{number} 的评论: {title}",
|
||||
action_comment: "💬 **{action}** 评论",
|
||||
},
|
||||
repository: {
|
||||
title: "📦 仓库 {action}: {repo}",
|
||||
},
|
||||
code_scanning: {
|
||||
title: "🔍 代码扫描: {action}",
|
||||
},
|
||||
dependabot: {
|
||||
title: "🛡️ Dependabot: {action}",
|
||||
},
|
||||
generic: {
|
||||
title: "{event}{action}",
|
||||
},
|
||||
},
|
||||
};
|
||||
10
src/log.ts
Normal file
10
src/log.ts
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
export const log = {
|
||||
info: (msg: string | object, ...args: unknown[]): void =>
|
||||
console.log(JSON.stringify({ level: "info", msg, ...args })),
|
||||
warn: (msg: string | object, ...args: unknown[]): void =>
|
||||
console.warn(JSON.stringify({ level: "warn", msg, ...args })),
|
||||
error: (msg: string | object, ...args: unknown[]): void =>
|
||||
console.error(JSON.stringify({ level: "error", msg, ...args })),
|
||||
fatal: (msg: string | object, ...args: unknown[]): void =>
|
||||
console.error(JSON.stringify({ level: "fatal", msg, ...args })),
|
||||
};
|
||||
82
src/oauth-routes.ts
Normal file
82
src/oauth-routes.ts
Normal file
|
|
@ -0,0 +1,82 @@
|
|||
import { Hono } from "hono";
|
||||
import { getOAuthURL, handleOAuthCallback } from "./github-oauth";
|
||||
import { removeToken } from "./token-store";
|
||||
import type { Env } from "./types";
|
||||
|
||||
interface PendingState {
|
||||
redirectTo: string;
|
||||
expiresAt: number;
|
||||
}
|
||||
|
||||
function generateRandomHex(length: number): string {
|
||||
const bytes = new Uint8Array(length);
|
||||
crypto.getRandomValues(bytes);
|
||||
return Array.from(bytes)
|
||||
.map((b) => b.toString(16).padStart(2, "0"))
|
||||
.join("");
|
||||
}
|
||||
|
||||
export function createOAuthRoutes(): Hono<{ Bindings: Env }> {
|
||||
const app = new Hono<{ Bindings: Env }>();
|
||||
|
||||
app.get("/github", async (c) => {
|
||||
const redirectTo = c.req.query("redirect") ?? "/";
|
||||
const state = generateRandomHex(16);
|
||||
|
||||
const pending: PendingState = {
|
||||
redirectTo,
|
||||
expiresAt: Date.now() + 10 * 60 * 1000,
|
||||
};
|
||||
await c.env.KV.put(`state:${state}`, JSON.stringify(pending), {
|
||||
expirationTtl: 600,
|
||||
});
|
||||
|
||||
const url = getOAuthURL(c.env.GITHUB_CLIENT_ID ?? "", state);
|
||||
return c.redirect(url);
|
||||
});
|
||||
|
||||
app.get("/github/callback", async (c) => {
|
||||
const code = c.req.query("code");
|
||||
const state = c.req.query("state");
|
||||
|
||||
if (!code || !state) {
|
||||
return c.json({ error: "Missing code or state" }, 400);
|
||||
}
|
||||
|
||||
const raw = await c.env.KV.get(`state:${state}`, "json");
|
||||
if (!raw) {
|
||||
return c.json({ error: "Invalid or expired state" }, 400);
|
||||
}
|
||||
const pending = raw as PendingState;
|
||||
if (Date.now() > pending.expiresAt) {
|
||||
await c.env.KV.delete(`state:${state}`);
|
||||
return c.json({ error: "Invalid or expired state" }, 400);
|
||||
}
|
||||
await c.env.KV.delete(`state:${state}`);
|
||||
|
||||
const result = await handleOAuthCallback(
|
||||
c.env.GITHUB_CLIENT_ID ?? "",
|
||||
c.env.GITHUB_CLIENT_SECRET ?? "",
|
||||
code,
|
||||
state,
|
||||
c.env.KV,
|
||||
);
|
||||
|
||||
if (!result) {
|
||||
return c.json({ error: "OAuth failed" }, 400);
|
||||
}
|
||||
|
||||
return c.json({
|
||||
userId: result.userId,
|
||||
login: result.login,
|
||||
redirectTo: pending.redirectTo,
|
||||
});
|
||||
});
|
||||
|
||||
app.delete("/token/:userId", async (c) => {
|
||||
await removeToken(c.env.KV, c.req.param("userId"));
|
||||
return c.json({ ok: true });
|
||||
});
|
||||
|
||||
return app;
|
||||
}
|
||||
54
src/server.ts
Normal file
54
src/server.ts
Normal file
|
|
@ -0,0 +1,54 @@
|
|||
import { Hono } from "hono";
|
||||
import type { Env } from "./types";
|
||||
import { verifySignature, parseEvent } from "./webhook";
|
||||
import { dispatchEvent } from "./discord";
|
||||
import { createOAuthRoutes } from "./oauth-routes";
|
||||
import { createActionRoutes } from "./action-routes";
|
||||
import { log } from "./log";
|
||||
|
||||
const MAX_BODY_SIZE = 1024 * 1024;
|
||||
|
||||
export function createServer(): Hono<{ Bindings: Env }> {
|
||||
const app = new Hono<{ Bindings: Env }>();
|
||||
|
||||
app.get("/health", (c) => c.json({ status: "ok" }));
|
||||
|
||||
app.route("/auth", createOAuthRoutes());
|
||||
app.route("/", createActionRoutes());
|
||||
|
||||
app.post("/webhook", async (c) => {
|
||||
const contentLength = Number(c.req.header("content-length") ?? 0);
|
||||
if (contentLength > MAX_BODY_SIZE) {
|
||||
return c.json({ error: "Request too large" }, 413);
|
||||
}
|
||||
|
||||
const body = await c.req.text();
|
||||
if (body.length > MAX_BODY_SIZE) {
|
||||
return c.json({ error: "Request too large" }, 413);
|
||||
}
|
||||
|
||||
const headers: Record<string, string> = {};
|
||||
c.req.raw.headers.forEach((value, key) => {
|
||||
headers[key] = value;
|
||||
});
|
||||
|
||||
if (
|
||||
!(await verifySignature(body, headers["x-hub-signature-256"], c.env.GITHUB_WEBHOOK_SECRET))
|
||||
) {
|
||||
return c.json({ error: "Invalid signature" }, 401);
|
||||
}
|
||||
|
||||
const event = parseEvent(headers, body);
|
||||
if (!event) {
|
||||
return c.json({ error: "Invalid event" }, 400);
|
||||
}
|
||||
|
||||
const { loadConfig } = await import("./config");
|
||||
const config = await loadConfig(c.env);
|
||||
dispatchEvent(config, event, c.env).catch((err) => log.error(err, "Dispatch failed"));
|
||||
|
||||
return c.json({ ok: true });
|
||||
});
|
||||
|
||||
return app;
|
||||
}
|
||||
62
src/token-store.ts
Normal file
62
src/token-store.ts
Normal file
|
|
@ -0,0 +1,62 @@
|
|||
interface StoredToken {
|
||||
userId: string;
|
||||
accessToken: string;
|
||||
expiresAt: number;
|
||||
refreshToken?: string;
|
||||
}
|
||||
|
||||
export async function saveToken(
|
||||
kv: KVNamespace,
|
||||
userId: string,
|
||||
accessToken: string,
|
||||
expiresInSeconds: number,
|
||||
refreshToken?: string,
|
||||
): Promise<void> {
|
||||
const token: StoredToken = {
|
||||
userId,
|
||||
accessToken,
|
||||
expiresAt: Date.now() + expiresInSeconds * 1000,
|
||||
refreshToken,
|
||||
};
|
||||
const ttl = Math.max(Math.floor(expiresInSeconds * 0.9), 60);
|
||||
await kv.put(`token:${userId}`, JSON.stringify(token), { expirationTtl: ttl });
|
||||
}
|
||||
|
||||
export async function getToken(kv: KVNamespace, userId: string): Promise<string | null> {
|
||||
const raw = await kv.get(`token:${userId}`, "json");
|
||||
if (!raw) return null;
|
||||
const t = raw as StoredToken;
|
||||
if (Date.now() >= t.expiresAt) {
|
||||
await kv.delete(`token:${userId}`);
|
||||
return null;
|
||||
}
|
||||
return t.accessToken;
|
||||
}
|
||||
|
||||
export async function getRefreshToken(kv: KVNamespace, userId: string): Promise<string | null> {
|
||||
const raw = await kv.get(`token:${userId}`, "json");
|
||||
if (!raw) return null;
|
||||
return (raw as StoredToken).refreshToken ?? null;
|
||||
}
|
||||
|
||||
export async function removeToken(kv: KVNamespace, userId: string): Promise<void> {
|
||||
await kv.delete(`token:${userId}`);
|
||||
}
|
||||
|
||||
export async function findUserIdByToken(
|
||||
kv: KVNamespace,
|
||||
accessToken: string,
|
||||
): Promise<string | null> {
|
||||
const list = await kv.list({ prefix: "token:" });
|
||||
for (const key of list.keys) {
|
||||
const raw = await kv.get(key.name, "json");
|
||||
if (!raw) continue;
|
||||
const t = raw as StoredToken;
|
||||
if (Date.now() >= t.expiresAt) {
|
||||
await kv.delete(key.name);
|
||||
continue;
|
||||
}
|
||||
if (t.accessToken === accessToken) return t.userId;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
69
src/types.ts
Normal file
69
src/types.ts
Normal file
|
|
@ -0,0 +1,69 @@
|
|||
export interface Env {
|
||||
GITHUB_WEBHOOK_SECRET: string;
|
||||
GITHUB_APP_ID?: string;
|
||||
GITHUB_PRIVATE_KEY?: string;
|
||||
GITHUB_CLIENT_ID?: string;
|
||||
GITHUB_CLIENT_SECRET?: string;
|
||||
DISCORD_TOKEN?: string;
|
||||
DISCORD_CHANNEL_ID?: string;
|
||||
BASE_URL?: string;
|
||||
KV: KVNamespace;
|
||||
DISCORD_GATEWAY: DurableObjectNamespace;
|
||||
}
|
||||
|
||||
export interface Config {
|
||||
baseUrl: string;
|
||||
github: {
|
||||
webhookSecret: string;
|
||||
appId: number;
|
||||
privateKey: string;
|
||||
clientId: string;
|
||||
clientSecret: string;
|
||||
};
|
||||
discord: {
|
||||
token: string;
|
||||
defaultChannelId: string;
|
||||
};
|
||||
routes: Route[];
|
||||
}
|
||||
|
||||
export interface Route {
|
||||
id: string;
|
||||
name: string;
|
||||
enabled: boolean;
|
||||
filters: Filter[];
|
||||
target: {
|
||||
channelId: string;
|
||||
threadId?: string;
|
||||
};
|
||||
lang?: string;
|
||||
}
|
||||
|
||||
export interface Filter {
|
||||
type: "event" | "repo" | "actor" | "action" | "branch" | "keyword";
|
||||
match: string | string[];
|
||||
exclude?: boolean;
|
||||
}
|
||||
|
||||
export interface WebhookEvent {
|
||||
event: string;
|
||||
payload: Record<string, unknown>;
|
||||
signature?: string;
|
||||
}
|
||||
|
||||
export interface FormattedMessage {
|
||||
embeds?: Array<{
|
||||
title?: string;
|
||||
description?: string;
|
||||
url?: string;
|
||||
color?: number;
|
||||
author?: {
|
||||
name: string;
|
||||
icon_url?: string;
|
||||
url?: string;
|
||||
};
|
||||
fields?: Array<{ name: string; value: string; inline?: boolean }>;
|
||||
footer?: { text: string };
|
||||
timestamp?: string;
|
||||
}>;
|
||||
}
|
||||
127
src/webhook.ts
Normal file
127
src/webhook.ts
Normal file
|
|
@ -0,0 +1,127 @@
|
|||
import type { WebhookEvent, Route, Filter } from "./types";
|
||||
|
||||
export async function verifySignature(
|
||||
payload: string,
|
||||
signature: string | undefined,
|
||||
secret: string,
|
||||
): Promise<boolean> {
|
||||
if (!signature) return false;
|
||||
|
||||
const encoder = new TextEncoder();
|
||||
const key = await crypto.subtle.importKey(
|
||||
"raw",
|
||||
encoder.encode(secret),
|
||||
{ name: "HMAC", hash: "SHA-256" },
|
||||
false,
|
||||
["sign"],
|
||||
);
|
||||
const sig = await crypto.subtle.sign("HMAC", key, encoder.encode(payload));
|
||||
const expected = `sha256=${Array.from(new Uint8Array(sig))
|
||||
.map((b) => b.toString(16).padStart(2, "0"))
|
||||
.join("")}`;
|
||||
|
||||
try {
|
||||
const a = encoder.encode(signature);
|
||||
const b = encoder.encode(expected);
|
||||
if (a.byteLength !== b.byteLength) return false;
|
||||
let diff = 0;
|
||||
for (let i = 0; i < a.byteLength; i++) {
|
||||
diff |= a[i]! ^ b[i]!;
|
||||
}
|
||||
return diff === 0;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export function parseEvent(headers: Record<string, string>, body: string): WebhookEvent | null {
|
||||
const event = headers["x-github-event"];
|
||||
const signature = headers["x-hub-signature-256"];
|
||||
|
||||
if (!event) return null;
|
||||
|
||||
try {
|
||||
const payload = JSON.parse(body);
|
||||
return { event, payload, signature };
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function extractBranch(event: WebhookEvent): string | undefined {
|
||||
if (event.event === "push") {
|
||||
return (event.payload.ref as string)?.replace("refs/heads/", "");
|
||||
}
|
||||
if (
|
||||
event.event === "pull_request" ||
|
||||
event.event === "pull_request_review" ||
|
||||
event.event === "pull_request_review_comment"
|
||||
) {
|
||||
const pr = event.payload.pull_request as { head?: { ref?: string } } | undefined;
|
||||
return pr?.head?.ref;
|
||||
}
|
||||
if (event.event === "create" || event.event === "delete") {
|
||||
return event.payload.ref as string | undefined;
|
||||
}
|
||||
if (event.event === "workflow_run") {
|
||||
const wf = event.payload.workflow_run as { head_branch?: string } | undefined;
|
||||
return wf?.head_branch;
|
||||
}
|
||||
if (event.event === "commit_comment") {
|
||||
const comment = event.payload.comment as { position?: number | null } | undefined;
|
||||
if (comment?.position != null) {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
if (event.event === "code_scanning_alert") {
|
||||
return event.payload.ref as string | undefined;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function matchFilter(filter: Filter, event: WebhookEvent): boolean {
|
||||
let value: string | undefined;
|
||||
|
||||
switch (filter.type) {
|
||||
case "event":
|
||||
value = event.event;
|
||||
break;
|
||||
case "repo":
|
||||
value = (event.payload.repository as { full_name?: string })?.full_name;
|
||||
break;
|
||||
case "actor":
|
||||
value = (event.payload.sender as { login?: string })?.login;
|
||||
break;
|
||||
case "action":
|
||||
value = event.payload.action as string;
|
||||
break;
|
||||
case "branch":
|
||||
value = extractBranch(event);
|
||||
break;
|
||||
case "keyword": {
|
||||
const body = JSON.stringify(event.payload).toLowerCase();
|
||||
const patterns = Array.isArray(filter.match) ? filter.match : [filter.match];
|
||||
return patterns.some((p) => {
|
||||
try {
|
||||
return new RegExp(p, "i").test(body);
|
||||
} catch {
|
||||
return body.includes(p.toLowerCase());
|
||||
}
|
||||
});
|
||||
}
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!value) return false;
|
||||
|
||||
const patterns = Array.isArray(filter.match) ? filter.match : [filter.match];
|
||||
const matches = patterns.some((p) => value!.toLowerCase() === p.toLowerCase());
|
||||
|
||||
return filter.exclude ? !matches : matches;
|
||||
}
|
||||
|
||||
export function matchRoute(route: Route, event: WebhookEvent): boolean {
|
||||
if (!route.enabled) return false;
|
||||
return route.filters.every((f) => matchFilter(f, event));
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue