initial commit

This commit is contained in:
RhenCloud 2026-07-24 21:48:50 +08:00
commit 512d4b01d5
No known key found for this signature in database
GPG key ID: A574A617378C4E0B
55 changed files with 6430 additions and 0 deletions

View 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");
});
});

View 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
View 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
View 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
View 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
View 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

File diff suppressed because it is too large Load diff

51
src/github-oauth.ts Normal file
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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));
}