feat: migrate to Nuxt 4 (Nitro) and Tailwind CSS v3

This commit is contained in:
RhenCloud 2026-08-13 17:43:33 +08:00
parent f4959eebf8
commit b139712a91
No known key found for this signature in database
GPG key ID: A574A617378C4E0B
166 changed files with 19790 additions and 5539 deletions

39
server/lib/cf.ts Normal file
View file

@ -0,0 +1,39 @@
import type { H3Event } from "h3";
import type { Env } from "./types";
/**
* Cloudflare bindings (KV, D1, secrets) from the request context. Works on
* Cloudflare Workers (fetch and scheduled) and in tests that stub
* `event.context.cloudflare`. Throws with a clear message in other runtimes.
*/
export function cfEnv(event: H3Event): Env {
const env = (event.context.cloudflare as { env?: Env } | undefined)?.env;
if (!env) {
throw new Error("Cloudflare bindings unavailable — run via wrangler dev/deploy");
}
return env;
}
/** `waitUntil` from the CF execution context (no-op outside workers). */
export function cfWaitUntil(event: H3Event): (promise: Promise<unknown>) => void {
const cloudflare = event.context.cloudflare as
| {
ctx?: { waitUntil?: (p: Promise<unknown>) => void };
context?: { waitUntil?: (p: Promise<unknown>) => void };
}
| undefined;
const waitUntil =
(event.context.waitUntil as ((p: Promise<unknown>) => void) | undefined) ??
cloudflare?.context?.waitUntil ??
cloudflare?.ctx?.waitUntil;
return waitUntil ? waitUntil.bind(event.context) : () => undefined;
}
/** Lowercased request headers (the provider detection reads lowercase keys). */
export function headersFrom(event: H3Event): Record<string, string> {
const out: Record<string, string> = {};
event.headers.forEach((value, key) => {
out[key.toLowerCase()] = value;
});
return out;
}

65
server/lib/config.ts Normal file
View file

@ -0,0 +1,65 @@
import type { Env, Config, Route } from "./types";
import { log } from "./lib/log";
const CONFIG_CACHE_TTL = 60_000;
const ROUTES_KEY = "config:routes";
let configCache: { config: Config; expiresAt: number } | null = null;
export async function loadRoutes(kv: KVNamespace): Promise<Route[]> {
try {
const stored = await kv.get<Route[]>(ROUTES_KEY, "json");
if (stored) return normalizeRoutes(stored);
} catch (err) {
log.warn({ err }, "Failed to load routes from KV");
}
return [];
}
/**
* Migrate legacy single-target routes (`target`) to the array form (`targets`).
*/
function normalizeRoutes(routes: Route[]): Route[] {
return routes.map((r) => {
if (r.targets && r.targets.length > 0) return r;
const legacy = (r as Route & { target?: Route["targets"][number] }).target;
if (!legacy) return r;
const { target: _target, ...rest } = r as Route & { target?: Route["targets"][number] };
return { ...rest, targets: [legacy] };
});
}
export async function saveRoutes(kv: KVNamespace, routes: Route[]): Promise<void> {
await kv.put(ROUTES_KEY, JSON.stringify(routes));
configCache = null;
}
/** Drop the in-memory route/config cache (used by the admin API and tests). */
export function invalidateConfigCache(): void {
configCache = null;
}
export async function loadConfig(env: Env): Promise<Config> {
if (configCache && Date.now() < configCache.expiresAt) {
return configCache.config;
}
const routes = await loadRoutes(env.KV);
const config: Config = {
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 ?? "",
},
routes,
};
configCache = { config, expiresAt: Date.now() + CONFIG_CACHE_TTL };
return config;
}

281
server/lib/core/dispatch.ts Normal file
View file

@ -0,0 +1,281 @@
import type { Config, WebhookEvent, Env, Route, NeutralMessage } from "../types";
import { formatEvent } from "../formatters";
import { matchRoute, eventOwners } from "../events/match";
import { log } from "../lib/log";
import { loadTranslations, t as translate, type Translations } from "../lib/i18n";
import { recordSend } from "../lib/send-log";
import {
loadGroups,
groupAcceptsOwners,
groupAcceptsProvider,
groupAcceptsInstallation,
} from "../web/groups";
import { getDriver } from "../drivers";
import type { SendResult } from "../drivers/types";
/** One dispatch attempt (route × target), collected for the group webhook log. */
interface DispatchAttempt {
groupId?: string;
routeId: string;
routeName: string;
target: string;
ok: boolean;
error?: string;
}
export async function dispatchEvent(config: Config, event: WebhookEvent, env: Env): Promise<void> {
const groups = await loadGroups(env.KV);
const groupById = new Map(groups.map((g) => [g.id, g]));
// Message language is configured per group (Group.lang), not per route.
const langs = [...new Set(groups.map((g) => g.lang ?? "en"))];
const trMap = new Map<string, Translations>();
await Promise.all(
langs.map(async (lang) => {
trMap.set(lang, await loadTranslations(lang, env.KV));
}),
);
const owners = eventOwners(event);
const accepted = (route: Route): boolean => {
if (!route.groupId) return true;
const group = groupById.get(route.groupId);
if (!group) return true;
if (!groupAcceptsInstallation(group, event.installationId)) return false;
if (!groupAcceptsOwners(group, owners)) return false;
return groupAcceptsProvider(group, event.provider);
};
const matched = config.routes.filter(
(route) => !route.fallback && matchRoute(route, event) && accepted(route),
);
const anyRegularMatched = matched.length > 0;
const attempts: DispatchAttempt[] = [];
const tasks: Promise<void>[] = [];
for (const route of config.routes) {
if (!accepted(route)) continue;
if (route.fallback) {
if (!anyRegularMatched && matchRoute(route, event)) {
tasks.push(processRoute(route));
}
continue;
}
if (matchRoute(route, event)) {
tasks.push(processRoute(route));
if (route.stop) break;
}
}
await Promise.allSettled(tasks);
await sendGroupLogs(attempts);
async function sendGroupLogs(list: DispatchAttempt[]): Promise<void> {
const byGroup = new Map<string, DispatchAttempt[]>();
for (const a of list) {
if (!a.groupId) continue;
const bucket = byGroup.get(a.groupId);
if (bucket) bucket.push(a);
else byGroup.set(a.groupId, [a]);
}
for (const [groupId, entries] of byGroup) {
const group = groupById.get(groupId);
const target = group?.logTarget;
if (!group || !target) continue;
const tr = trMap.get(group.lang ?? "en")!;
try {
const allOk = entries.every((a) => a.ok);
const routeLines = entries
.slice(0, 10)
.map((a) =>
a.ok
? translate("log.route_ok", { route: a.routeName, target: a.target }, undefined, tr)
: translate(
"log.route_fail",
{ route: a.routeName, target: a.target, error: a.error ?? "?" },
undefined,
tr,
),
);
if (entries.length > 10) routeLines.push(`… +${entries.length - 10}`);
const message: NeutralMessage = {
title: translate(
"log.title",
{
repo:
(event.payload.repository as { full_name?: string } | undefined)?.full_name ?? "-",
event: event.event,
action: event.payload.action ? `: ${String(event.payload.action)}` : "",
},
undefined,
tr,
),
color: allOk ? 0x3fb950 : 0xf85149,
fields: [
{
name: translate("log.routes", {}, undefined, tr),
value: routeLines.join("\n"),
inline: false,
},
{
name: translate("log.delivery", {}, undefined, tr),
value: event.deliveryId ?? "-",
inline: true,
},
],
timestamp: new Date().toISOString(),
};
const result = await getDriver(target).send(message, target, env);
if (!result.ok) {
log.warn({ groupId, error: result.error }, "Failed to send group webhook log");
}
} catch (err) {
log.error({ groupId, err }, "Group webhook log send failed");
}
}
}
async function processRoute(route: Route): Promise<void> {
const targets = route.targets && route.targets.length > 0 ? route.targets : [];
if (targets.length === 0) return;
const group = route.groupId ? groupById.get(route.groupId) : undefined;
const tr = trMap.get(group?.lang ?? "en")!;
const showEmoji = group?.emoji !== false;
const message = formatEvent(route, event, tr, showEmoji);
if (route.discordRoleIds?.length) {
message.mentionRoleIds = route.discordRoleIds;
}
for (const target of targets) {
const targetStr =
target.platform === "telegram"
? target.topicId
? `${target.chatId}/${target.topicId}`
: (target.chatId ?? "")
: target.threadId
? `${target.channelId}/${target.threadId}`
: (target.channelId ?? "");
const base: {
ts: number;
routeId: string;
groupId: string | undefined;
event: string;
repo: string | undefined;
target: string;
deliveryId: string | undefined;
actor: string | undefined;
action: string | undefined;
} = {
ts: Date.now(),
routeId: route.id,
groupId: route.groupId,
event: event.event,
repo: (event.payload.repository as { full_name?: string } | undefined)?.full_name,
target: targetStr,
deliveryId: event.deliveryId,
actor: (event.payload.sender as { login?: string } | undefined)?.login,
action: event.payload.action as string | undefined,
};
const started = Date.now();
try {
const driver = getDriver(target);
let result: SendResult;
if (message.updateKey) {
const groupPrefix = route.groupId ? `${route.groupId}:` : "";
const kvKey = `msg:${groupPrefix}${route.id}:${message.updateKey}:${targetStr}`;
const existingId = await env.KV.get(kvKey);
if (existingId) {
result = await driver.edit(message, target, env, existingId);
if (result.ok) {
attempts.push({
groupId: route.groupId,
routeId: route.id,
routeName: route.name,
target: targetStr,
ok: true,
});
await recordSend(env.DB, {
...base,
ok: true,
status: result.status,
messageId: existingId,
platform: driver.id,
attempts: result.attempts,
durationMs: Date.now() - started,
errorCode: result.errorCode,
});
continue;
}
if (/not modified/i.test(result.error ?? "")) {
attempts.push({
groupId: route.groupId,
routeId: route.id,
routeName: route.name,
target: targetStr,
ok: true,
});
await recordSend(env.DB, {
...base,
ok: true,
status: result.status,
messageId: existingId,
platform: driver.id,
attempts: result.attempts,
durationMs: Date.now() - started,
errorCode: result.errorCode,
});
continue;
}
await env.KV.delete(kvKey);
}
result = await driver.send(message, target, env);
if (result.ok && result.messageId) {
await env.KV.put(kvKey, result.messageId, { expirationTtl: 604800 });
}
} else {
result = await driver.send(message, target, env);
}
const durationMs = Date.now() - started;
if (!result.ok) throw new Error(result.error ?? "Send failed");
attempts.push({
groupId: route.groupId,
routeId: route.id,
routeName: route.name,
target: targetStr,
ok: true,
});
await recordSend(env.DB, {
...base,
ok: true,
status: result.status,
messageId: result.messageId,
platform: driver.id,
attempts: result.attempts,
durationMs,
errorCode: result.errorCode,
});
} catch (err) {
const durationMs = Date.now() - started;
const error = err instanceof Error ? err.message : String(err);
attempts.push({
groupId: route.groupId,
routeId: route.id,
routeName: route.name,
target: targetStr,
ok: false,
error,
});
await recordSend(env.DB, {
...base,
ok: false,
error,
durationMs,
});
log.error({ routeId: route.id, target: targetStr, err }, "Route failed");
}
}
}
}

View file

@ -0,0 +1,174 @@
import { log } from "../../lib/log";
import type { Env } from "../../types";
const DISCORD_API = "https://discord.com/api/v10";
const COMMAND_TYPE = { CHAT_INPUT: 1, MESSAGE: 3 } as const;
const OPTION_TYPE = { SUB_COMMAND: 1, SUB_COMMAND_GROUP: 2, STRING: 3 } as const;
export const MSG_CMD_ADD = "GitHub: 添加评论";
export const MSG_CMD_EDIT = "GitHub: 编辑评论";
export const MSG_CMD_DEL = "GitHub: 删除评论";
export const APP_COMMANDS = [
{
name: "gh",
type: COMMAND_TYPE.CHAT_INPUT,
description: "GitHub 集成",
options: [
{
type: OPTION_TYPE.SUB_COMMAND,
name: "login",
description: "绑定你的 GitHub 账号以用本人身份评论",
},
{ type: OPTION_TYPE.SUB_COMMAND, name: "logout", description: "解绑你的 GitHub 账号" },
{
type: OPTION_TYPE.SUB_COMMAND_GROUP,
name: "comment",
description: "对 issue/PR 评论进行增删改",
options: [
{
type: OPTION_TYPE.SUB_COMMAND,
name: "add",
description: "在 issue/PR 下新增评论",
options: [
{
type: OPTION_TYPE.STRING,
name: "link",
description: "issue/PR 链接",
required: true,
},
],
},
{
type: OPTION_TYPE.SUB_COMMAND,
name: "edit",
description: "编辑一条评论",
options: [
{
type: OPTION_TYPE.STRING,
name: "link",
description: "评论链接(含 #issuecomment-",
required: true,
},
],
},
{
type: OPTION_TYPE.SUB_COMMAND,
name: "del",
description: "删除一条评论",
options: [
{
type: OPTION_TYPE.STRING,
name: "link",
description: "评论链接(含 #issuecomment-",
required: true,
},
],
},
],
},
],
},
{ name: MSG_CMD_ADD, type: COMMAND_TYPE.MESSAGE },
{ name: MSG_CMD_EDIT, type: COMMAND_TYPE.MESSAGE },
{ name: MSG_CMD_DEL, type: COMMAND_TYPE.MESSAGE },
];
export async function getApplicationId(env: Env): Promise<string | null> {
if (env.DISCORD_APPLICATION_ID) return env.DISCORD_APPLICATION_ID;
try {
const cached = await env.KV.get("config:discord-app-id");
if (cached) return cached;
} catch {
// fall through to the API
}
const token = env.DISCORD_TOKEN ?? "";
if (!token) return null;
const res = await fetch(`${DISCORD_API}/oauth2/applications/@me`, {
headers: { Authorization: `Bot ${token}` },
});
if (!res.ok) {
log.warn({ status: res.status }, "Failed to fetch Discord application id");
return null;
}
const app = (await res.json()) as { id?: string };
if (app.id) {
try {
await env.KV.put("config:discord-app-id", app.id);
} catch {
// cache is best-effort
}
return app.id;
}
return null;
}
export async function registerGlobalCommands(env: Env): Promise<void> {
const token = env.DISCORD_TOKEN ?? "";
if (!token) return;
try {
if (await env.KV.get("cmd:registered:global")) return;
} catch {
// fall through and register
}
const appId = await getApplicationId(env);
if (!appId) return;
const res = await fetch(`${DISCORD_API}/applications/${appId}/commands`, {
method: "PUT",
headers: { Authorization: `Bot ${token}`, "Content-Type": "application/json" },
body: JSON.stringify(APP_COMMANDS),
});
if (res.ok) {
try {
await env.KV.put("cmd:registered:global", "1", { expirationTtl: 86400 });
} catch {
// best-effort
}
log.info("Registered global application commands");
} else {
const err = await res.text();
log.warn({ status: res.status, err }, "Global command registration failed");
}
}
export async function syncGuildCommands(env: Env): Promise<void> {
const token = env.DISCORD_TOKEN ?? "";
if (!token) return;
const appId = await getApplicationId(env);
if (!appId) return;
const res = await fetch(`${DISCORD_API}/users/@me/guilds`, {
headers: { Authorization: `Bot ${token}` },
});
if (!res.ok) {
const err = await res.text();
log.warn({ status: res.status, err }, "Failed to list guilds");
return;
}
const guilds = (await res.json()) as Array<{ id: string }>;
for (const guild of guilds) {
try {
if (await env.KV.get(`cmd:guild:${guild.id}`)) continue;
const r = await fetch(`${DISCORD_API}/applications/${appId}/guilds/${guild.id}/commands`, {
method: "PUT",
headers: { Authorization: `Bot ${token}`, "Content-Type": "application/json" },
body: JSON.stringify(APP_COMMANDS),
});
if (r.ok) {
await env.KV.put(`cmd:guild:${guild.id}`, "1");
log.info({ guildId: guild.id }, "Registered guild application commands");
} else {
const err = await r.text();
log.warn({ guildId: guild.id, status: r.status, err }, "Command registration failed");
}
} catch (err) {
log.warn({ guildId: guild.id, err: String(err) }, "Command registration failed");
}
}
}
export async function syncCommands(env: Env): Promise<void> {
if (!env.DISCORD_TOKEN) return;
await registerGlobalCommands(env);
await syncGuildCommands(env);
}

View file

@ -0,0 +1,31 @@
import type { RouteTarget, Env, NeutralMessage } from "../../types";
import type { PlatformDriver, SendResult } from "../types";
import { sendMessage, editMessage } from "./rest";
import { renderNeutralMessage } from "./render";
export class DiscordDriver implements PlatformDriver {
readonly id = "discord";
async send(message: NeutralMessage, target: RouteTarget, env: Env): Promise<SendResult> {
const channelId = target.channelId ?? "";
if (!channelId) {
return { ok: false, error: "target.channelId is required", errorCode: "NO_TARGET" };
}
const token = env.DISCORD_TOKEN ?? "";
return sendMessage(token, channelId, renderNeutralMessage(message), target.threadId);
}
async edit(
message: NeutralMessage,
target: RouteTarget,
env: Env,
messageId: string,
): Promise<SendResult> {
const channelId = target.channelId ?? "";
if (!channelId) {
return { ok: false, error: "target.channelId is required", errorCode: "NO_TARGET" };
}
const token = env.DISCORD_TOKEN ?? "";
return editMessage(token, channelId, messageId, renderNeutralMessage(message), target.threadId);
}
}

View file

@ -0,0 +1,534 @@
import { log } from "../../lib/log";
import {
getOAuthURL,
commentAsUser,
getCommentAsUser,
editCommentAsUser,
deleteCommentAsUser,
mergePullRequestAsUser,
closePullRequestAsUser,
} from "../../github/oauth";
import { getDiscordLink, removeDiscordLink } from "../../github/store";
import type { Env } from "../../types";
import { MSG_CMD_ADD, MSG_CMD_EDIT, MSG_CMD_DEL } from "./commands";
const DISCORD_API = "https://discord.com/api/v10";
// Discord interaction protocol constants
const INTERACTION_TYPE = { PING: 1, COMMAND: 2, BUTTON: 3, MODAL_SUBMIT: 5 } as const;
const CALLBACK_TYPE = { PONG: 1, MESSAGE: 4, DEFERRED_MESSAGE: 5, MODAL: 9 } as const;
const COMMAND_TYPE = { CHAT_INPUT: 1, MESSAGE: 3 } as const;
const EPHEMERAL = 64;
// Modal custom_id encodings (delimiter '|' never appears in owner/repo).
const MODAL_ADD = "ghc|add|"; // ghc|add|owner|repo|issueNumber
const MODAL_EDIT = "ghc|edit|"; // ghc|edit|owner|repo|commentId
// PR notification button custom_id encodings.
const BTN_MERGE = "ghpr|merge|"; // ghpr|merge|owner|repo|pullNumber
const BTN_CLOSE = "ghpr|close|"; // ghpr|close|owner|repo|pullNumber
// Comment link (has the comment id); check this BEFORE the plain issue regex.
const GITHUB_COMMENT_RE =
/github\.com\/([^/\s]+)\/([^/\s]+)\/(?:issues|pull)\/\d+#issuecomment-(\d+)/;
const GITHUB_ISSUE_RE = /github\.com\/([^/\s]+)\/([^/\s]+)\/(?:issues|pull)\/(\d+)/;
interface Interaction {
id: string;
token: string;
type: number;
channel_id?: string;
member?: { user?: { id?: string } };
user?: { id?: string };
data?: Record<string, unknown>;
}
const MAX_BODY_SIZE = 1024 * 1024;
const TIMESTAMP_TOLERANCE_SECONDS = 180;
function hexToBytes(hex: string): ArrayBuffer {
const buffer = new ArrayBuffer(hex.length / 2);
const bytes = new Uint8Array(buffer);
for (let i = 0; i < bytes.length; i++) {
bytes[i] = parseInt(hex.slice(i * 2, i * 2 + 2), 16);
}
return buffer;
}
/**
* Verify an interaction's Ed25519 signature (X-Signature-Ed25519 over
* timestamp + raw body, signed by the Discord application public key).
*/
export async function verifyDiscordSignature(
publicKey: string,
timestamp: string,
signatureHex: string,
rawBody: string,
): Promise<boolean> {
try {
const key = await crypto.subtle.importKey(
"raw",
hexToBytes(publicKey),
{ name: "Ed25519" },
false,
["verify"],
);
return await crypto.subtle.verify(
{ name: "Ed25519" },
key,
hexToBytes(signatureHex),
new TextEncoder().encode(timestamp + rawBody),
);
} catch (err) {
log.warn({ err: String(err) }, "Failed to verify Discord signature");
return false;
}
}
/** Handle a POST to the Discord Interactions Endpoint. */
export async function handleInteractionRequest(request: Request, env: Env): Promise<Response> {
const contentLength = Number(request.headers.get("content-length") ?? 0);
if (contentLength > MAX_BODY_SIZE) {
return new Response("Request too large", { status: 413 });
}
const signature = request.headers.get("X-Signature-Ed25519");
const timestamp = request.headers.get("X-Signature-Timestamp");
if (!signature || !timestamp || !env.DISCORD_PUBLIC_KEY) {
log.warn(
{ hasSig: !!signature, hasTs: !!timestamp, hasKey: !!env.DISCORD_PUBLIC_KEY },
"Discord interaction missing signature",
);
return new Response("Invalid signature", { status: 401 });
}
if (Math.abs(Math.floor(Date.now() / 1000) - Number(timestamp)) > TIMESTAMP_TOLERANCE_SECONDS) {
return new Response("Invalid signature", { status: 401 });
}
const rawBody = await request.text();
if (rawBody.length > MAX_BODY_SIZE) {
return new Response("Request too large", { status: 413 });
}
const valid = await verifyDiscordSignature(env.DISCORD_PUBLIC_KEY, timestamp, signature, rawBody);
if (!valid) {
return new Response("Invalid signature", { status: 401 });
}
let interaction: Interaction;
try {
interaction = JSON.parse(rawBody) as Interaction;
} catch {
return new Response("Invalid JSON", { status: 400 });
}
// Discord's connection check.
if (interaction.type === INTERACTION_TYPE.PING) {
return new Response(JSON.stringify({ type: CALLBACK_TYPE.PONG }), {
headers: { "Content-Type": "application/json" },
});
}
// Handle the interaction via the callback webhook; respond 202 with no body
// as required for interactions received over the HTTP endpoint.
await handleInteraction(env, interaction).catch((err) =>
log.error({ err: String(err) }, "Interaction handler failed"),
);
return new Response(null, { status: 202 });
}
async function handleInteraction(env: Env, interaction: Interaction): Promise<void> {
const userId = interaction.member?.user?.id ?? interaction.user?.id ?? null;
const id = interaction.id;
const token = interaction.token;
if (interaction.type === INTERACTION_TYPE.BUTTON) {
const data = interaction.data as { custom_id?: string; message?: { id?: string } };
return handleButton(
env,
id,
token,
userId,
interaction.channel_id,
data.message?.id,
data.custom_id,
);
}
if (interaction.type === INTERACTION_TYPE.COMMAND) {
const data = interaction.data as {
name?: string;
type?: number;
target_id?: string;
options?: Array<{
name: string;
options?: Array<{
name: string;
value?: string;
options?: Array<{ name: string; value?: string }>;
}>;
}>;
resolved?: {
messages?: Record<string, { embeds?: Array<{ url?: string }>; content?: string }>;
};
};
// Right-click (message context-menu) commands.
if (data.type === COMMAND_TYPE.MESSAGE) {
const op =
data.name === MSG_CMD_ADD
? "add"
: data.name === MSG_CMD_EDIT
? "edit"
: data.name === MSG_CMD_DEL
? "del"
: null;
if (!op) return;
const target = data.target_id ? data.resolved?.messages?.[data.target_id] : undefined;
const source = target?.embeds?.[0]?.url ?? target?.content ?? "";
return commentOp(env, id, token, userId, op, source);
}
// Slash command /gh ...
if (data.name === "gh" && data.type === COMMAND_TYPE.CHAT_INPUT) {
const top = data.options?.[0];
if (top?.name === "login") return cmdLogin(env, id, token, userId);
if (top?.name === "logout") return cmdLogout(env, id, token, userId);
if (top?.name === "comment") {
const sub = top.options?.[0];
const op =
sub?.name === "add"
? "add"
: sub?.name === "edit"
? "edit"
: sub?.name === "del"
? "del"
: null;
if (!op) return;
const link = sub?.options?.find((o) => o.name === "link")?.value ?? "";
return commentOp(env, id, token, userId, op, link);
}
return;
}
return;
}
if (interaction.type === INTERACTION_TYPE.MODAL_SUBMIT) {
return modalSubmit(env, id, token, userId, interaction.data);
}
}
/** Respond to an interaction with an ephemeral text message. */
async function respond(env: Env, id: string, token: string, content: string): Promise<void> {
await interactionCallback(env, id, token, {
type: CALLBACK_TYPE.MESSAGE,
data: { content, flags: EPHEMERAL },
});
}
async function interactionCallback(
env: Env,
id: string,
token: string,
body: unknown,
): Promise<void> {
const res = await fetch(`${DISCORD_API}/interactions/${id}/${token}/callback`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});
if (!res.ok) {
const err = await res.text();
log.warn({ status: res.status, err }, "Interaction callback failed");
}
}
/** Replace the deferred (ephemeral) response body with the final result. */
async function updateOriginal(env: Env, id: string, token: string, content: string): Promise<void> {
const res = await fetch(`${DISCORD_API}/interactions/${id}/${token}/messages/@original`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ content }),
});
if (!res.ok) {
const err = await res.text();
log.warn({ status: res.status, err }, "Failed to update interaction response");
}
}
/**
* PR notification buttons: merge or close the PR as the clicker's linked
* GitHub account. The clicker must have run `/gh login` first.
*/
async function handleButton(
env: Env,
id: string,
token: string,
userId: string | null,
channelId: string | undefined,
messageId: string | undefined,
customId: string | undefined,
): Promise<void> {
if (!userId) return respond(env, id, token, "无法识别你的 Discord 账号。");
const githubUserId = await getDiscordLink(env.DB, userId);
if (!githubUserId) {
return respond(env, id, token, "你还没有绑定 GitHub 账号,请先使用 `/gh login`。");
}
let op: "merge" | "close";
let rest: string;
if (customId?.startsWith(BTN_MERGE)) {
op = "merge";
rest = customId.slice(BTN_MERGE.length);
} else if (customId?.startsWith(BTN_CLOSE)) {
op = "close";
rest = customId.slice(BTN_CLOSE.length);
} else {
return;
}
const [owner, repo, number] = rest.split("|");
if (!owner || !repo || !number) return;
// Acknowledge first (deferred, ephemeral) so the clicker sees a spinner
// while the GitHub API call runs.
await interactionCallback(env, id, token, {
type: CALLBACK_TYPE.DEFERRED_MESSAGE,
data: { flags: EPHEMERAL },
});
try {
if (op === "merge") {
await mergePullRequestAsUser(env.KV, githubUserId, owner, repo, Number(number));
} else {
await closePullRequestAsUser(env.KV, githubUserId, owner, repo, Number(number));
}
// Remove the buttons from the notification so nobody double-clicks.
if (channelId && messageId) {
await fetch(`${DISCORD_API}/channels/${channelId}/messages/${messageId}`, {
method: "PATCH",
headers: {
Authorization: `Bot ${env.DISCORD_TOKEN ?? ""}`,
"Content-Type": "application/json",
},
body: JSON.stringify({ components: [] }),
}).catch((err) => log.warn({ err: String(err) }, "Failed to strip PR buttons"));
}
const label = op === "merge" ? "合并" : "关闭";
await updateOriginal(env, id, token, `✅ 已${label} PR ${owner}/${repo}#${number}`);
} catch (err) {
await updateOriginal(env, id, token, errText(err));
}
}
async function cmdLogin(env: Env, id: string, token: string, userId: string | null): Promise<void> {
if (!userId) return respond(env, id, token, "无法识别你的 Discord 账号。");
const clientId = env.GITHUB_CLIENT_ID;
if (!clientId) return respond(env, id, token, "服务器未配置 GitHub OAuthGITHUB_CLIENT_ID。");
const state = crypto.randomUUID().replace(/-/g, "");
await env.KV.put(
`state:${state}`,
JSON.stringify({ redirectTo: "/", discordUserId: userId, expiresAt: Date.now() + 600_000 }),
{ expirationTtl: 600 },
);
const url = getOAuthURL(clientId, state);
await respond(
env,
id,
token,
`点击链接授权 GitHub即可用**本人身份**评论仅你可见10 分钟内有效):\n${url}`,
);
}
async function cmdLogout(
env: Env,
id: string,
token: string,
userId: string | null,
): Promise<void> {
if (!userId) return respond(env, id, token, "无法识别你的 Discord 账号。");
await removeDiscordLink(env.DB, userId);
await respond(env, id, token, "已解绑你的 GitHub 账号。");
}
/** Map a GitHub op error code to a user-facing (Chinese) message. */
function errText(err: unknown): string {
const t = err instanceof Error ? err.message : String(err);
if (t === "GITHUB_TOKEN_EXPIRED") return "GitHub 授权已过期或无效,请重新使用 `/gh login` 绑定。";
if (t === "GITHUB_FORBIDDEN") return "GitHub 拒绝了此操作:你的账号没有权限修改/删除这条评论。";
if (t === "GITHUB_NOT_FOUND") return "找不到目标(可能评论已被删除或仓库不可访问)。";
return `操作失败:${t}`;
}
/**
* Unified entry for add/edit/del, from either a slash command (source = link
* option) or a right-click message command (source = notification embed url).
*/
async function commentOp(
env: Env,
id: string,
token: string,
userId: string | null,
op: "add" | "edit" | "del",
source: string,
): Promise<void> {
if (!userId) return respond(env, id, token, "无法识别你的 Discord 账号。");
const githubUserId = await getDiscordLink(env.DB, userId);
if (!githubUserId) {
return respond(env, id, token, "你还没有绑定 GitHub 账号,请先使用 `/gh login`。");
}
if (op === "add") {
const m = source.match(GITHUB_ISSUE_RE);
if (!m)
return respond(
env,
id,
token,
"找不到 issue / PR 链接(右键 issue/PR 通知,或用 link 传入链接)。",
);
return openCommentModal(
env,
id,
token,
`${MODAL_ADD}${m[1]}|${m[2]}|${m[3]}`,
`评论 ${m[1]}/${m[2]}#${m[3]}`,
);
}
// edit / del both need a specific comment id.
const m = source.match(GITHUB_COMMENT_RE);
if (!m) {
return respond(
env,
id,
token,
"找不到评论链接(需含 `#issuecomment-...`,请右键某条评论通知,或粘贴评论链接)。",
);
}
const [, owner, repo, commentId] = m;
if (op === "del") {
try {
await deleteCommentAsUser(env.KV, githubUserId, owner!, repo!, Number(commentId));
return respond(env, id, token, `已删除评论 ${owner}/${repo}#issuecomment-${commentId}`);
} catch (err) {
return respond(env, id, token, errText(err));
}
}
// edit: fetch current body to prefill the modal.
let prefill = "";
try {
const { body } = await getCommentAsUser(env.KV, githubUserId, owner!, repo!, Number(commentId));
prefill = body;
} catch (err) {
return respond(env, id, token, errText(err));
}
return openCommentModal(
env,
id,
token,
`${MODAL_EDIT}${owner}|${repo}|${commentId}`,
`编辑评论 #${commentId}`,
prefill,
);
}
/** Open a modal to collect/edit comment body. */
async function openCommentModal(
env: Env,
id: string,
token: string,
customId: string,
title: string,
prefill = "",
): Promise<void> {
await interactionCallback(env, id, token, {
type: CALLBACK_TYPE.MODAL,
data: {
custom_id: customId,
title: title.slice(0, 45),
components: [
{
type: 1,
components: [
{
type: 4,
custom_id: "body",
label: "评论内容",
style: 2,
required: true,
max_length: 2000,
value: prefill.slice(0, 2000) || undefined,
},
],
},
],
},
});
}
async function modalSubmit(
env: Env,
id: string,
token: string,
userId: string | null,
data: unknown,
): Promise<void> {
const d = data as {
custom_id?: string;
components?: Array<{ components?: Array<{ custom_id?: string; value?: string }> }>;
};
const customId = d.custom_id;
if (!userId || !customId) return;
const body = d.components?.[0]?.components?.find((c) => c.custom_id === "body")?.value?.trim();
if (!body) return respond(env, id, token, "评论内容不能为空。");
const githubUserId = await getDiscordLink(env.DB, userId);
if (!githubUserId) {
return respond(env, id, token, "你还没有绑定 GitHub 账号,请先使用 `/gh login`。");
}
// ghc|add|owner|repo|issueNumber
if (customId.startsWith(MODAL_ADD)) {
const [owner, repo, number] = customId.slice(MODAL_ADD.length).split("|");
if (!owner || !repo || !number)
return respond(env, id, token, "内部错误:无法解析目标 issue。");
try {
const { htmlUrl, login } = await commentAsUser(
env.KV,
githubUserId,
owner,
repo,
Number(number),
body,
);
return respond(env, id, token, `已以 **@${login}** 身份评论:${htmlUrl}`);
} catch (err) {
return respond(env, id, token, errText(err));
}
}
// ghc|edit|owner|repo|commentId
if (customId.startsWith(MODAL_EDIT)) {
const [owner, repo, commentId] = customId.slice(MODAL_EDIT.length).split("|");
if (!owner || !repo || !commentId)
return respond(env, id, token, "内部错误:无法解析目标评论。");
try {
const { htmlUrl } = await editCommentAsUser(
env.KV,
githubUserId,
owner,
repo,
Number(commentId),
body,
);
return respond(env, id, token, `已更新评论:${htmlUrl}`);
} catch (err) {
return respond(env, id, token, errText(err));
}
}
}

View file

@ -0,0 +1,52 @@
import type { FormattedMessage, NeutralActionStyle, NeutralMessage } from "../../types";
function toStyle(style: NeutralActionStyle): number {
switch (style) {
case "primary":
return 3;
case "danger":
return 4;
default:
return 2;
}
}
export function renderNeutralMessage(message: NeutralMessage): FormattedMessage {
const content = message.mentionRoleIds?.length
? message.mentionRoleIds.map((id) => `<@&${id}>`).join(" ")
: undefined;
return {
content,
embeds: [
{
title: message.title,
url: message.url,
color: message.color,
description: message.description,
author: message.author
? {
name: message.author.name,
icon_url: message.author.iconUrl,
url: message.author.url,
}
: undefined,
fields: message.fields,
footer: message.footer ? { text: message.footer } : undefined,
timestamp: message.timestamp,
},
],
components: message.actions?.length
? [
{
type: 1,
components: message.actions.map((action) => ({
type: 2,
style: toStyle(action.style),
label: action.label,
custom_id: action.id,
})),
},
]
: undefined,
};
}

View file

@ -0,0 +1,103 @@
import { log } from "../../lib/log";
import type { SendResult } from "../types";
const DISCORD_API = "https://discord.com/api/v10";
interface DiscordMessage {
id?: string;
}
async function request(
url: string,
method: string,
token: string,
message: unknown,
channelId: string,
): Promise<SendResult> {
for (let attempt = 0; attempt < 3; attempt++) {
try {
const res = await fetch(url, {
method,
headers: {
Authorization: `Bot ${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();
if (res.status >= 500) {
log.error({ status: res.status, err, attempt, channelId }, "Discord API 5xx");
if (attempt === 2)
return {
ok: false,
error: err,
errorCode: "DISCORD_5XX",
status: res.status,
attempts: attempt + 1,
};
await new Promise((r) => setTimeout(r, 1000 * (attempt + 1)));
continue;
}
log.error({ status: res.status, err, channelId }, "Discord API error");
return {
ok: false,
error: err,
errorCode: "DISCORD_ERROR",
status: res.status,
attempts: attempt + 1,
};
}
let messageId: string | undefined;
try {
const data = (await res.json()) as DiscordMessage;
messageId = data.id;
} catch {
// ignore malformed success body
}
return { ok: true, status: res.status, messageId, attempts: attempt + 1 };
} catch (err) {
log.error({ err, attempt, channelId }, "Failed to send message");
if (attempt === 2)
return { ok: false, error: String(err), errorCode: "NETWORK", attempts: attempt + 1 };
await new Promise((r) => setTimeout(r, 1000 * (attempt + 1)));
}
}
return { ok: false, error: "Max retries exceeded", errorCode: "RETRIES", attempts: 3 };
}
export async function sendMessage(
token: string,
channelId: string,
message: unknown,
threadId?: string,
): Promise<SendResult> {
const url = threadId
? `${DISCORD_API}/channels/${threadId}/messages`
: `${DISCORD_API}/channels/${channelId}/messages`;
return request(url, "POST", token, message, channelId);
}
export async function editMessage(
token: string,
channelId: string,
messageId: string,
message: unknown,
threadId?: string,
): Promise<SendResult> {
const base = threadId ?? channelId;
const url = `${DISCORD_API}/channels/${base}/messages/${messageId}`;
return request(url, "PATCH", token, message, channelId);
}

View file

@ -0,0 +1,16 @@
import type { RouteTarget } from "../types";
import type { PlatformDriver } from "./types";
import { DiscordDriver } from "./discord";
import { TelegramDriver } from "./telegram";
const drivers: Record<string, PlatformDriver> = {
discord: new DiscordDriver(),
telegram: new TelegramDriver(),
};
export function getDriver(target: RouteTarget): PlatformDriver {
const platform = target.platform ?? "discord";
const driver = drivers[platform];
if (!driver) throw new Error(`No driver for platform "${platform}"`);
return driver;
}

View file

@ -0,0 +1,247 @@
import { log } from "../../lib/log";
import {
getOAuthURL,
commentAsUser,
mergePullRequestAsUser,
closePullRequestAsUser,
} from "../../github/oauth";
import { getTelegramLink, removeTelegramLink } from "../../github/store";
import type { Env } from "../../types";
import { sendMessage } from "./rest";
const GITHUB_ISSUE_RE = /github\.com\/([^/\s]+)\/([^/\s]+)\/(?:issues|pull)\/(\d+)/;
const GITHUB_PR_RE = /github\.com\/([^/\s]+)\/([^/\s]+)\/pull\/(\d+)/;
interface TelegramMessage {
message_id?: number;
text?: string;
from?: { id?: number; first_name?: string; username?: string };
chat?: { id?: number; type?: string; title?: string };
date?: number;
message_thread_id?: number;
entities?: Array<{ type?: string; url?: string; offset?: number; length?: number }>;
reply_to_message?: TelegramMessage;
}
interface Target {
owner: string;
repo: string;
number: number;
}
function chatIdOf(msg: TelegramMessage): string | null {
return msg.chat?.id != null ? String(msg.chat.id) : null;
}
function userIdOf(msg: TelegramMessage): string | null {
return msg.from?.id != null ? String(msg.from.id) : null;
}
/** Extract a GitHub issue/PR link from a message (entities text_link or raw text). */
function extractTarget(msg: TelegramMessage, prOnly = false): Target | null {
const urls: string[] = [];
for (const ent of msg.entities ?? []) {
if (ent.type === "text_link" && ent.url) urls.push(ent.url);
}
if (msg.text) {
for (const u of msg.text.match(/https?:\/\/github\.com\/[^\s]+/g) ?? []) urls.push(u);
}
for (const url of urls) {
const re = prOnly ? GITHUB_PR_RE : GITHUB_ISSUE_RE;
const m = url.match(re);
if (m && m[1] && m[2] && m[3]) return { owner: m[1], repo: m[2], number: Number(m[3]) };
}
return null;
}
async function reply(
env: Env,
chatId: string,
topicId: string | undefined,
text: string,
): Promise<void> {
await sendMessage(env.TELEGRAM_TOKEN ?? "", chatId, text, topicId);
}
function errText(err: unknown): string {
const t = err instanceof Error ? err.message : String(err);
if (t === "GITHUB_TOKEN_EXPIRED") return "GitHub 授权已过期或无效,请重新使用 /gh login 绑定。";
if (t === "GITHUB_FORBIDDEN") return "GitHub 拒绝了此操作:你的账号没有权限。";
if (t === "GITHUB_NOT_FOUND") return "找不到目标(可能已删除或仓库不可访问)。";
return `操作失败:${t}`;
}
async function cmdLogin(env: Env, msg: TelegramMessage): Promise<void> {
const chatId = chatIdOf(msg);
const telegramUserId = userIdOf(msg);
if (!chatId || !telegramUserId) return;
const topicId = msg.message_thread_id != null ? String(msg.message_thread_id) : undefined;
const clientId = env.GITHUB_CLIENT_ID;
if (!clientId)
return reply(env, chatId, topicId, "服务器未配置 GitHub OAuthGITHUB_CLIENT_ID。");
const state = crypto.randomUUID().replace(/-/g, "");
await env.KV.put(
`state:${state}`,
JSON.stringify({
redirectTo: "/",
telegramUserId,
telegramChatId: chatId,
expiresAt: Date.now() + 600_000,
}),
{ expirationTtl: 600 },
);
const url = getOAuthURL(clientId, state);
await reply(
env,
chatId,
topicId,
`点击链接授权 GitHub即可用**本人身份**评论10 分钟内有效):\n${url}`,
);
}
async function cmdLogout(env: Env, msg: TelegramMessage): Promise<void> {
const chatId = chatIdOf(msg);
const telegramUserId = userIdOf(msg);
if (!chatId || !telegramUserId) return;
const topicId = msg.message_thread_id != null ? String(msg.message_thread_id) : undefined;
await removeTelegramLink(env.DB, telegramUserId);
await reply(env, chatId, topicId, "已解绑你的 GitHub 账号。");
}
async function cmdComment(env: Env, msg: TelegramMessage, body: string): Promise<void> {
const chatId = chatIdOf(msg);
const telegramUserId = userIdOf(msg);
if (!chatId || !telegramUserId) return;
const topicId = msg.message_thread_id != null ? String(msg.message_thread_id) : undefined;
const githubUserId = await getTelegramLink(env.DB, telegramUserId);
if (!githubUserId) {
return reply(env, chatId, topicId, "你还没有绑定 GitHub 账号,请先使用 /gh login。");
}
const source = msg.reply_to_message;
const target = source ? extractTarget(source) : null;
if (!target) {
return reply(
env,
chatId,
topicId,
"找不到 issue / PR 链接,请在对应的 GitHub 通知消息上回复 /gh comment。",
);
}
try {
const { htmlUrl, login } = await commentAsUser(
env.KV,
githubUserId,
target.owner,
target.repo,
target.number,
body,
);
await reply(env, chatId, topicId, `已以 **@${login}** 身份评论:${htmlUrl}`);
} catch (err) {
await reply(env, chatId, topicId, errText(err));
}
}
async function cmdMergeClose(env: Env, msg: TelegramMessage, op: "merge" | "close"): Promise<void> {
const chatId = chatIdOf(msg);
const telegramUserId = userIdOf(msg);
if (!chatId || !telegramUserId) return;
const topicId = msg.message_thread_id != null ? String(msg.message_thread_id) : undefined;
const githubUserId = await getTelegramLink(env.DB, telegramUserId);
if (!githubUserId) {
return reply(env, chatId, topicId, "你还没有绑定 GitHub 账号,请先使用 /gh login。");
}
const source = msg.reply_to_message;
const target = source ? extractTarget(source, true) : null;
if (!target) {
return reply(
env,
chatId,
topicId,
"找不到 PR 链接,请在对应的 GitHub PR 通知消息上回复 /gh merge 或 /gh close。",
);
}
try {
if (op === "merge") {
await mergePullRequestAsUser(env.KV, githubUserId, target.owner, target.repo, target.number);
} else {
await closePullRequestAsUser(env.KV, githubUserId, target.owner, target.repo, target.number);
}
const label = op === "merge" ? "合并" : "关闭";
await reply(
env,
chatId,
topicId,
`✅ 已${label} PR ${target.owner}/${target.repo}#${target.number}`,
);
} catch (err) {
await reply(env, chatId, topicId, errText(err));
}
}
/** Register the Telegram webhook to point at this worker (called from cron). */
export async function syncTelegramWebhook(env: Env): Promise<void> {
const token = env.TELEGRAM_TOKEN;
if (!token) return;
const baseUrl = env.BASE_URL;
if (!baseUrl) return;
const secret = env.TELEGRAM_WEBHOOK_SECRET;
const res = await fetch(`https://api.telegram.org/bot${token}/setWebhook`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
url: `${baseUrl.replace(/\/$/, "")}/telegram/webhook`,
secret_token: secret || undefined,
allowed_updates: ["message"],
}),
});
if (!res.ok) {
const err = await res.text();
log.error({ status: res.status, err }, "Failed to set Telegram webhook");
}
}
/** Handle a single Telegram update (message). Called from the webhook route. */
export async function handleTelegramUpdate(env: Env, update: unknown): Promise<void> {
const message = (update as { message?: TelegramMessage })?.message;
if (!message?.text) return;
const text = message.text.trim();
const m = text.match(/^\/gh(?:\s+|$)(.*)$/s);
if (!m) return;
const rest = (m[1] ?? "").trim();
const [sub, ...args] = rest.split(/\s+/);
const body = args.join(" ").trim();
switch (sub) {
case "login":
return cmdLogin(env, message);
case "logout":
return cmdLogout(env, message);
case "comment":
if (!body) {
const chatId = chatIdOf(message);
const topicId =
message.message_thread_id != null ? String(message.message_thread_id) : undefined;
if (chatId) await reply(env, chatId, topicId, "请附上评论内容:/gh comment 你的评论");
return;
}
return cmdComment(env, message, body);
case "merge":
return cmdMergeClose(env, message, "merge");
case "close":
return cmdMergeClose(env, message, "close");
default:
log.info({ text }, "Unhandled /gh command from Telegram");
}
}

View file

@ -0,0 +1,64 @@
import type { RouteTarget, Env, NeutralMessage } from "../../types";
import type { PlatformDriver, SendResult } from "../types";
import { sendMessage, sendPhoto, editMessageText, editMessageCaption } from "./rest";
import { renderNeutralMessage } from "./render";
function smallAvatar(url: string): string {
const sep = url.includes("?") ? "&" : "?";
return `${url}${sep}s=64`;
}
function richHeaderUrl(message: NeutralMessage, avatar: string, host: string): string {
const params = new URLSearchParams();
if (message.author?.name) params.set("title", message.author.name);
if (message.title) params.set("content", message.title);
params.set("avatar", avatar);
return `${host.replace(/\/+$/, "")}/api/richheader?${params.toString()}`;
}
export class TelegramDriver implements PlatformDriver {
readonly id = "telegram";
async send(message: NeutralMessage, target: RouteTarget, env: Env): Promise<SendResult> {
const chatId = target.chatId ?? "";
if (!chatId) {
return { ok: false, error: "target.chatId is required", errorCode: "NO_TARGET" };
}
const token = env.TELEGRAM_TOKEN ?? "";
const text = renderNeutralMessage(message);
const avatar = message.author?.iconUrl;
const richHeaderHost = env.TELEGRAM_RICH_HEADER_HOST ?? env.BASE_URL;
if (avatar && richHeaderHost) {
const rhUrl = richHeaderUrl(message, avatar, richHeaderHost);
return sendMessage(token, chatId, text, target.topicId, rhUrl);
}
if (avatar) {
return sendPhoto(token, chatId, smallAvatar(avatar), text, target.topicId);
}
return sendMessage(token, chatId, text, target.topicId);
}
async edit(
message: NeutralMessage,
target: RouteTarget,
env: Env,
messageId: string,
): Promise<SendResult> {
const chatId = target.chatId ?? "";
if (!chatId) {
return { ok: false, error: "target.chatId is required", errorCode: "NO_TARGET" };
}
const token = env.TELEGRAM_TOKEN ?? "";
const text = renderNeutralMessage(message);
const avatar = message.author?.iconUrl;
const richHeaderHost = env.TELEGRAM_RICH_HEADER_HOST ?? env.BASE_URL;
if (avatar && richHeaderHost) {
const rhUrl = richHeaderUrl(message, avatar, richHeaderHost);
return editMessageText(token, chatId, messageId, text, target.topicId, rhUrl);
}
if (avatar) {
return editMessageCaption(token, chatId, messageId, text, target.topicId);
}
return editMessageText(token, chatId, messageId, text, target.topicId);
}
}

View file

@ -0,0 +1,63 @@
import type { NeutralMessage } from "../../types";
function esc(s: string): string {
return s
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;");
}
function mdToHtml(s: string): string {
let out = esc(s);
out = out.replace(
/\[([^\]]+)\]\(([^)]+)\)/g,
(_m, label, url) => `<a href="${esc(url)}">${label}</a>`,
);
out = out.replace(/\*\*([^*]+)\*\*/g, "<b>$1</b>");
out = out.replace(/`([^`]+)`/g, "<code>$1</code>");
out = out.replace(/(^|[^*])\*([^*]+)\*/g, "$1<i>$2</i>");
out = out.replace(/~~([^~]+)~~/g, "<s>$1</s>");
return out;
}
function formatTimestamp(ts?: string): string {
if (!ts) return "";
const d = new Date(ts);
if (Number.isNaN(d.getTime())) return ts;
const pad = (n: number): string => String(n).padStart(2, "0");
return `${d.getUTCFullYear()}-${pad(d.getUTCMonth() + 1)}-${pad(d.getUTCDate())} ${pad(d.getUTCHours())}:${pad(d.getUTCMinutes())} UTC`;
}
export function renderNeutralMessage(message: NeutralMessage): string {
const parts: string[] = [];
const title = message.url
? `<a href="${esc(message.url)}">${mdToHtml(message.title)}</a>`
: mdToHtml(message.title);
parts.push(`<b>${title}</b>`);
if (message.author) {
const name = mdToHtml(message.author.name);
const author = message.author.url ? `<a href="${esc(message.author.url)}">${name}</a>` : name;
parts.push(`👤 ${author}`);
}
if (message.description) {
parts.push(mdToHtml(message.description));
}
for (const field of message.fields ?? []) {
parts.push(`<b>${mdToHtml(field.name)}</b>: ${mdToHtml(field.value)}`);
}
const meta: string[] = [];
if (message.footer) meta.push(esc(message.footer));
const ts = formatTimestamp(message.timestamp);
if (ts) meta.push(ts);
if (meta.length > 0) {
parts.push(`<i>${meta.join(" · ")}</i>`);
}
return parts.join("\n");
}

View file

@ -0,0 +1,189 @@
import { log } from "../../lib/log";
import type { SendResult } from "../types";
const TELEGRAM_API = "https://api.telegram.org";
interface TelegramResponse {
ok?: boolean;
description?: string;
result?: { message_id?: number };
}
async function post(
token: string,
method: string,
body: Record<string, unknown>,
chatId: string,
): Promise<SendResult> {
const url = `${TELEGRAM_API}/bot${token}/${method}`;
let lastStatus = 0;
let lastError = "";
for (let attempt = 0; attempt < 3; attempt++) {
try {
const res = await fetch(url, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});
lastStatus = res.status;
const data = (await res.json().catch(() => null)) as TelegramResponse | null;
if (res.status === 429) {
const retryAfter = (data as { retry_after?: number })?.retry_after ?? 1;
lastError = data?.description ?? `Rate limited (retry_after=${retryAfter})`;
await new Promise((r) => setTimeout(r, retryAfter * 1000));
continue;
}
if (!res.ok) {
lastError = data?.description ?? `HTTP ${res.status}`;
log.error(
{ status: res.status, err: lastError, chatId, attempts: attempt + 1 },
"Telegram API error",
);
return {
ok: false,
error: lastError,
errorCode: res.status >= 500 ? "TELEGRAM_5XX" : "TELEGRAM_ERROR",
status: res.status,
attempts: attempt + 1,
};
}
return {
ok: true,
status: res.status,
messageId: data?.result?.message_id != null ? String(data.result.message_id) : undefined,
attempts: attempt + 1,
};
} catch (err) {
lastError = err instanceof Error ? err.message : String(err);
log.error({ err, chatId, attempts: attempt + 1 }, "Failed to send Telegram message");
if (attempt === 2) {
return {
ok: false,
error: lastError,
errorCode: "NETWORK",
status: lastStatus,
attempts: attempt + 1,
};
}
await new Promise((r) => setTimeout(r, 500 * (attempt + 1)));
}
}
return {
ok: false,
error: lastError || "Failed to send Telegram message",
errorCode: "RETRIES",
status: lastStatus,
attempts: 3,
};
}
export async function sendMessage(
token: string,
chatId: string,
text: string,
topicId?: string,
linkPreviewUrl?: string,
): Promise<SendResult> {
if (!token) {
return { ok: false, error: "TELEGRAM_TOKEN not configured", errorCode: "NO_TOKEN" };
}
const body: Record<string, unknown> = {
chat_id: chatId,
text,
parse_mode: "HTML",
disable_web_page_preview: !linkPreviewUrl,
};
if (linkPreviewUrl) {
body.link_preview_options = {
url: linkPreviewUrl,
prefer_small_media: true,
show_above_text: true,
};
}
if (topicId) {
body.message_thread_id = Number(topicId);
}
return post(token, "sendMessage", body, chatId);
}
export async function sendPhoto(
token: string,
chatId: string,
photoUrl: string,
caption?: string,
topicId?: string,
): Promise<SendResult> {
if (!token) {
return { ok: false, error: "TELEGRAM_TOKEN not configured", errorCode: "NO_TOKEN" };
}
const body: Record<string, unknown> = {
chat_id: chatId,
photo: photoUrl,
parse_mode: "HTML",
};
if (caption) {
body.caption = caption;
}
if (topicId) {
body.message_thread_id = Number(topicId);
}
return post(token, "sendPhoto", body, chatId);
}
export async function editMessageText(
token: string,
chatId: string,
messageId: string,
text: string,
topicId?: string,
linkPreviewUrl?: string,
): Promise<SendResult> {
if (!token) {
return { ok: false, error: "TELEGRAM_TOKEN not configured", errorCode: "NO_TOKEN" };
}
const body: Record<string, unknown> = {
chat_id: chatId,
message_id: messageId,
text,
parse_mode: "HTML",
disable_web_page_preview: !linkPreviewUrl,
};
if (linkPreviewUrl) {
body.link_preview_options = {
url: linkPreviewUrl,
prefer_small_media: true,
show_above_text: true,
};
}
if (topicId) {
body.message_thread_id = Number(topicId);
}
return post(token, "editMessageText", body, chatId);
}
export async function editMessageCaption(
token: string,
chatId: string,
messageId: string,
caption: string,
topicId?: string,
): Promise<SendResult> {
if (!token) {
return { ok: false, error: "TELEGRAM_TOKEN not configured", errorCode: "NO_TOKEN" };
}
const body: Record<string, unknown> = {
chat_id: chatId,
message_id: messageId,
caption,
parse_mode: "HTML",
};
if (topicId) {
body.message_thread_id = Number(topicId);
}
return post(token, "editMessageCaption", body, chatId);
}

View file

@ -0,0 +1,45 @@
import type { Env } from "../../types";
import { handleTelegramUpdate } from "./commands";
const MAX_BODY_SIZE = 1024 * 1024;
function timingSafeEqual(a: string, b: string): boolean {
if (a.length !== b.length) return false;
let diff = 0;
for (let i = 0; i < a.length; i++) {
diff |= a.charCodeAt(i) ^ b.charCodeAt(i);
}
return diff === 0;
}
/** Handle a POST to the Telegram webhook endpoint. */
export async function handleTelegramWebhookRequest(request: Request, env: Env): Promise<Response> {
const contentLength = Number(request.headers.get("content-length") ?? 0);
if (contentLength > MAX_BODY_SIZE) {
return new Response("Request too large", { status: 413 });
}
const secret = env.TELEGRAM_WEBHOOK_SECRET;
if (secret) {
const token = request.headers.get("X-Telegram-Bot-Api-Secret-Token");
if (!token || !timingSafeEqual(token, secret)) {
return new Response("Invalid secret", { status: 401 });
}
}
const rawBody = await request.text();
if (rawBody.length > MAX_BODY_SIZE) {
return new Response("Request too large", { status: 413 });
}
let update: unknown;
try {
update = JSON.parse(rawBody);
} catch {
return new Response("Invalid JSON", { status: 400 });
}
// Telegram expects a quick 200; process commands in the background.
await handleTelegramUpdate(env, update);
return new Response("ok");
}

View file

@ -0,0 +1,25 @@
import type { RouteTarget, Env, NeutralMessage } from "../types";
export interface SendResult {
ok: boolean;
error?: string;
errorCode?: string;
status?: number;
messageId?: string;
attempts?: number;
}
export interface PlatformDriver {
readonly id: string;
send(message: NeutralMessage, target: RouteTarget, env: Env): Promise<SendResult>;
/**
* Edit an already-sent message in place (e.g. workflow run progress updates).
* Must be implemented by drivers that support message updates.
*/
edit(
message: NeutralMessage,
target: RouteTarget,
env: Env,
messageId: string,
): Promise<SendResult>;
}

127
server/lib/events/match.ts Normal file
View file

@ -0,0 +1,127 @@
import type { WebhookEvent, Route, Filter } from "../types";
const regexCache = new Map<string, RegExp>();
const keywordBodyCache = new WeakMap<WebhookEvent, string>();
const MAX_PATTERN_LENGTH = 200;
function compileKeywordRegex(pattern: string): RegExp | null {
if (pattern.length > MAX_PATTERN_LENGTH) return null;
const cached = regexCache.get(pattern);
if (cached) return cached;
try {
const re = new RegExp(pattern, "i");
regexCache.set(pattern, re);
return re;
} catch {
return null;
}
}
function getKeywordBody(event: WebhookEvent): string {
const cached = keywordBodyCache.get(event);
if (cached !== undefined) return cached;
const body = JSON.stringify(event.payload).toLowerCase();
keywordBodyCache.set(event, body);
return body;
}
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 === "check_suite") {
const suite = event.payload.check_suite as { head_branch?: string } | undefined;
return suite?.head_branch;
}
if (event.event === "workflow_job") {
const job = event.payload.workflow_job as { head_branch?: string } | undefined;
return job?.head_branch;
}
if (event.event === "deployment") {
const deployment = event.payload.deployment as { ref?: string } | undefined;
return deployment?.ref?.replace("refs/heads/", "");
}
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, keywordBody?: string): 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 = keywordBody ?? getKeywordBody(event);
const patterns = Array.isArray(filter.match) ? filter.match : [filter.match];
const matches = patterns.some((p) => {
const re = compileKeywordRegex(p);
if (!re) return body.includes(p.toLowerCase());
return re.test(body);
});
return filter.exclude ? !matches : matches;
}
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 eventOwners(event: WebhookEvent): string[] {
const owners = new Set<string>();
const repoOwner = (event.payload.repository as { owner?: { login?: string } } | undefined)?.owner
?.login;
if (repoOwner) owners.add(repoOwner);
const org = (event.payload.organization as { login?: string } | undefined)?.login;
if (org) owners.add(org);
return [...owners];
}
export function matchRoute(route: Route, event: WebhookEvent): boolean {
if (!route.enabled) return false;
const hasKeyword = route.filters.some((f) => f.type === "keyword");
const keywordBody = hasKeyword ? getKeywordBody(event) : undefined;
return route.filters.every((f) => matchFilter(f, event, keywordBody));
}

View file

@ -0,0 +1,222 @@
import type { NeutralMessage, NeutralAuthor } from "../types";
import { GITHUB_COLORS, WORKFLOW_CONCLUSION_EMOJI } from "./colors";
import { branchLink, commitLink, emojiPrefix, type T, buildMessage, repoBaseUrl } from "./helpers";
export function formatCheckSuite(
payload: Record<string, unknown>,
repo: string | undefined,
author: NeutralAuthor,
t: T,
showEmoji: boolean,
): NeutralMessage {
const suite = payload.check_suite as {
head_branch?: string;
head_sha?: string;
conclusion?: string;
status?: string;
app?: { name?: string };
html_url?: string;
};
const status =
suite.status === "queued"
? "queued"
: suite.status === "in_progress"
? "running"
: (suite.conclusion ?? "pending");
const baseUrl = repoBaseUrl(payload, repo);
const emoji = WORKFLOW_CONCLUSION_EMOJI[status] ?? "⏳";
const em = (e: string): string => emojiPrefix(e, showEmoji);
const colorKey =
status === "success"
? "check_run_success"
: status === "failure"
? "check_run_failure"
: "check_run_other";
const fields: Array<{ name: string; value: string; inline?: boolean }> = [];
fields.push({
name: t("fields.status"),
value: `${em(emoji)}${status}`,
inline: true,
});
if (suite.app?.name) {
fields.push({
name: t("fields.service"),
value: suite.app.name,
inline: true,
});
}
if (suite.head_branch) {
fields.push({
name: t("fields.branch"),
value: branchLink(baseUrl, suite.head_branch),
inline: true,
});
}
if (suite.head_sha) {
fields.push({
name: t("fields.commit"),
value: commitLink(baseUrl, suite.head_sha),
inline: true,
});
}
return buildMessage(
{
author,
title: t("events.check_suite.title", {
repo: repo ?? t("common.repository"),
conclusion: status,
}),
url:
suite.html_url ??
(baseUrl && suite.head_sha ? `${baseUrl}/commit/${suite.head_sha}/checks` : undefined),
color: GITHUB_COLORS[colorKey],
fields,
},
t,
repo,
);
}
export function formatStatus(
payload: Record<string, unknown>,
repo: string | undefined,
author: NeutralAuthor,
t: T,
showEmoji: boolean,
): NeutralMessage {
const state = (payload.state as string) ?? "pending";
const context = (payload.context as string) ?? "";
const description = payload.description as string | undefined;
const targetUrl = payload.target_url as string | undefined;
const sha = payload.sha as string | undefined;
const baseUrl = repoBaseUrl(payload, repo);
const emoji = state === "success" ? "✅" : state === "failure" || state === "error" ? "❌" : "⏳";
const em = (e: string): string => emojiPrefix(e, showEmoji);
const colorKey =
state === "success"
? "check_run_success"
: state === "failure" || state === "error"
? "check_run_failure"
: "check_run_other";
const fields: Array<{ name: string; value: string; inline?: boolean }> = [];
fields.push({
name: t("fields.status"),
value: `${em(emoji)}${state}`,
inline: true,
});
if (context) {
fields.push({
name: t("fields.context"),
value: context,
inline: true,
});
}
if (sha) {
fields.push({
name: t("fields.commit"),
value: commitLink(baseUrl, sha),
inline: true,
});
}
if (description) {
fields.push({
name: t("fields.description"),
value: description,
inline: false,
});
}
return buildMessage(
{
author,
title: t("events.status.title", {
repo: repo ?? t("common.repository"),
context: context || t("common.unknown"),
state,
}),
url: targetUrl,
color: GITHUB_COLORS[colorKey],
fields,
},
t,
repo,
);
}
export function formatCheckRun(
payload: Record<string, unknown>,
repo: string | undefined,
author: NeutralAuthor,
t: T,
showEmoji: boolean,
): NeutralMessage {
const checkRun = payload.check_run as {
id?: number;
name?: string;
conclusion?: string;
html_url?: string;
status?: string;
output?: { title?: string; summary?: string };
};
const status =
checkRun.status === "queued"
? "queued"
: checkRun.status === "in_progress"
? "running"
: (checkRun.conclusion ?? "pending");
const emoji = WORKFLOW_CONCLUSION_EMOJI[status] ?? "⏳";
const em = (e: string): string => emojiPrefix(e, showEmoji);
const colorKey =
status === "success"
? "check_run_success"
: status === "failure"
? "check_run_failure"
: "check_run_other";
const fields: Array<{ name: string; value: string; inline?: boolean }> = [];
fields.push({
name: t("fields.status"),
value: `${em(emoji)}${status}`,
inline: true,
});
if (checkRun.output?.title) {
fields.push({
name: t("fields.details"),
value: checkRun.output.title,
inline: false,
});
}
return buildMessage(
{
author,
title: t("events.check_run.title", {
repo: repo ?? t("common.repository"),
name: checkRun.name ?? "Check Run",
conclusion: status,
}),
url: checkRun.html_url,
color: GITHUB_COLORS[colorKey],
fields,
updateKey: repo && checkRun.id != null ? `check_run:${repo}:${checkRun.id}` : undefined,
},
t,
repo,
);
}

View file

@ -0,0 +1,64 @@
export const GITHUB_COLORS = {
push: 0x2ea44f,
pull_request_opened: 0x2da44e,
pull_request_closed: 0xf85149,
pull_request_merged: 0x8957e5,
pull_request_ready_for_review: 0x2da44e,
pull_request_other: 0x1f6feb,
issues_opened: 0x2da44e,
issues_closed: 0xf85149,
issues_reopened: 0x1f6feb,
issues_other: 0x8957e5,
issue_comment: 0x6e7681,
workflow_run_success: 0x2da44e,
workflow_run_failure: 0xf85149,
workflow_run_other: 0xd29922,
release_published: 0x2da44e,
release_prerelease: 0xd29922,
release_deleted: 0xf85149,
create: 0x3fb950,
delete: 0xf85149,
star: 0xd29922,
fork: 0x1f6feb,
discussion: 0x8957e5,
check_run_success: 0x2da44e,
check_run_failure: 0xf85149,
check_run_other: 0xd29922,
pull_request_review_approved: 0x2da44e,
pull_request_review_changes: 0xf85149,
pull_request_review_commented: 0x8b949e,
commit_comment: 0x6e7681,
deployment_success: 0x2da44e,
deployment_failure: 0xf85149,
deployment_pending: 0xd29922,
member_added: 0x2da44e,
member_removed: 0xf85149,
label: 0x8957e5,
milestone_opened: 0x1f6feb,
milestone_closed: 0x2da44e,
discussion_created: 0x8957e5,
discussion_answered: 0x2da44e,
discussion_comment: 0x6e7681,
repository: 0x8b949e,
code_scanning_critical: 0xf85149,
code_scanning_high: 0xf85149,
code_scanning_medium: 0xd29922,
code_scanning_low: 0x8b949e,
dependabot_critical: 0xf85149,
dependabot_high: 0xf85149,
dependabot_medium: 0xd29922,
dependabot_low: 0x8b949e,
default: 0x8b949e,
} as const;
export const WORKFLOW_CONCLUSION_EMOJI: Record<string, string> = {
success: "✅",
failure: "❌",
cancelled: "🚫",
timed_out: "⏱️",
action_required: "⚠️",
neutral: "",
stale: "♻️",
queued: "⏳",
running: "🔄",
};

View file

@ -0,0 +1,43 @@
import type { NeutralMessage, NeutralAuthor } from "../types";
import { GITHUB_COLORS } from "./colors";
import { emojiPrefix, type T, buildMessage } from "./helpers";
export function formatIssueComment(
payload: Record<string, unknown>,
repo: string | undefined,
author: NeutralAuthor,
t: T,
showEmoji: boolean,
): NeutralMessage {
const action = (payload.action as string) ?? "created";
const issue = payload.issue as {
number?: number;
title?: string;
html_url?: string;
};
const comment = payload.comment as {
body?: string;
html_url?: string;
};
const al = t("actions." + action) ?? action;
const em = (e: string): string => emojiPrefix(e, showEmoji);
const commentBody = comment.body?.slice(0, 500) ?? "";
const truncated = comment.body && comment.body.length > 500;
return buildMessage(
{
author,
title: t("events.issue_comment.title", {
repo: repo ?? t("common.repository"),
number: issue.number ?? "?",
title: issue.title ?? t("common.untitled"),
}),
url: comment.html_url ?? issue.html_url,
color: GITHUB_COLORS.issue_comment,
description: `${t("events.issue_comment.action_comment", { emoji: em("💬"), action: al })}\n\n> ${commentBody}${truncated ? "..." : ""}`,
},
t,
repo,
);
}

View file

@ -0,0 +1,51 @@
import type { NeutralMessage, NeutralAuthor } from "../types";
import { GITHUB_COLORS } from "./colors";
import { commitLink, emojiPrefix, type T, buildMessage, repoBaseUrl } from "./helpers";
export function formatCommitComment(
payload: Record<string, unknown>,
repo: string | undefined,
author: NeutralAuthor,
t: T,
showEmoji: boolean,
): NeutralMessage {
const action = (payload.action as string) ?? "created";
const comment = payload.comment as {
body?: string;
commit_id?: string;
html_url?: string;
};
const al = t("actions." + action) ?? action;
const em = (e: string): string => emojiPrefix(e, showEmoji);
const commentBody = comment.body?.slice(0, 500) ?? "";
const truncated = comment.body && comment.body.length > 500;
const baseUrl = repoBaseUrl(payload, repo);
const shortSha = comment.commit_id?.slice(0, 7) ?? "???????";
const fields: Array<{ name: string; value: string; inline?: boolean }> = [];
if (comment.commit_id) {
fields.push({
name: t("fields.commit"),
value: commitLink(baseUrl, comment.commit_id, shortSha),
inline: true,
});
}
return buildMessage(
{
author,
title: t("events.commit_comment.title", {
repo: repo ?? t("common.repository"),
sha: commitLink(baseUrl, comment.commit_id ?? "", shortSha),
}),
url: comment.html_url,
color: GITHUB_COLORS.commit_comment,
description: `${t("events.commit_comment.action_comment", { emoji: em("💬"), action: al })}\n\n> ${commentBody}${truncated ? "..." : ""}`,
fields: fields.length > 0 ? fields : undefined,
},
t,
repo,
);
}

View file

@ -0,0 +1,88 @@
import type { NeutralMessage, NeutralAuthor } from "../types";
import { GITHUB_COLORS } from "./colors";
import { branchLink, emojiPrefix, tagLink, type T, buildMessage, repoBaseUrl } from "./helpers";
export function formatCreate(
payload: Record<string, unknown>,
repo: string | undefined,
author: NeutralAuthor,
t: T,
showEmoji: boolean,
): NeutralMessage {
const refType = (payload.ref_type as string) ?? "branch";
const ref = (payload.ref as string) ?? t("common.unknown");
const baseUrl = repoBaseUrl(payload, repo);
const refText = refType === "tag" ? tagLink(baseUrl, ref) : branchLink(baseUrl, ref);
const emoji = refType === "tag" ? "🏷️" : "🌿";
const em = (e: string): string => emojiPrefix(e, showEmoji);
const fields: Array<{ name: string; value: string; inline?: boolean }> = [];
fields.push({
name: t("fields.type"),
value: refType,
inline: true,
});
fields.push({
name: t("fields.name"),
value: refText,
inline: true,
});
if (payload.description) {
fields.push({
name: t("fields.description"),
value: payload.description as string,
inline: false,
});
}
return buildMessage(
{
author,
title: t("events.create.title", {
repo: repo ?? t("common.repository"),
emoji: em(emoji),
type: refType,
ref: refText,
}),
color: GITHUB_COLORS.create,
fields,
},
t,
repo,
);
}
export function formatDelete(
payload: Record<string, unknown>,
repo: string | undefined,
author: NeutralAuthor,
t: T,
showEmoji: boolean,
): NeutralMessage {
const refType = (payload.ref_type as string) ?? "branch";
const ref = (payload.ref as string) ?? t("common.unknown");
const baseUrl = repoBaseUrl(payload, repo);
const refText = refType === "tag" ? tagLink(baseUrl, ref) : branchLink(baseUrl, ref);
const emoji = refType === "tag" ? "🏷️" : "🌿";
const em = (e: string): string => emojiPrefix(e, showEmoji);
return buildMessage(
{
author,
title: t("events.delete.title", {
repo: repo ?? t("common.repository"),
emoji: em(emoji),
type: refType,
ref: refText,
}),
color: GITHUB_COLORS.delete,
},
t,
repo,
);
}

View file

@ -0,0 +1,89 @@
import type { NeutralAuthor, NeutralField, NeutralMessage } from "../types";
import { type T, buildMessage } from "./helpers";
const COLOR_WORDS: Record<string, number> = {
red: 0xf85149,
green: 0x3fb950,
yellow: 0xd29922,
blue: 0x58a6ff,
purple: 0xbc8cff,
orange: 0xdb6d28,
cyan: 0x39c5cf,
gray: 0x6e7681,
};
function parseColor(color: unknown): number | undefined {
if (typeof color !== "string") return undefined;
const key = color.trim().toLowerCase();
if (COLOR_WORDS[key]) return COLOR_WORDS[key];
const hex = /^#?([0-9a-f]{6})$/i.exec(key);
return hex ? parseInt(hex[1]!, 16) : undefined;
}
function parseFields(raw: unknown): NeutralField[] | undefined {
if (!Array.isArray(raw)) return undefined;
const fields: NeutralField[] = [];
for (const f of raw) {
if (!f || typeof f !== "object") continue;
const name = (f as Record<string, unknown>).name;
const value = (f as Record<string, unknown>).value;
if (typeof name !== "string" || typeof value !== "string") continue;
fields.push({
name: name || "\u200b",
value,
inline: (f as Record<string, unknown>).inline === true,
});
}
return fields.length > 0 ? fields : undefined;
}
/**
* Renders a `custom` webhook payload (the message schema documented in
* docs/guide/configuration.md) into a NeutralMessage. Unlike forge events the
* title is not required to start with a repo; an optional `repo` field is
* used as the `{repo}: ` prefix and footer.
*/
export function formatCustom(
payload: Record<string, unknown>,
repo: string | undefined,
author: NeutralAuthor,
t: T,
_showEmoji: boolean,
): NeutralMessage {
const title =
typeof payload.title === "string" && payload.title.trim()
? payload.title.trim()
: t("custom.title_fallback");
const payloadRepo =
typeof payload.repo === "string" && payload.repo.trim() ? payload.repo.trim() : undefined;
const effectiveRepo = payloadRepo ?? repo;
const fullTitle = effectiveRepo ? `${effectiveRepo}: ${title}` : title;
const description = typeof payload.description === "string" ? payload.description : undefined;
const url = typeof payload.url === "string" ? payload.url : undefined;
const footer = typeof payload.footer === "string" ? payload.footer : undefined;
const rawAuthor = payload.author as Record<string, unknown> | undefined;
const customAuthor: NeutralAuthor | undefined =
rawAuthor && typeof rawAuthor === "object"
? {
name: typeof rawAuthor.name === "string" && rawAuthor.name ? rawAuthor.name : author.name,
iconUrl: typeof rawAuthor.iconUrl === "string" ? rawAuthor.iconUrl : author.iconUrl,
url: typeof rawAuthor.url === "string" ? rawAuthor.url : author.url,
}
: undefined;
return buildMessage(
{
author: customAuthor,
title: fullTitle,
url,
color: parseColor(payload.color) ?? 0x6e7681,
description,
fields: parseFields(payload.fields),
// A custom message without a repo gets no `{repo}` footer at all.
footer: footer ?? (effectiveRepo ? undefined : ""),
},
t,
effectiveRepo,
);
}

View file

@ -0,0 +1,179 @@
import type { NeutralMessage, NeutralAuthor } from "../types";
import { GITHUB_COLORS } from "./colors";
import {
branchLink,
commitLink,
emojiPrefix,
tagLink,
type T,
buildMessage,
repoBaseUrl,
} from "./helpers";
export function formatDeployment(
payload: Record<string, unknown>,
repo: string | undefined,
author: NeutralAuthor,
t: T,
showEmoji: boolean,
): NeutralMessage {
const deployment = payload.deployment as {
environment?: string;
ref?: string;
sha?: string;
description?: string;
html_url?: string;
statuses_url?: string;
};
const emoji = "🚀";
const em = (e: string): string => emojiPrefix(e, showEmoji);
const env = deployment.environment ?? t("common.unknown");
const shortSha = deployment.sha?.slice(0, 7) ?? "???????";
const baseUrl = repoBaseUrl(payload, repo);
const fields: Array<{ name: string; value: string; inline?: boolean }> = [];
fields.push({
name: t("fields.status"),
value: `${em(emoji)}created`,
inline: true,
});
fields.push({
name: t("fields.environment"),
value: env,
inline: true,
});
if (deployment.ref) {
const isTag = deployment.ref.startsWith("refs/tags/");
fields.push({
name: t("fields.branch_tag"),
value: isTag ? tagLink(baseUrl, deployment.ref) : branchLink(baseUrl, deployment.ref),
inline: true,
});
}
if (deployment.sha) {
fields.push({
name: t("fields.commit"),
value: commitLink(baseUrl, deployment.sha, shortSha),
inline: true,
});
}
if (deployment.description) {
fields.push({
name: t("fields.description"),
value: deployment.description,
inline: false,
});
}
return buildMessage(
{
author,
title: t("events.deployment.title", {
repo: repo ?? t("common.repository"),
env,
state: "created",
}),
url: deployment.html_url ?? deployment.statuses_url,
color: GITHUB_COLORS.deployment_pending,
fields,
},
t,
repo,
);
}
export function formatDeploymentStatus(
payload: Record<string, unknown>,
repo: string | undefined,
author: NeutralAuthor,
t: T,
showEmoji: boolean,
): NeutralMessage {
const status = payload.deployment_status as {
state?: string;
environment?: string;
environment_url?: string;
description?: string;
};
const deployment = payload.deployment as {
sha?: string;
ref?: string;
environment?: string;
};
const state = status.state ?? "pending";
const colorKey =
state === "success"
? "deployment_success"
: state === "failure"
? "deployment_failure"
: "deployment_pending";
const emoji = state === "success" ? "✅" : state === "failure" ? "❌" : "⏳";
const em = (e: string): string => emojiPrefix(e, showEmoji);
const env = status.environment ?? deployment.environment ?? t("common.unknown");
const shortSha = deployment.sha?.slice(0, 7) ?? "???????";
const baseUrl = repoBaseUrl(payload, repo);
const fields: Array<{ name: string; value: string; inline?: boolean }> = [];
fields.push({
name: t("fields.status"),
value: `${em(emoji)}${status}`,
inline: true,
});
fields.push({
name: t("fields.environment"),
value: env,
inline: true,
});
if (deployment.ref) {
const isTag = deployment.ref.startsWith("refs/tags/");
fields.push({
name: t("fields.branch_tag"),
value: isTag ? tagLink(baseUrl, deployment.ref) : branchLink(baseUrl, deployment.ref),
inline: true,
});
}
if (deployment.sha) {
fields.push({
name: t("fields.commit"),
value: commitLink(baseUrl, deployment.sha, shortSha),
inline: true,
});
}
if (status.environment_url) {
fields.push({
name: t("fields.url"),
value: status.environment_url,
inline: false,
});
}
if (status.description) {
fields.push({
name: t("fields.description"),
value: status.description,
inline: false,
});
}
return buildMessage(
{
author,
title: t("events.deployment.title", { repo: repo ?? t("common.repository"), env, state }),
color: GITHUB_COLORS[colorKey],
fields,
},
t,
repo,
);
}

View file

@ -0,0 +1,96 @@
import type { NeutralMessage, NeutralAuthor } from "../types";
import { GITHUB_COLORS } from "./colors";
import { emojiPrefix, type T, buildMessage } from "./helpers";
export function formatDiscussion(
payload: Record<string, unknown>,
repo: string | undefined,
author: NeutralAuthor,
t: T,
showEmoji: boolean,
): NeutralMessage {
const action = (payload.action as string) ?? "created";
const discussion = payload.discussion as {
number?: number;
title?: string;
html_url?: string;
category?: { name?: string };
};
const al = t("actions." + action) ?? action;
const em = (e: string): string => emojiPrefix(e, showEmoji);
const stateEmoji =
action === "answered"
? "✅"
: action === "closed"
? "🔴"
: action === "created"
? "🟢"
: action === "deleted"
? "🗑️"
: "💬";
const category = discussion.category?.name;
return buildMessage(
{
author,
title: t("events.discussion.title", {
repo: repo ?? t("common.repository"),
number: discussion.number ?? "?",
title: discussion.title ?? t("common.untitled"),
}),
url: discussion.html_url,
color:
action === "answered"
? GITHUB_COLORS.discussion_answered
: GITHUB_COLORS.discussion_created,
description: t("events.discussion.action_discussion", {
emoji: em(stateEmoji),
action: al,
category: category ? ` in **${category}**` : "",
}),
},
t,
repo,
);
}
export function formatDiscussionComment(
payload: Record<string, unknown>,
repo: string | undefined,
author: NeutralAuthor,
t: T,
showEmoji: boolean,
): NeutralMessage {
const action = (payload.action as string) ?? "created";
const comment = payload.comment as {
body?: string;
html_url?: string;
};
const discussion = payload.discussion as {
number?: number;
title?: string;
};
const al = t("actions." + action) ?? action;
const em = (e: string): string => emojiPrefix(e, showEmoji);
const commentBody = comment.body?.slice(0, 500) ?? "";
const truncated = comment.body && comment.body.length > 500;
return buildMessage(
{
author,
title: t("events.discussion_comment.title", {
repo: repo ?? t("common.repository"),
number: discussion.number ?? "?",
title: discussion.title ?? t("common.untitled"),
}),
url: comment.html_url,
color: GITHUB_COLORS.discussion_comment,
description: `${t("events.discussion_comment.action_comment", { emoji: em("💬"), action: al })}\n\n> ${commentBody}${truncated ? "..." : ""}`,
},
t,
repo,
);
}

View file

@ -0,0 +1,26 @@
import type { NeutralMessage, NeutralAuthor } from "../types";
import { GITHUB_COLORS } from "./colors";
import { type T, buildMessage } from "./helpers";
export function formatGeneric(
eventType: string,
payload: Record<string, unknown>,
repo: string | undefined,
author: NeutralAuthor,
t: T,
_showEmoji: boolean,
): NeutralMessage {
return buildMessage(
{
author,
title: t("events.generic.title", {
repo: repo ?? t("common.repository"),
event: eventType,
action: payload.action ? `: ${payload.action}` : "",
}),
color: GITHUB_COLORS.default,
},
t,
repo,
);
}

View file

@ -0,0 +1,65 @@
import type { NeutralMessage } from "../types";
import { t as translate } from "../lib/i18n";
import type { Translations } from "../lib/i18n";
export type T = (key: string, params?: Record<string, string | number>) => string;
export function emojiPrefix(emoji: string, show: boolean): string {
return show ? `${emoji} ` : "";
}
export function makeT(tr?: Translations): T {
return (key, params) => translate(key, params, undefined, tr);
}
export function buildMessage(
partial: Omit<Partial<NeutralMessage>, "title"> & { title: string },
t: T,
repo?: string,
): NeutralMessage {
return {
...partial,
footer: partial.footer ?? t("common.footer", { repo: repo ?? t("common.github") }),
timestamp: partial.timestamp ?? new Date().toISOString(),
};
}
/**
* Base URL of the forge repo (e.g. `https://github.com/owner/repo` or a Gitea
* instance URL). Derived from `repository.html_url` in the payload so it works
* for any provider; falls back to github.com for legacy payloads.
*/
export function repoBaseUrl(payload: Record<string, unknown>, repo?: string): string | undefined {
const html = (payload.repository as { html_url?: string } | undefined)?.html_url;
if (html) return html;
return repo ? `https://github.com/${repo}` : undefined;
}
function encodeRefPath(ref: string): string {
return ref
.split("/")
.map((seg) => encodeURIComponent(seg))
.join("/");
}
/** Inline code + hyperlink for a commit, e.g. [`abc123d`](.../commit/abc123def456). */
export function commitLink(baseUrl: string | undefined, sha: string, short?: string): string {
const label = short ?? sha.slice(0, 7);
return baseUrl ? `[\`${label}\`](${baseUrl}/commit/${encodeRefPath(sha)})` : `\`${label}\``;
}
/** Inline code + hyperlink for a branch (or bare ref), e.g. [`main`](.../tree/main). */
export function branchLink(baseUrl: string | undefined, branch: string, label?: string): string {
const clean = branch.replace("refs/heads/", "").replace("refs/tags/", "");
const display = label ?? clean;
return baseUrl ? `[\`${display}\`](${baseUrl}/tree/${encodeRefPath(clean)})` : `\`${display}\``;
}
/** Inline code + hyperlink for a tag, e.g. [`v1.0`](.../releases/tag/v1.0). */
export function tagLink(baseUrl: string | undefined, tag: string, label?: string): string {
const clean = tag.replace("refs/tags/", "");
const display = label ?? clean;
return baseUrl
? `[\`${display}\`](${baseUrl}/releases/tag/${encodeRefPath(clean)})`
: `\`${display}\``;
}

View file

@ -0,0 +1,109 @@
import type { Route, WebhookEvent, NeutralMessage, NeutralAuthor } from "../types";
import type { Translations } from "../lib/i18n";
import { makeT, type T } from "./helpers";
import { formatPush } from "./push";
import { formatPullRequest } from "./pull-request";
import { formatPullRequestReview, formatPullRequestReviewComment } from "./review";
import { formatIssues } from "./issues";
import { formatIssueComment } from "./comments";
import { formatWorkflowRun, formatWorkflowJob } from "./workflow";
import { formatRelease } from "./release";
import { formatCreate, formatDelete } from "./create";
import { formatStar, formatFork } from "./repo";
import { formatCheckRun, formatCheckSuite, formatStatus } from "./check";
import { formatCommitComment } from "./commit-comment";
import { formatDeployment, formatDeploymentStatus } from "./deployment";
import { formatMember } from "./member";
import { formatLabel } from "./label";
import { formatMilestone } from "./milestone";
import { formatDiscussion, formatDiscussionComment } from "./discussion";
import { formatRepository } from "./repository";
import { formatCodeScanningAlert, formatDependabotAlert } from "./security";
import { formatGeneric } from "./generic";
import { formatPing } from "./ping";
import { formatCustom } from "./custom";
export function formatEvent(
route: Route,
event: WebhookEvent,
tr?: Translations,
showEmoji = true,
): NeutralMessage {
const { event: eventType, payload } = event;
const repo = (payload.repository as { full_name?: string })?.full_name;
const sender = (payload.sender as { login?: string })?.login;
const senderAvatar = (payload.sender as { avatar_url?: string })?.avatar_url;
const senderUrl = (payload.sender as { html_url?: string })?.html_url;
const repoUrl = (payload.repository as { html_url?: string })?.html_url;
const t: T = makeT(tr);
const author: NeutralAuthor = {
name: sender ?? t("common.unknown"),
iconUrl: senderAvatar,
url: senderUrl ?? (sender ? `https://github.com/${sender}` : undefined),
};
switch (eventType) {
case "push":
return formatPush(payload, repo, author, t, showEmoji);
case "pull_request":
return formatPullRequest(payload, repo, author, t, showEmoji);
case "pull_request_review":
return formatPullRequestReview(payload, repo, author, t, showEmoji);
case "pull_request_review_comment":
return formatPullRequestReviewComment(payload, repo, author, t, showEmoji);
case "issues":
return formatIssues(payload, repo, author, t, showEmoji);
case "issue_comment":
return formatIssueComment(payload, repo, author, t, showEmoji);
case "workflow_run":
return formatWorkflowRun(payload, repo, author, t, showEmoji);
case "workflow_job":
return formatWorkflowJob(payload, repo, author, t, showEmoji);
case "status":
return formatStatus(payload, repo, author, t, showEmoji);
case "deployment":
return formatDeployment(payload, repo, author, t, showEmoji);
case "ping":
return formatPing(payload, repo, author, t, showEmoji);
case "release":
return formatRelease(payload, repo, author, t, showEmoji);
case "create":
return formatCreate(payload, repo, author, t, showEmoji);
case "delete":
return formatDelete(payload, repo, author, t, showEmoji);
case "star":
return formatStar(payload, repo, repoUrl, author, t, showEmoji);
case "fork":
return formatFork(payload, repo, repoUrl, author, t, showEmoji);
case "check_run":
return formatCheckRun(payload, repo, author, t, showEmoji);
case "check_suite":
return formatCheckSuite(payload, repo, author, t, showEmoji);
case "commit_comment":
return formatCommitComment(payload, repo, author, t, showEmoji);
case "deployment_status":
return formatDeploymentStatus(payload, repo, author, t, showEmoji);
case "member":
return formatMember(payload, repo, author, t, showEmoji);
case "label":
return formatLabel(payload, repo, author, t, showEmoji);
case "milestone":
return formatMilestone(payload, repo, author, t, showEmoji);
case "discussion":
return formatDiscussion(payload, repo, author, t, showEmoji);
case "discussion_comment":
return formatDiscussionComment(payload, repo, author, t, showEmoji);
case "repository":
return formatRepository(payload, repo, repoUrl, author, t, showEmoji);
case "code_scanning_alert":
return formatCodeScanningAlert(payload, repo, author, t, showEmoji);
case "dependabot_alert":
return formatDependabotAlert(payload, repo, author, t, showEmoji);
case "custom":
return formatCustom(payload, repo, author, t, showEmoji);
default:
return formatGeneric(eventType, payload, repo, author, t, showEmoji);
}
}

View file

@ -0,0 +1,87 @@
import type { NeutralMessage, NeutralAuthor } from "../types";
import { GITHUB_COLORS } from "./colors";
import { emojiPrefix, type T, buildMessage } from "./helpers";
export function formatIssues(
payload: Record<string, unknown>,
repo: string | undefined,
author: NeutralAuthor,
t: T,
showEmoji: boolean,
): NeutralMessage {
const action = (payload.action as string) ?? "opened";
const issue = payload.issue as {
number?: number;
title?: string;
html_url?: string;
state?: string;
body?: string;
labels?: Array<{ name?: string; color?: string }>;
assignees?: Array<{ login?: string }>;
milestone?: { title?: string };
};
const colorKey =
action === "closed"
? "issues_closed"
: action === "reopened"
? "issues_reopened"
: action === "opened"
? "issues_opened"
: "issues_other";
const al = t("actions." + action) ?? action;
const stateEmoji = action === "closed" ? "🔴" : action === "opened" ? "🟢" : "🟣";
const em = (e: string): string => emojiPrefix(e, showEmoji);
const descriptionParts: string[] = [];
descriptionParts.push(t("events.issues.action_issue", { emoji: em(stateEmoji), action: al }));
if (issue.body) {
const truncated = issue.body.slice(0, 300);
descriptionParts.push(`\n${truncated}${issue.body.length > 300 ? "..." : ""}`);
}
const fields: Array<{ name: string; value: string; inline?: boolean }> = [];
if (issue.labels && issue.labels.length > 0) {
fields.push({
name: t("fields.labels"),
value: issue.labels.map((l) => l.name).join(", "),
inline: true,
});
}
if (issue.assignees && issue.assignees.length > 0) {
fields.push({
name: t("fields.assignees"),
value: issue.assignees.map((a) => a.login).join(", "),
inline: true,
});
}
if (issue.milestone) {
fields.push({
name: t("fields.milestone"),
value: issue.milestone.title ?? t("common.unknown"),
inline: true,
});
}
return buildMessage(
{
author,
title: t("events.issues.title", {
repo: repo ?? t("common.repository"),
number: issue.number ?? "?",
title: issue.title ?? t("common.untitled"),
}),
url: issue.html_url,
color: GITHUB_COLORS[colorKey],
description: descriptionParts.join("\n"),
fields: fields.length > 0 ? fields : undefined,
},
t,
repo,
);
}

View file

@ -0,0 +1,64 @@
import type { NeutralMessage, NeutralAuthor } from "../types";
import { GITHUB_COLORS } from "./colors";
import { emojiPrefix, type T, buildMessage } from "./helpers";
export function formatLabel(
payload: Record<string, unknown>,
repo: string | undefined,
author: NeutralAuthor,
t: T,
showEmoji: boolean,
): NeutralMessage {
const action = (payload.action as string) ?? "created";
const label = payload.label as {
name?: string;
color?: string;
description?: string;
};
const al = t("actions." + action) ?? action;
const emoji = action === "deleted" ? "🗑️" : action === "edited" ? "✏️" : "🏷️";
const em = (e: string): string => emojiPrefix(e, showEmoji);
const fields: Array<{ name: string; value: string; inline?: boolean }> = [];
if (label.name) {
fields.push({
name: t("fields.label"),
value: label.name,
inline: true,
});
}
if (label.color) {
fields.push({
name: t("fields.color"),
value: `#${label.color}`,
inline: true,
});
}
if (label.description) {
fields.push({
name: t("fields.description"),
value: label.description,
inline: false,
});
}
return buildMessage(
{
author,
title: t("events.label.title", {
repo: repo ?? t("common.repository"),
emoji: em(emoji),
action: al,
name: label.name ?? t("common.unknown"),
}),
color: GITHUB_COLORS.label,
fields: fields.length > 0 ? fields : undefined,
},
t,
repo,
);
}

View file

@ -0,0 +1,34 @@
import type { NeutralMessage, NeutralAuthor } from "../types";
import { GITHUB_COLORS } from "./colors";
import { emojiPrefix, type T, buildMessage } from "./helpers";
export function formatMember(
payload: Record<string, unknown>,
repo: string | undefined,
author: NeutralAuthor,
t: T,
showEmoji: boolean,
): NeutralMessage {
const action = (payload.action as string) ?? "added";
const member = payload.member as { login?: string } | undefined;
const al = t("actions." + action) ?? action;
const emoji = action === "added" ? "" : action === "removed" ? "" : "👤";
const em = (e: string): string => emojiPrefix(e, showEmoji);
const memberName = member?.login ?? t("common.unknown");
return buildMessage(
{
author,
title: t("events.member.title", {
repo: repo ?? t("common.repository"),
emoji: em(emoji),
action: al,
name: memberName,
}),
color: action === "added" ? GITHUB_COLORS.member_added : GITHUB_COLORS.member_removed,
},
t,
repo,
);
}

View file

@ -0,0 +1,83 @@
import type { NeutralMessage, NeutralAuthor } from "../types";
import { GITHUB_COLORS } from "./colors";
import { emojiPrefix, type T, buildMessage } from "./helpers";
export function formatMilestone(
payload: Record<string, unknown>,
repo: string | undefined,
author: NeutralAuthor,
t: T,
showEmoji: boolean,
): NeutralMessage {
const action = (payload.action as string) ?? "created";
const milestone = payload.milestone as {
title?: string;
number?: number;
state?: string;
open_issues?: number;
closed_issues?: number;
due_on?: string;
html_url?: string;
};
const al = t("actions." + action) ?? action;
const stateEmoji = milestone.state === "closed" ? "✅" : "🔵";
const em = (e: string): string => emojiPrefix(e, showEmoji);
const fields: Array<{ name: string; value: string; inline?: boolean }> = [];
if (milestone.title) {
fields.push({
name: t("fields.milestone"),
value: milestone.title,
inline: true,
});
}
if (milestone.number) {
fields.push({
name: t("fields.number"),
value: `#${milestone.number}`,
inline: true,
});
}
if (milestone.open_issues != null && milestone.closed_issues != null) {
const total = milestone.open_issues + milestone.closed_issues;
const pct = total > 0 ? Math.round((milestone.closed_issues / total) * 100) : 0;
const bar = pct >= 75 ? "🟢🟢🟢" : pct >= 50 ? "🟡🟡" : pct > 0 ? "🟠" : "⬜";
fields.push({
name: t("fields.progress"),
value: `${bar} ${milestone.closed_issues}/${total} (${pct}%)`,
inline: false,
});
}
if (milestone.due_on) {
fields.push({
name: t("fields.due"),
value: milestone.due_on.split("T")[0] ?? "",
inline: true,
});
}
return buildMessage(
{
author,
title: t("events.milestone.title", {
repo: repo ?? t("common.repository"),
emoji: em(stateEmoji),
action: al,
title: milestone.title ?? t("common.unknown"),
}),
url: milestone.html_url,
color:
milestone.state === "closed"
? GITHUB_COLORS.milestone_closed
: GITHUB_COLORS.milestone_opened,
fields: fields.length > 0 ? fields : undefined,
},
t,
repo,
);
}

View file

@ -0,0 +1,36 @@
import type { NeutralMessage, NeutralAuthor } from "../types";
import { GITHUB_COLORS } from "./colors";
import { type T, buildMessage } from "./helpers";
export function formatPing(
payload: Record<string, unknown>,
repo: string | undefined,
author: NeutralAuthor,
t: T,
_showEmoji: boolean,
): NeutralMessage {
const zen = payload.zen as string | undefined;
const hookId = payload.hook_id as number | undefined;
const fields: Array<{ name: string; value: string; inline?: boolean }> = [];
if (hookId != null) {
fields.push({
name: t("fields.details"),
value: String(hookId),
inline: true,
});
}
return buildMessage(
{
author,
title: t("events.ping.title", { repo: repo ?? t("common.repository") }),
color: GITHUB_COLORS.default,
description: zen,
fields,
},
t,
repo,
);
}

View file

@ -0,0 +1,116 @@
import type { NeutralMessage, NeutralAuthor, NeutralAction } from "../types";
import { GITHUB_COLORS } from "./colors";
import { branchLink, emojiPrefix, type T, buildMessage, repoBaseUrl } from "./helpers";
export function formatPullRequest(
payload: Record<string, unknown>,
repo: string | undefined,
author: NeutralAuthor,
t: T,
showEmoji: boolean,
): NeutralMessage {
const action = (payload.action as string) ?? "opened";
const pr = payload.pull_request as {
number?: number;
title?: string;
html_url?: string;
state?: string;
draft?: boolean;
merged?: boolean;
head?: { ref?: string; sha?: string; repo?: { html_url?: string } };
base?: { ref?: string; repo?: { html_url?: string } };
body?: string;
labels?: Array<{ name?: string; color?: string }>;
changed_files?: number;
additions?: number;
deletions?: number;
};
const colorKey = pr.merged
? "pull_request_merged"
: action === "closed"
? "pull_request_closed"
: action === "ready_for_review"
? "pull_request_ready_for_review"
: action === "opened"
? "pull_request_opened"
: "pull_request_other";
const al = t("actions." + action) ?? action;
const stateEmoji = pr.merged
? "🟣"
: action === "closed"
? "🔴"
: action === "opened"
? "🟢"
: "🔵";
const em = (e: string): string => emojiPrefix(e, showEmoji);
const descriptionParts: string[] = [];
descriptionParts.push(t("events.pr.action_pr", { emoji: em(stateEmoji), action: al }));
if (pr.body) {
const truncated = pr.body.slice(0, 300);
descriptionParts.push(`\n${truncated}${pr.body.length > 300 ? "..." : ""}`);
}
const fields: Array<{ name: string; value: string; inline?: boolean }> = [];
if (pr.head?.ref && pr.base?.ref) {
const baseUrl = repoBaseUrl(payload, repo);
const headUrl = pr.head.repo?.html_url ?? baseUrl;
const baseRepoUrl = pr.base.repo?.html_url ?? baseUrl;
fields.push({
name: t("fields.branch"),
value: `${branchLink(headUrl, pr.head.ref)}${branchLink(baseRepoUrl, pr.base.ref)}`,
inline: true,
});
}
if (pr.changed_files != null || pr.additions != null || pr.deletions != null) {
const parts: string[] = [];
if (pr.additions != null) parts.push(`+${pr.additions}`);
if (pr.deletions != null) parts.push(`-${pr.deletions}`);
if (pr.changed_files != null) parts.push(t("common.n_files", { count: pr.changed_files }));
fields.push({
name: t("fields.changes"),
value: parts.join(" | "),
inline: true,
});
}
if (pr.labels && pr.labels.length > 0) {
fields.push({
name: t("fields.labels"),
value: pr.labels.map((l) => l.name).join(", "),
inline: true,
});
}
const [repoOwner, repoName] = (repo ?? "/").split("/", 2);
const actionable = pr.state === "open" && !!repoOwner && !!repoName && pr.number != null;
const actions: NeutralAction[] | undefined = actionable
? [
{ id: `ghpr|merge|${repoOwner}|${repoName}|${pr.number}`, label: "合并", style: "primary" },
{ id: `ghpr|close|${repoOwner}|${repoName}|${pr.number}`, label: "关闭", style: "danger" },
]
: undefined;
return buildMessage(
{
author,
title: t("events.pr.title", {
repo: repo ?? t("common.repository"),
number: pr.number ?? "?",
title: pr.title ?? t("common.untitled"),
}),
url: pr.html_url,
color: GITHUB_COLORS[colorKey],
description: descriptionParts.join("\n"),
fields: fields.length > 0 ? fields : undefined,
actions,
},
t,
repo,
);
}

View file

@ -0,0 +1,109 @@
import type { NeutralMessage, NeutralAuthor } from "../types";
import { GITHUB_COLORS } from "./colors";
import { branchLink, emojiPrefix, tagLink, type T, buildMessage, repoBaseUrl } from "./helpers";
export function formatPush(
payload: Record<string, unknown>,
repo: string | undefined,
author: NeutralAuthor,
t: T,
showEmoji: boolean,
): NeutralMessage {
const rawRef = (payload.ref as string) ?? "";
const isTagPush = rawRef.startsWith("refs/tags/");
const ref = rawRef.replace("refs/heads/", "").replace("refs/tags/", "tag: ");
const commits = (payload.commits ?? []) as Array<{
id?: string;
message?: string;
author?: { name?: string; email?: string };
added?: string[];
removed?: string[];
modified?: string[];
}>;
const count = commits.length;
const compareUrl = payload.compare as string | undefined;
const baseUrl = repoBaseUrl(payload, repo);
const forced = payload.forced as boolean | undefined;
const created = payload.created as boolean | undefined;
const em = (e: string): string => emojiPrefix(e, showEmoji);
const descriptionParts: string[] = [];
if (forced) {
descriptionParts.push(em("⚠️") + t("events.push.force_push"));
}
if (created) {
descriptionParts.push(em("🆕") + t("events.push.branch_created"));
}
descriptionParts.push(
t("events.push.commits_pushed", {
count,
s: count !== 1 ? "s" : "",
ref: isTagPush ? tagLink(baseUrl, rawRef, ref) : branchLink(baseUrl, rawRef, ref),
}),
);
if (compareUrl) {
descriptionParts.push(t("events.push.view_comparison", { url: compareUrl }));
}
const fields: Array<{ name: string; value: string; inline?: boolean }> = [];
const commitField = (c: (typeof commits)[number]): { name: string; value: string } => {
const shortId = c.id?.slice(0, 7) ?? "???????";
const msg = (c.message?.split("\n")[0] ?? "").slice(0, 72) || t("common.no_message");
const url = baseUrl && c.id ? `${baseUrl}/commit/${c.id}` : null;
const hash = url ? `[\`${shortId}\`](${url})` : `\`${shortId}\``;
return { name: `\u200b`, value: `${hash} ${msg}` };
};
if (count <= 5) {
for (const c of commits) {
fields.push({ ...commitField(c), inline: false });
}
} else {
const first3 = commits.slice(0, 3);
for (const c of first3) {
fields.push({ ...commitField(c), inline: false });
}
fields.push({
name: `\u200b`,
value: t("common.and_n_more", { count: count - 3 }),
inline: false,
});
}
const added = commits.flatMap((c) => c.added ?? []);
const removed = commits.flatMap((c) => c.removed ?? []);
const modified = commits.flatMap((c) => c.modified ?? []);
if (added.length > 0 || removed.length > 0 || modified.length > 0) {
const changes: string[] = [];
if (added.length > 0) changes.push(t("events.push.added", { count: added.length }));
if (removed.length > 0) changes.push(t("events.push.removed", { count: removed.length }));
if (modified.length > 0) changes.push(t("events.push.modified", { count: modified.length }));
fields.push({
name: t("fields.changes"),
value: changes.join(" | "),
inline: true,
});
}
return buildMessage(
{
author,
title: t("events.push.title", {
count,
s: count !== 1 ? "s" : "",
repo: repo ?? t("common.repository"),
}),
url: compareUrl,
color: GITHUB_COLORS.push,
description: descriptionParts.join("\n"),
fields: fields.length > 0 ? fields : undefined,
},
t,
repo,
);
}

View file

@ -0,0 +1,62 @@
import type { NeutralMessage, NeutralAuthor } from "../types";
import { GITHUB_COLORS } from "./colors";
import { emojiPrefix, type T, buildMessage } from "./helpers";
export function formatRelease(
payload: Record<string, unknown>,
repo: string | undefined,
author: NeutralAuthor,
t: T,
showEmoji: boolean,
): NeutralMessage {
const action = (payload.action as string) ?? "published";
const release = payload.release as {
tag_name?: string;
name?: string;
html_url?: string;
body?: string;
prerelease?: boolean;
draft?: boolean;
author?: { login?: string };
};
const isPrerelease = release.prerelease;
const colorKey =
action === "deleted"
? "release_deleted"
: isPrerelease
? "release_prerelease"
: "release_published";
const al = t("actions." + action) ?? action;
const emoji = action === "deleted" ? "🗑️" : isPrerelease ? "⚠️" : "🚀";
const em = (e: string): string => emojiPrefix(e, showEmoji);
const descriptionParts: string[] = [];
descriptionParts.push(
t("events.release.action_release", {
emoji: em(emoji),
action: al,
tag: release.tag_name ?? t("common.unknown"),
}),
);
if (release.body) {
const truncated = release.body.slice(0, 300);
descriptionParts.push(`\n${truncated}${release.body.length > 300 ? "..." : ""}`);
}
return buildMessage(
{
author,
title: t("events.release.title", {
repo: repo ?? t("common.repository"),
name: release.name ?? release.tag_name ?? "Release",
}),
url: release.html_url,
color: GITHUB_COLORS[colorKey],
description: descriptionParts.join("\n"),
},
t,
repo,
);
}

View file

@ -0,0 +1,58 @@
import type { NeutralMessage, NeutralAuthor } from "../types";
import { GITHUB_COLORS } from "./colors";
import { emojiPrefix, type T, buildMessage } from "./helpers";
export function formatStar(
payload: Record<string, unknown>,
repo: string | undefined,
repoUrl: string | undefined,
author: NeutralAuthor,
t: T,
showEmoji: boolean,
): NeutralMessage {
const action = (payload.action as string) ?? "created";
const actionLabel = action === "created" ? t("events.star.starred") : t("events.star.unstarred");
const em = (e: string): string => emojiPrefix(e, showEmoji);
return buildMessage(
{
author,
title: t("events.star.title", {
repo: repo ?? t("common.repository"),
emoji: em(action === "created" ? "⭐️" : "💫"),
label: actionLabel,
}),
url: repoUrl,
color: GITHUB_COLORS.star,
},
t,
repo,
);
}
export function formatFork(
payload: Record<string, unknown>,
repo: string | undefined,
repoUrl: string | undefined,
author: NeutralAuthor,
t: T,
showEmoji: boolean,
): NeutralMessage {
const forkee = payload.forkee as { full_name?: string; html_url?: string } | undefined;
const em = (e: string): string => emojiPrefix(e, showEmoji);
return buildMessage(
{
author,
title: t("events.fork.title", {
repo: repo ?? t("common.repository"),
emoji: em("🍴"),
forkee: forkee?.full_name ?? t("common.unknown"),
}),
url: forkee?.html_url ?? repoUrl,
color: GITHUB_COLORS.fork,
},
t,
repo,
);
}

View file

@ -0,0 +1,92 @@
import type { NeutralMessage, NeutralAuthor } from "../types";
import { GITHUB_COLORS } from "./colors";
import { emojiPrefix, type T, buildMessage } from "./helpers";
export function formatRepository(
payload: Record<string, unknown>,
repo: string | undefined,
repoUrl: string | undefined,
author: NeutralAuthor,
t: T,
showEmoji: boolean,
): NeutralMessage {
const action = (payload.action as string) ?? "created";
const al = t("actions." + action) ?? action;
const em = (e: string): string => emojiPrefix(e, showEmoji);
const fields: Array<{ name: string; value: string; inline?: boolean }> = [];
if (action === "renamed") {
const changes = payload.changes as { name?: { from?: string } } | undefined;
const newName =
(payload.repository as { full_name?: string })?.full_name ?? t("common.unknown");
fields.push({
name: t("fields.renamed"),
value: changes?.name?.from ? `${changes.name.from}${newName}` : newName,
inline: false,
});
}
if (action === "transferred") {
const changes = payload.changes as { owner?: { from?: { login?: string } } } | undefined;
const newOwner =
(payload.repository as { owner?: { login?: string } })?.owner?.login ?? t("common.unknown");
fields.push({
name: t("fields.transferred"),
value: changes?.owner?.from?.login ? `${changes.owner.from.login}${newOwner}` : newOwner,
inline: false,
});
}
const repoData = payload.repository as {
visibility?: string;
fork?: boolean;
description?: string | null;
};
const isCreateOrVisibility =
action === "created" || action === "publicized" || action === "privatized";
if (isCreateOrVisibility) {
if (repoData.visibility) {
fields.push({
name: t("events.repository.visibility"),
value: t("events.repository." + repoData.visibility) ?? repoData.visibility,
inline: true,
});
}
if (repoData.fork) {
fields.push({
name: t("common.repository"),
value: t("events.repository.is_fork"),
inline: true,
});
}
}
const descriptionParts: string[] = [];
if (isCreateOrVisibility && repoUrl) {
descriptionParts.push(`[${em("🔗")}${t("events.repository.open")}](${repoUrl})`);
}
if (isCreateOrVisibility && repoData.description) {
descriptionParts.push(`> ${repoData.description}`);
}
return buildMessage(
{
author,
title: t("events.repository.title", {
repo: repo ?? t("common.repository"),
emoji: em("📦"),
action: al,
}),
url: repoUrl,
color: GITHUB_COLORS.repository,
description: descriptionParts.length > 0 ? descriptionParts.join("\n") : undefined,
fields: fields.length > 0 ? fields : undefined,
},
t,
repo,
);
}

View file

@ -0,0 +1,114 @@
import type { NeutralMessage, NeutralAuthor } from "../types";
import { GITHUB_COLORS } from "./colors";
import { emojiPrefix, type T, buildMessage } from "./helpers";
export function formatPullRequestReview(
payload: Record<string, unknown>,
repo: string | undefined,
author: NeutralAuthor,
t: T,
showEmoji: boolean,
): NeutralMessage {
const action = (payload.action as string) ?? "submitted";
const review = payload.review as {
state?: string;
body?: string;
html_url?: string;
};
const pr = payload.pull_request as {
number?: number;
title?: string;
html_url?: string;
};
const state = review.state ?? "commented";
const colorKey =
state === "approved"
? "pull_request_review_approved"
: state === "changes_requested"
? "pull_request_review_changes"
: "pull_request_review_commented";
const stateEmoji = state === "approved" ? "✅" : state === "changes_requested" ? "🔴" : "💬";
const al = t("actions." + action) ?? state;
const em = (e: string): string => emojiPrefix(e, showEmoji);
const descriptionParts: string[] = [];
descriptionParts.push(t("events.pr_review.action_review", { emoji: em(stateEmoji), action: al }));
if (review.body) {
const truncated = review.body.slice(0, 500);
descriptionParts.push(`\n> ${truncated}${review.body.length > 500 ? "..." : ""}`);
}
return buildMessage(
{
author,
title: t("events.pr_review.title", {
repo: repo ?? t("common.repository"),
number: pr.number ?? "?",
title: pr.title ?? t("common.untitled"),
}),
url: review.html_url,
color: GITHUB_COLORS[colorKey],
description: descriptionParts.join("\n"),
},
t,
repo,
);
}
export function formatPullRequestReviewComment(
payload: Record<string, unknown>,
repo: string | undefined,
author: NeutralAuthor,
t: T,
showEmoji: boolean,
): NeutralMessage {
const action = (payload.action as string) ?? "created";
const comment = payload.comment as {
body?: string;
path?: string;
position?: number | null;
html_url?: string;
};
const pr = payload.pull_request as {
number?: number;
title?: string;
};
const al = t("actions." + action) ?? action;
const em = (e: string): string => emojiPrefix(e, showEmoji);
const commentBody = comment.body?.slice(0, 400) ?? "";
const truncated = comment.body && comment.body.length > 400;
const fields: Array<{ name: string; value: string; inline?: boolean }> = [];
if (comment.path) {
const loc =
comment.position != null
? t("events.pr_review_comment.line", { position: comment.position })
: "";
fields.push({
name: t("fields.file"),
value: `\`${comment.path}\`${loc}`,
inline: false,
});
}
return buildMessage(
{
author,
title: t("events.pr_review_comment.title", {
repo: repo ?? t("common.repository"),
number: pr.number ?? "?",
title: pr.title ?? t("common.untitled"),
}),
url: comment.html_url,
color: GITHUB_COLORS.pull_request_review_commented,
description: `${t("events.pr_review_comment.action_inline", { emoji: em("💬"), action: al })}\n\n> ${commentBody}${truncated ? "..." : ""}`,
fields: fields.length > 0 ? fields : undefined,
},
t,
repo,
);
}

View file

@ -0,0 +1,173 @@
import type { NeutralMessage, NeutralAuthor } from "../types";
import { GITHUB_COLORS } from "./colors";
import { emojiPrefix, type T, buildMessage } from "./helpers";
export function formatCodeScanningAlert(
payload: Record<string, unknown>,
repo: string | undefined,
author: NeutralAuthor,
t: T,
showEmoji: boolean,
): NeutralMessage {
const action = (payload.action as string) ?? "created";
const alert = payload.alert as {
rule?: { id?: string; severity?: string; description?: string };
most_recent_instance?: { location?: { path?: string } };
state?: string;
};
const severity = alert.rule?.severity ?? "warning";
const colorKey =
severity === "critical"
? "code_scanning_critical"
: severity === "high"
? "code_scanning_high"
: severity === "medium"
? "code_scanning_medium"
: "code_scanning_low";
const severityEmoji =
severity === "critical"
? "🔴"
: severity === "high"
? "🟠"
: severity === "medium"
? "🟡"
: "⚪";
const al = t("actions." + action) ?? action;
const em = (e: string): string => emojiPrefix(e, showEmoji);
const fields: Array<{ name: string; value: string; inline?: boolean }> = [];
fields.push({
name: t("fields.severity"),
value: `${em(severityEmoji)}${severity}`,
inline: true,
});
if (alert.rule?.id) {
fields.push({
name: t("fields.rule"),
value: alert.rule.id,
inline: true,
});
}
if (alert.most_recent_instance?.location?.path) {
fields.push({
name: t("fields.file"),
value: `\`${alert.most_recent_instance.location.path}\``,
inline: false,
});
}
if (alert.rule?.description) {
fields.push({
name: t("fields.description"),
value: alert.rule.description,
inline: false,
});
}
return buildMessage(
{
author,
title: t("events.code_scanning.title", {
repo: repo ?? t("common.repository"),
action: al,
}),
color: GITHUB_COLORS[colorKey],
fields,
},
t,
repo,
);
}
export function formatDependabotAlert(
payload: Record<string, unknown>,
repo: string | undefined,
author: NeutralAuthor,
t: T,
showEmoji: boolean,
): NeutralMessage {
const action = (payload.action as string) ?? "created";
const alert = payload.alert as {
security_advisory?: { severity?: string; summary?: string; description?: string };
security_vulnerability?: {
package?: { name?: string };
vulnerable_version_range?: string;
first_patched_version?: { identifier?: string };
};
state?: string;
dependency?: { package?: { name?: string } };
html_url?: string;
};
const severity = alert.security_advisory?.severity ?? "medium";
const colorKey =
severity === "critical"
? "dependabot_critical"
: severity === "high"
? "dependabot_high"
: severity === "medium"
? "dependabot_medium"
: "dependabot_low";
const severityEmoji =
severity === "critical"
? "🔴"
: severity === "high"
? "🟠"
: severity === "medium"
? "🟡"
: "⚪";
const al = t("actions." + action) ?? action;
const em = (e: string): string => emojiPrefix(e, showEmoji);
const fields: Array<{ name: string; value: string; inline?: boolean }> = [];
fields.push({
name: t("fields.severity"),
value: `${em(severityEmoji)}${severity}`,
inline: true,
});
const pkgName = alert.security_vulnerability?.package?.name ?? alert.dependency?.package?.name;
if (pkgName) {
fields.push({
name: t("fields.package"),
value: pkgName,
inline: true,
});
}
if (alert.security_vulnerability?.vulnerable_version_range) {
const patched = alert.security_vulnerability.first_patched_version?.identifier;
fields.push({
name: t("fields.vulnerable_range"),
value: `${alert.security_vulnerability.vulnerable_version_range}${patched ? ` → fix: \`${patched}\`` : ""}`,
inline: false,
});
}
if (alert.security_advisory?.summary) {
fields.push({
name: t("fields.summary"),
value: alert.security_advisory.summary,
inline: false,
});
}
return buildMessage(
{
author,
title: t("events.dependabot.title", { repo: repo ?? t("common.repository"), action: al }),
url: alert.html_url,
color: GITHUB_COLORS[colorKey],
fields,
},
t,
repo,
);
}

View file

@ -0,0 +1,194 @@
import type { NeutralMessage, NeutralAuthor } from "../types";
import { GITHUB_COLORS, WORKFLOW_CONCLUSION_EMOJI } from "./colors";
import { emojiPrefix, type T, buildMessage, branchLink, commitLink, repoBaseUrl } from "./helpers";
export function formatWorkflowJob(
payload: Record<string, unknown>,
repo: string | undefined,
author: NeutralAuthor,
t: T,
showEmoji: boolean,
): NeutralMessage {
const job = payload.workflow_job as {
name?: string;
status?: string;
conclusion?: string | null;
head_branch?: string;
head_sha?: string;
html_url?: string;
workflow_name?: string;
run_id?: number;
};
const status =
job.status === "queued"
? "queued"
: job.status === "in_progress"
? "running"
: (job.conclusion ?? "pending");
const emoji = WORKFLOW_CONCLUSION_EMOJI[status] ?? "⏳";
const em = (e: string): string => emojiPrefix(e, showEmoji);
const baseUrl = repoBaseUrl(payload, repo);
const colorKey =
status === "success"
? "workflow_run_success"
: status === "failure"
? "workflow_run_failure"
: "workflow_run_other";
const fields: Array<{ name: string; value: string; inline?: boolean }> = [];
fields.push({
name: t("fields.status"),
value: `${em(emoji)}${status}`,
inline: true,
});
if (job.name) {
fields.push({
name: t("fields.job"),
value: job.name,
inline: true,
});
}
if (job.workflow_name) {
fields.push({
name: t("fields.workflow"),
value: job.workflow_name,
inline: true,
});
}
if (job.head_branch) {
fields.push({
name: t("fields.branch"),
value: branchLink(baseUrl, job.head_branch),
inline: true,
});
}
if (job.head_sha) {
fields.push({
name: t("fields.commit"),
value: commitLink(baseUrl, job.head_sha),
inline: true,
});
}
return buildMessage(
{
author,
title: t("events.workflow_job.title", {
repo: repo ?? t("common.repository"),
name: job.name ?? "Job",
conclusion: status,
}),
url: job.html_url,
color: GITHUB_COLORS[colorKey],
fields,
},
t,
repo,
);
}
export function formatWorkflowRun(
payload: Record<string, unknown>,
repo: string | undefined,
author: NeutralAuthor,
t: T,
showEmoji: boolean,
): NeutralMessage {
const workflow = payload.workflow_run as {
id?: number;
name?: string;
conclusion?: string;
html_url?: string;
head_branch?: string;
run_number?: number;
created_at?: string;
updated_at?: string;
elapsed_seconds?: number;
jobs?: Array<{ name?: string; conclusion?: string }>;
};
const action = payload.action as string | undefined;
const status =
action === "in_progress"
? "running"
: action === "requested"
? "queued"
: (workflow.conclusion ?? "pending");
const emoji = WORKFLOW_CONCLUSION_EMOJI[status] ?? "⏳";
const em = (e: string): string => emojiPrefix(e, showEmoji);
const baseUrl = repoBaseUrl(payload, repo);
const colorKey =
status === "success"
? "workflow_run_success"
: status === "failure"
? "workflow_run_failure"
: "workflow_run_other";
const fields: Array<{ name: string; value: string; inline?: boolean }> = [];
fields.push({
name: t("fields.status"),
value: `${em(emoji)}${status}`,
inline: true,
});
if (workflow.jobs?.length) {
const jobLines = workflow.jobs.map(
(j) => `${em(WORKFLOW_CONCLUSION_EMOJI[j.conclusion ?? ""] ?? "⏳")}${j.name ?? ""}`,
);
fields.push({
name: t("fields.job"),
value: jobLines.join("\n"),
inline: false,
});
}
if (workflow.head_branch) {
fields.push({
name: t("fields.branch"),
value: branchLink(baseUrl, workflow.head_branch),
inline: true,
});
}
if (workflow.run_number) {
fields.push({
name: t("fields.run"),
value: `#${workflow.run_number}`,
inline: true,
});
}
if (workflow.elapsed_seconds != null) {
const mins = Math.floor(workflow.elapsed_seconds / 60);
const secs = workflow.elapsed_seconds % 60;
fields.push({
name: t("fields.duration"),
value: `${mins}m ${secs}s`,
inline: true,
});
}
return buildMessage(
{
author,
title: t("events.workflow_run.title", {
repo: repo ?? t("common.repository"),
name: workflow.name ?? "Workflow",
conclusion: status,
}),
url: workflow.html_url,
color: GITHUB_COLORS[colorKey],
fields,
updateKey: repo && workflow.id != null ? `workflow_run:${repo}:${workflow.id}` : undefined,
},
t,
repo,
);
}

258
server/lib/github/oauth.ts Normal file
View file

@ -0,0 +1,258 @@
import { Octokit } from "octokit";
import { saveToken, getToken } from "./store";
export function getOAuthURL(clientId: string, state: string): string {
return `https://github.com/login/oauth/authorize?client_id=${clientId}&scope=repo&state=${state}`;
}
function b64url(buf: ArrayBuffer | string): string {
const bytes = typeof buf === "string" ? new TextEncoder().encode(buf) : new Uint8Array(buf);
let s = "";
for (const b of bytes) s += String.fromCharCode(b);
return btoa(s).replace(/=+$/, "").replace(/\+/g, "-").replace(/\//g, "_");
}
function pemToBinary(pem: string): ArrayBuffer {
const base64 = pem
.replace(/-----BEGIN [^-]+-----/, "")
.replace(/-----END [^-]+-----/, "")
.replace(/\s+/g, "");
const bin = atob(base64);
const bytes = new Uint8Array(bin.length);
for (let i = 0; i < bin.length; i++) bytes[i] = bin.charCodeAt(i);
return bytes.buffer;
}
/** GitHub App JWT (RS256, PKCS#8 PEM key), valid ~10 minutes. */
async function createAppJwt(appId: string, privateKey: string): Promise<string> {
const now = Math.floor(Date.now() / 1000);
const header = b64url(JSON.stringify({ alg: "RS256", typ: "JWT" }));
const payload = b64url(JSON.stringify({ iat: now - 60, exp: now + 600, iss: appId }));
const data = `${header}.${payload}`;
const key = await crypto.subtle.importKey(
"pkcs8",
pemToBinary(privateKey),
{ name: "RSASSA-PKCS1-v1_5", hash: "SHA-256" },
false,
["sign"],
);
const sig = await crypto.subtle.sign("RSASSA-PKCS1-v1_5", key, new TextEncoder().encode(data));
return `${data}.${b64url(sig)}`;
}
/**
* Look up the account (org/user login) that owns a GitHub App installation,
* using an App JWT. Returns null when the App credentials are missing or the
* lookup fails (the caller falls back to an anonymous installation group).
*/
export async function getInstallationAccount(
appId: string,
privateKey: string,
installationId: number,
): Promise<string | null> {
if (!appId || !privateKey) return null;
try {
const jwt = await createAppJwt(appId, privateKey);
const res = await fetch(`https://api.github.com/app/installations/${installationId}`, {
headers: {
Authorization: `Bearer ${jwt}`,
Accept: "application/vnd.github+json",
"X-GitHub-Api-Version": "2022-11-28",
},
});
if (!res.ok) return null;
const data = (await res.json()) as { account?: { login?: string } };
return data.account?.login ?? null;
} catch {
return null;
}
}
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 });
}
/**
* Map an Octokit REST error to a stable, translatable code the caller can
* turn into a user-facing message.
*/
function mapGitHubError(err: unknown): Error {
const status = (err as { status?: number })?.status;
if (status === 401) return new Error("GITHUB_TOKEN_EXPIRED");
if (status === 403) return new Error("GITHUB_FORBIDDEN");
if (status === 404) return new Error("GITHUB_NOT_FOUND");
return err instanceof Error ? err : new Error(String(err));
}
async function requireOctokit(kv: KVNamespace, githubUserId: string): Promise<Octokit> {
const octokit = await getUserOctokit(githubUserId, kv);
if (!octokit) throw new Error("GITHUB_TOKEN_EXPIRED");
return octokit;
}
/**
* Post an issue/PR comment AS the given GitHub user (their OAuth token),
* so the comment shows up under their own identity instead of the bot.
*/
export async function commentAsUser(
kv: KVNamespace,
githubUserId: string,
owner: string,
repo: string,
issueNumber: number,
body: string,
): Promise<{ htmlUrl: string; login: string }> {
const octokit = await requireOctokit(kv, githubUserId);
try {
const { data: me } = await octokit.rest.users.getAuthenticated();
const res = await octokit.rest.issues.createComment({
owner,
repo,
issue_number: issueNumber,
body,
});
return { htmlUrl: res.data.html_url, login: me.login };
} catch (err) {
throw mapGitHubError(err);
}
}
/** Fetch a single issue comment's current body (used to prefill the edit modal). */
export async function getCommentAsUser(
kv: KVNamespace,
githubUserId: string,
owner: string,
repo: string,
commentId: number,
): Promise<{ body: string; login: string }> {
const octokit = await requireOctokit(kv, githubUserId);
try {
const res = await octokit.rest.issues.getComment({ owner, repo, comment_id: commentId });
return { body: res.data.body ?? "", login: res.data.user?.login ?? "" };
} catch (err) {
throw mapGitHubError(err);
}
}
/** Edit an existing issue/PR comment. GitHub enforces permission (403 if not allowed). */
export async function editCommentAsUser(
kv: KVNamespace,
githubUserId: string,
owner: string,
repo: string,
commentId: number,
body: string,
): Promise<{ htmlUrl: string }> {
const octokit = await requireOctokit(kv, githubUserId);
try {
const res = await octokit.rest.issues.updateComment({
owner,
repo,
comment_id: commentId,
body,
});
return { htmlUrl: res.data.html_url };
} catch (err) {
throw mapGitHubError(err);
}
}
/** Delete an existing issue/PR comment. GitHub enforces permission (403 if not allowed). */
export async function deleteCommentAsUser(
kv: KVNamespace,
githubUserId: string,
owner: string,
repo: string,
commentId: number,
): Promise<void> {
const octokit = await requireOctokit(kv, githubUserId);
try {
await octokit.rest.issues.deleteComment({ owner, repo, comment_id: commentId });
} catch (err) {
throw mapGitHubError(err);
}
}
/** Merge a pull request as the linked GitHub user. GitHub enforces permission (403 if not allowed). */
export async function mergePullRequestAsUser(
kv: KVNamespace,
githubUserId: string,
owner: string,
repo: string,
pullNumber: number,
method: "merge" | "squash" | "rebase" = "squash",
): Promise<void> {
const octokit = await requireOctokit(kv, githubUserId);
try {
await octokit.rest.pulls.merge({
owner,
repo,
pull_number: pullNumber,
merge_method: method,
});
} catch (err) {
throw mapGitHubError(err);
}
}
/** Close a pull request as the linked GitHub user. GitHub enforces permission (403 if not allowed). */
export async function closePullRequestAsUser(
kv: KVNamespace,
githubUserId: string,
owner: string,
repo: string,
pullNumber: number,
): Promise<void> {
const octokit = await requireOctokit(kv, githubUserId);
try {
await octokit.rest.pulls.update({
owner,
repo,
pull_number: pullNumber,
state: "closed",
});
} catch (err) {
throw mapGitHubError(err);
}
}

121
server/lib/github/store.ts Normal file
View file

@ -0,0 +1,121 @@
interface StoredToken {
userId: string;
accessToken: string;
expiresAt: number;
}
async function hashToken(token: string): Promise<string> {
const data = new TextEncoder().encode(token);
const hash = await crypto.subtle.digest("SHA-256", data);
return Array.from(new Uint8Array(hash))
.map((b) => b.toString(16).padStart(2, "0"))
.join("");
}
export async function saveToken(
kv: KVNamespace,
userId: string,
accessToken: string,
expiresInSeconds: number,
): Promise<void> {
const token: StoredToken = {
userId,
accessToken,
expiresAt: Date.now() + expiresInSeconds * 1000,
};
const ttl = Math.max(Math.floor(expiresInSeconds * 0.9), 60);
await kv.put(`token:${userId}`, JSON.stringify(token), { expirationTtl: ttl });
const tokenHash = await hashToken(accessToken);
await kv.put(`token-reverse:${tokenHash}`, userId, { 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 removeToken(kv: KVNamespace, userId: string): Promise<void> {
const raw = await kv.get(`token:${userId}`, "json");
if (raw) {
const t = raw as StoredToken;
const tokenHash = await hashToken(t.accessToken);
await kv.delete(`token-reverse:${tokenHash}`);
}
await kv.delete(`token:${userId}`);
}
export async function findUserIdByToken(
kv: KVNamespace,
accessToken: string,
): Promise<string | null> {
const tokenHash = await hashToken(accessToken);
return await kv.get(`token-reverse:${tokenHash}`, "text");
}
/**
* Link a Discord user id to a GitHub user id so that bot commands can act
* as that GitHub account. The actual OAuth token lives under `token:{githubUserId}`.
*/
export async function saveDiscordLink(
db: D1Database,
discordUserId: string,
githubUserId: string,
): Promise<void> {
await db
.prepare("INSERT OR REPLACE INTO discord_links (discord_user_id, github_user_id) VALUES (?, ?)")
.bind(discordUserId, githubUserId)
.run();
}
export async function getDiscordLink(
db: D1Database,
discordUserId: string,
): Promise<string | null> {
const { results } = await db
.prepare("SELECT github_user_id FROM discord_links WHERE discord_user_id = ?")
.bind(discordUserId)
.all<{ github_user_id: string }>();
return results[0]?.github_user_id ?? null;
}
export async function removeDiscordLink(db: D1Database, discordUserId: string): Promise<void> {
await db.prepare("DELETE FROM discord_links WHERE discord_user_id = ?").bind(discordUserId).run();
}
export async function saveTelegramLink(
db: D1Database,
telegramUserId: string,
githubUserId: string,
): Promise<void> {
await db
.prepare(
"INSERT OR REPLACE INTO telegram_links (telegram_user_id, github_user_id) VALUES (?, ?)",
)
.bind(telegramUserId, githubUserId)
.run();
}
export async function getTelegramLink(
db: D1Database,
telegramUserId: string,
): Promise<string | null> {
const { results } = await db
.prepare("SELECT github_user_id FROM telegram_links WHERE telegram_user_id = ?")
.bind(telegramUserId)
.all<{ github_user_id: string }>();
return results[0]?.github_user_id ?? null;
}
export async function removeTelegramLink(db: D1Database, telegramUserId: string): Promise<void> {
await db
.prepare("DELETE FROM telegram_links WHERE telegram_user_id = ?")
.bind(telegramUserId)
.run();
}

28
server/lib/http.ts Normal file
View file

@ -0,0 +1,28 @@
import type { H3Event } from "h3";
import { setResponseStatus } from "h3";
/**
* Map a thrown h3 error to the API's legacy JSON contract
* (`{ error: string }` with the proper status code).
*/
export function toApiError(event: H3Event, err: unknown): { error: string } {
const e = err as { statusCode?: number; statusMessage?: string; message?: string };
setResponseStatus(event, e.statusCode ?? 500);
return { error: e.statusMessage ?? e.message ?? "Internal Server Error" };
}
/**
* Wrap an API handler so thrown h3 errors (401/403/400/...) become
* `{ error }` JSON responses instead of the default HTML error page.
*/
export function wrapApi<Args extends unknown[]>(
fn: (event: H3Event, ...args: Args) => Promise<unknown>,
): (event: H3Event, ...args: Args) => Promise<unknown> {
return async (event, ...args) => {
try {
return await fn(event, ...args);
} catch (err) {
return toApiError(event, err);
}
};
}

107
server/lib/lib/audit.ts Normal file
View file

@ -0,0 +1,107 @@
import { log } from "./log";
export interface AuditEntry {
id?: number;
ts: number;
actorId?: string;
actorLogin?: string;
/** Machine-readable action, e.g. "session.login", "group.update", "invite.create". */
action: string;
targetType?: string;
targetId?: string;
groupId?: string;
/** Free-form metadata. Never include secrets or message bodies. */
detail?: Record<string, unknown>;
ip?: string;
}
const COLUMNS =
"id, ts, actor_id, actor_login, action, target_type, target_id, group_id, detail, ip";
interface AuditRow {
id: number;
ts: number;
actor_id: string | null;
actor_login: string | null;
action: string;
target_type: string | null;
target_id: string | null;
group_id: string | null;
detail: string | null;
ip: string | null;
}
function toEntry(r: AuditRow): AuditEntry {
return {
id: r.id,
ts: r.ts,
actorId: r.actor_id ?? undefined,
actorLogin: r.actor_login ?? undefined,
action: r.action,
targetType: r.target_type ?? undefined,
targetId: r.target_id ?? undefined,
groupId: r.group_id ?? undefined,
detail: r.detail ? (JSON.parse(r.detail) as Record<string, unknown>) : undefined,
ip: r.ip ?? undefined,
};
}
/** Best-effort write; failures never break the business flow. */
export async function recordAudit(db: D1Database, entry: AuditEntry): Promise<void> {
try {
await db
.prepare(
`INSERT INTO audit_logs (ts, actor_id, actor_login, action, target_type, target_id, group_id, detail, ip)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
)
.bind(
entry.ts,
entry.actorId ?? null,
entry.actorLogin ?? null,
entry.action,
entry.targetType ?? null,
entry.targetId ?? null,
entry.groupId ?? null,
entry.detail ? JSON.stringify(entry.detail) : null,
entry.ip ?? null,
)
.run();
} catch (err) {
log.warn({ err, action: entry.action }, "Failed to record audit entry");
}
}
export async function getAuditLog(
db: D1Database,
opts: { groupId?: string; limit?: number } = {},
): Promise<AuditEntry[]> {
const limit = Math.min(Math.max(opts.limit ?? 50, 1), 200);
try {
if (opts.groupId) {
const { results } = await db
.prepare(`SELECT ${COLUMNS} FROM audit_logs WHERE group_id = ? ORDER BY ts DESC LIMIT ?`)
.bind(opts.groupId, limit)
.all<AuditRow>();
return results.map(toEntry);
}
const { results } = await db
.prepare(`SELECT ${COLUMNS} FROM audit_logs ORDER BY ts DESC LIMIT ?`)
.bind(limit)
.all<AuditRow>();
return results.map(toEntry);
} catch (err) {
log.warn({ err }, "Failed to load audit log");
return [];
}
}
export async function pruneAuditLogs(db: D1Database, retentionDays: number): Promise<number> {
const cutoff = Date.now() - retentionDays * 86400_000;
try {
const { meta } = await db.prepare("DELETE FROM audit_logs WHERE ts < ?").bind(cutoff).run();
return meta.changes;
} catch (err) {
log.warn({ err }, "Failed to prune audit logs");
return 0;
}
}

63
server/lib/lib/i18n.ts Normal file
View file

@ -0,0 +1,63 @@
import { en } from "./locales/en";
import { zh } from "./locales/zh";
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;
if (lang === "zh") return zh;
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;
}

View file

@ -0,0 +1,208 @@
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",
publicized: "made public",
privatized: "made private",
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",
job: "Job",
workflow: "Workflow",
context: "Context",
duration: "Duration",
type: "Type",
name: "Name",
description: "Description",
file: "File",
commit: "Commit",
environment: "Environment",
url: "URL",
service: "Service",
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: "{repo}: Pushed {count} commit{s}",
},
pr: {
action_pr: "{emoji}**{action}** pull request",
title: "{repo}#{number}: {title}",
},
issues: {
action_issue: "{emoji}**{action}** issue",
title: "{repo}#{number}: {title}",
},
issue_comment: {
title: "{repo}#{number}: {title}",
action_comment: "{emoji}**{action}** comment",
},
workflow_run: {
title: "{repo}: {name} — {conclusion}",
},
workflow_job: {
title: "{repo}: Job {name} — {conclusion}",
},
status: {
title: "{repo}: {context} — {state}",
},
ping: {
title: "{repo}: Webhook ping",
},
release: {
action_release: "{emoji}**{action}** release `{tag}`",
title: "{repo}: {name}",
},
create: {
title: "{repo}: {emoji}Created {type} {ref}",
},
delete: {
title: "{repo}: {emoji}Deleted {type} {ref}",
},
star: {
starred: "Starred",
unstarred: "Unstarred",
title: "{repo}: {emoji}{label}",
},
fork: {
title: "{repo}: {emoji}Forked to {forkee}",
},
check_run: {
title: "{repo}: {name} — {conclusion}",
},
check_suite: {
title: "{repo}: Check suite {conclusion}",
},
pr_review: {
action_review: "{emoji}**{action}** review",
title: "{repo}#{number}: {title}",
},
pr_review_comment: {
action_inline: "{emoji}**{action}** inline comment",
title: "{repo}#{number}: {title}",
line: " (line {position})",
},
commit_comment: {
action_comment: "{emoji}**{action}**",
title: "{repo}: Comment on commit {sha}",
},
deployment: {
title: "{repo}: Deployment to `{env}` — {state}",
},
member: {
title: "{repo}: {emoji}{action} collaborator: {name}",
},
label: {
title: "{repo}: {emoji}Label {action}: {name}",
},
milestone: {
title: "{repo}: {emoji}Milestone {action}: {title}",
},
discussion: {
title: "{repo}#{number}: {title}",
action_discussion: "{emoji}{action} discussion{category}",
},
discussion_comment: {
title: "{repo}#{number}: {title}",
action_comment: "{emoji}**{action}** comment",
},
repository: {
title: "{repo}: {emoji}Repository {action}",
open: "Open repository",
public: "public",
private: "private",
internal: "internal",
is_fork: "This is a fork",
visibility: "Visibility",
},
code_scanning: {
title: "{repo}: Code Scanning {action}",
},
dependabot: {
title: "{repo}: Dependabot {action}",
},
generic: {
title: "{repo}: {event}{action}",
},
},
custom: {
title_fallback: "Custom message",
},
log: {
title: "{repo}: {event}{action}",
routes: "Routes",
delivery: "Delivery",
route_ok: "✅ {route} → {target}",
route_fail: "❌ {route} → {target}: {error}",
},
};

View file

@ -0,0 +1,208 @@
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: "已转移",
publicized: "已公开",
privatized: "已设为私有",
locked: "已锁定",
unlocked: "已解锁",
renamed: "已重命名",
archived: "已归档",
unarchived: "已取消归档",
fixed: "已修复",
appeared_in_branch: "出现在分支中",
reopened_by_user: "用户重新打开",
closed_by_user: "用户关闭",
},
fields: {
branch: "分支",
changes: "变更",
labels: "标签",
assignees: "指派人",
milestone: "里程碑",
status: "状态",
run: "运行",
job: "作业",
workflow: "工作流",
context: "上下文",
duration: "耗时",
type: "类型",
name: "名称",
description: "描述",
file: "文件",
commit: "提交",
environment: "环境",
url: "链接",
service: "服务",
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: "{repo}: 推送了 {count} 个提交",
},
pr: {
action_pr: "{emoji}**{action}** 拉取请求",
title: "{repo}#{number}: {title}",
},
issues: {
action_issue: "{emoji}**{action}** 议题",
title: "{repo}#{number}: {title}",
},
issue_comment: {
title: "{repo}#{number}: {title}",
action_comment: "{emoji}**{action}** 评论",
},
workflow_run: {
title: "{repo}: {name} — {conclusion}",
},
workflow_job: {
title: "{repo}: 作业 {name} — {conclusion}",
},
status: {
title: "{repo}: {context} — {state}",
},
ping: {
title: "{repo}: Webhook ping",
},
release: {
action_release: "{emoji}**{action}** 发布 `{tag}`",
title: "{repo}: {name}",
},
create: {
title: "{repo}: {emoji}已创建{type} {ref}",
},
delete: {
title: "{repo}: {emoji}已删除{type} {ref}",
},
star: {
starred: "已加星标",
unstarred: "已取消星标",
title: "{repo}: {emoji}{label}",
},
fork: {
title: "{repo}: {emoji}复刻到 {forkee}",
},
check_run: {
title: "{repo}: {name} — {conclusion}",
},
check_suite: {
title: "{repo}: 检查套件 {conclusion}",
},
pr_review: {
action_review: "{emoji}**{action}** 审查",
title: "{repo}#{number}: {title}",
},
pr_review_comment: {
action_inline: "{emoji}**{action}** 行内评论",
title: "{repo}#{number}: {title}",
line: " (第 {position} 行)",
},
commit_comment: {
action_comment: "{emoji}**{action}**",
title: "{repo}: 提交 {sha} 的评论",
},
deployment: {
title: "{repo}: 部署到 `{env}` — {state}",
},
member: {
title: "{repo}: {emoji}{action} 协作者: {name}",
},
label: {
title: "{repo}: {emoji}标签 {action}: {name}",
},
milestone: {
title: "{repo}: {emoji}里程碑 {action}: {title}",
},
discussion: {
title: "{repo}#{number}: {title}",
action_discussion: "{emoji}{action} 讨论{category}",
},
discussion_comment: {
title: "{repo}#{number}: {title}",
action_comment: "{emoji}**{action}** 评论",
},
repository: {
title: "{repo}: {emoji}仓库 {action}",
open: "打开仓库",
public: "公开",
private: "私有",
internal: "内部",
is_fork: "这是 Fork 仓库",
visibility: "可见性",
},
code_scanning: {
title: "{repo}: 代码扫描 {action}",
},
dependabot: {
title: "{repo}: Dependabot {action}",
},
generic: {
title: "{repo}: {event}{action}",
},
},
custom: {
title_fallback: "自定义消息",
},
log: {
title: "{repo}: {event}{action}",
routes: "路由",
delivery: "投递",
route_ok: "✅ {route} → {target}",
route_fail: "❌ {route} → {target}: {error}",
},
};

10
server/lib/lib/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 })),
};

132
server/lib/lib/send-log.ts Normal file
View file

@ -0,0 +1,132 @@
import { log } from "./log";
export interface SendRecord {
id?: number;
ts: number;
routeId: string;
groupId?: string;
event: string;
repo?: string;
target: string;
ok: boolean;
error?: string;
status?: number;
messageId?: string;
deliveryId?: string;
platform?: string;
actor?: string;
action?: string;
durationMs?: number;
errorCode?: string;
attempts?: number;
detail?: Record<string, unknown>;
}
const COLUMNS =
"id, ts, route_id, group_id, event, repo, target, ok, error, status, message_id, delivery_id, platform, actor, action, duration_ms, error_code, attempts, detail";
interface LogRow {
id: number;
ts: number;
route_id: string;
group_id: string | null;
event: string;
repo: string | null;
target: string;
ok: number;
error: string | null;
status: number | null;
message_id: string | null;
delivery_id: string | null;
platform: string | null;
actor: string | null;
action: string | null;
duration_ms: number | null;
error_code: string | null;
attempts: number | null;
detail: string | null;
}
function toRecord(r: LogRow): SendRecord {
return {
id: r.id,
ts: r.ts,
routeId: r.route_id,
groupId: r.group_id ?? undefined,
event: r.event,
repo: r.repo ?? undefined,
target: r.target,
ok: r.ok === 1,
error: r.error ?? undefined,
status: r.status ?? undefined,
messageId: r.message_id ?? undefined,
deliveryId: r.delivery_id ?? undefined,
platform: r.platform ?? undefined,
actor: r.actor ?? undefined,
action: r.action ?? undefined,
durationMs: r.duration_ms ?? undefined,
errorCode: r.error_code ?? undefined,
attempts: r.attempts ?? undefined,
detail: r.detail ? (JSON.parse(r.detail) as Record<string, unknown>) : undefined,
};
}
export async function recordSend(db: D1Database, record: SendRecord): Promise<void> {
try {
await db
.prepare(
`INSERT INTO send_logs (ts, route_id, group_id, event, repo, target, ok, error, status, message_id, delivery_id, platform, actor, action, duration_ms, error_code, attempts, detail)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
)
.bind(
record.ts,
record.routeId,
record.groupId ?? null,
record.event,
record.repo ?? null,
record.target,
record.ok ? 1 : 0,
record.error ?? null,
record.status ?? null,
record.messageId ?? null,
record.deliveryId ?? null,
record.platform ?? null,
record.actor ?? null,
record.action ?? null,
record.durationMs ?? null,
record.errorCode ?? null,
record.attempts ?? null,
record.detail ? JSON.stringify(record.detail) : null,
)
.run();
} catch (err) {
log.warn({ err }, "Failed to record send log");
}
}
export async function getSendLog(db: D1Database, limit = 50): Promise<SendRecord[]> {
try {
const { results } = await db
.prepare(`SELECT ${COLUMNS} FROM send_logs ORDER BY ts DESC LIMIT ?`)
.bind(limit)
.all<LogRow>();
return results.map(toRecord);
} catch (err) {
log.warn({ err }, "Failed to load send log");
return [];
}
}
export async function getSendLogById(db: D1Database, id: number): Promise<SendRecord | null> {
try {
const { results } = await db
.prepare(`SELECT ${COLUMNS} FROM send_logs WHERE id = ? LIMIT 1`)
.bind(id)
.all<LogRow>();
const row = results[0];
return row ? toRecord(row) : null;
} catch (err) {
log.warn({ err, id }, "Failed to load send log entry");
return null;
}
}

View file

@ -0,0 +1,44 @@
import type { Env, WebhookEvent } from "../../types";
import type { Provider } from "../types";
import { verifySignature } from "../github/verify";
/**
* Custom webhook provider: accepts arbitrary JSON posts (monitoring, CI,
* scripts, ...) that are not signed by a forge. The sender signs the raw body
* with the tenant's secret using the GitHub-style `sha256=<hex>` HMAC header
* `X-WebHooker-Signature`. Payloads become `custom` events that flow through
* the normal route matching pipeline (a route with `event: custom`).
*/
export const customProvider: Provider = {
id: "custom",
matches(headers) {
return (
headers["x-github-event"] === undefined &&
headers["x-gitea-event"] === undefined &&
headers["x-webhooker-signature"] !== undefined
);
},
async verify(body, headers, env: Env) {
// The tenant webhook handler overrides GITHUB_WEBHOOK_SECRET with the
// group's secret; on the legacy global endpoint this falls back to the
// operator's global secret.
return verifySignature(body, headers["x-webhooker-signature"], env.GITHUB_WEBHOOK_SECRET);
},
parse(body, _headers): WebhookEvent | null {
try {
const payload = JSON.parse(body) as Record<string, unknown>;
if (!payload || typeof payload !== "object") return null;
// Optional id for sender-side dedup (retries from monitoring systems).
const deliveryId =
typeof payload.deliveryId === "string" && payload.deliveryId
? payload.deliveryId
: undefined;
return { event: "custom", provider: "custom", payload, deliveryId };
} catch {
return null;
}
},
};

View file

@ -0,0 +1,20 @@
import type { Env, WebhookEvent } from "../../types";
import type { Provider } from "../types";
import { verifyGiteaSignature } from "./verify";
import { parseGiteaEvent } from "./parse";
export const giteaProvider: Provider = {
id: "gitea",
matches(headers) {
return headers["x-gitea-event"] !== undefined;
},
async verify(body, headers, env: Env) {
return verifyGiteaSignature(body, headers["x-gitea-signature"], env.GITEA_WEBHOOK_SECRET);
},
parse(body, headers): WebhookEvent | null {
return parseGiteaEvent(headers, body);
},
};

View file

@ -0,0 +1,76 @@
import type { WebhookEvent } from "../../types";
/**
* Gitea webhook events that map to a different internal event name. Everything
* else already uses the same name as GitHub (push, issues, release, ...).
*/
const EVENT_MAP: Record<string, string> = {
pull_request_comment: "pull_request_review_comment",
};
/**
* Normalize a Gitea webhook payload so the shared GitHub-shaped formatters can
* consume it. Gitea models its payloads on GitHub but with a few differences.
*/
function normalizePayload(
event: string,
payload: Record<string, unknown>,
): Record<string, unknown> {
if (event === "push") {
// Gitea sends `compare_url` (GitHub sends `compare`).
if (payload.compare_url && payload.compare === undefined) {
payload.compare = payload.compare_url;
}
// Gitea sends `pusher` (and `sender`); keep a `sender` for the formatters.
if (!payload.sender && payload.pusher) {
payload.sender = payload.pusher;
}
}
if (event === "pull_request_comment") {
// GitHub names this event pull_request_review_comment and always includes
// a top-level `pull_request`. Gitea may only carry the PR-as-issue.
if (!payload.pull_request && payload.issue) {
payload.pull_request = payload.issue;
}
// GitHub uses `comment.position` for the line number, Gitea uses `line`.
const comment = payload.comment as { line?: number; position?: number } | undefined;
if (comment && comment.position === undefined && comment.line !== undefined) {
comment.position = comment.line;
}
}
if (event === "commit_comment") {
// Gitea puts the commit id at the top level, GitHub on the comment object.
const comment = payload.comment as { commit_id?: string } | undefined;
if (comment && !comment.commit_id && typeof payload.commit_id === "string") {
comment.commit_id = payload.commit_id;
}
}
return payload;
}
export function parseGiteaEvent(
headers: Record<string, string>,
body: string,
): WebhookEvent | null {
const event = headers["x-gitea-event"];
const signature = headers["x-gitea-signature"];
const deliveryId = headers["x-gitea-delivery"];
if (!event) return null;
try {
const payload = normalizePayload(event, JSON.parse(body));
return {
provider: "gitea",
event: EVENT_MAP[event] ?? event,
payload,
signature,
deliveryId,
};
} catch {
return null;
}
}

View file

@ -0,0 +1,15 @@
import { hmacSha256Hex, timingSafeEqual } from "../hmac";
/**
* Gitea signs webhooks with the HMAC-SHA256 hex digest of the raw body in the
* `X-Gitea-Signature` header (no `sha256=` prefix, unlike GitHub).
*/
export async function verifyGiteaSignature(
payload: string,
signature: string | undefined,
secret: string | undefined,
): Promise<boolean> {
if (!signature || !secret) return false;
const expected = await hmacSha256Hex(secret, payload);
return timingSafeEqual(signature, expected);
}

View file

@ -0,0 +1,20 @@
import type { Env, WebhookEvent } from "../../types";
import type { Provider } from "../types";
import { verifySignature } from "./verify";
import { parseEvent } from "./parse";
export const githubProvider: Provider = {
id: "github",
matches(headers) {
return headers["x-github-event"] !== undefined;
},
async verify(body, headers, env: Env) {
return verifySignature(body, headers["x-hub-signature-256"], env.GITHUB_WEBHOOK_SECRET);
},
parse(body, headers): WebhookEvent | null {
return parseEvent(headers, body);
},
};

View file

@ -0,0 +1,20 @@
import type { WebhookEvent } from "../../types";
export function parseEvent(headers: Record<string, string>, body: string): WebhookEvent | null {
const event = headers["x-github-event"];
const signature = headers["x-hub-signature-256"];
const deliveryId = headers["x-github-delivery"];
if (!event) return null;
try {
const payload = JSON.parse(body) as Record<string, unknown>;
const installationId =
typeof (payload.installation as { id?: unknown } | undefined)?.id === "number"
? (payload.installation as { id: number }).id
: undefined;
return { provider: "github", event, payload, signature, deliveryId, installationId };
} catch {
return null;
}
}

View file

@ -0,0 +1,11 @@
import { hmacSha256Hex, timingSafeEqual } from "../hmac";
export async function verifySignature(
payload: string,
signature: string | undefined,
secret: string,
): Promise<boolean> {
if (!signature || !secret) return false;
const expected = `sha256=${await hmacSha256Hex(secret, payload)}`;
return timingSafeEqual(signature, expected);
}

View file

@ -0,0 +1,36 @@
const keyCache = new Map<string, CryptoKey>();
async function getHmacKey(secret: string): Promise<CryptoKey> {
const cached = keyCache.get(secret);
if (cached) return cached;
const key = await crypto.subtle.importKey(
"raw",
new TextEncoder().encode(secret),
{ name: "HMAC", hash: "SHA-256" },
false,
["sign"],
);
keyCache.set(secret, key);
return key;
}
export async function hmacSha256Hex(secret: string, payload: string): Promise<string> {
const key = await getHmacKey(secret);
const sig = await crypto.subtle.sign("HMAC", key, new TextEncoder().encode(payload));
return Array.from(new Uint8Array(sig))
.map((b) => b.toString(16).padStart(2, "0"))
.join("");
}
/** Constant-time string comparison. */
export function timingSafeEqual(a: string, b: string): boolean {
const encoder = new TextEncoder();
const x = encoder.encode(a);
const y = encoder.encode(b);
if (x.byteLength !== y.byteLength) return false;
let diff = 0;
for (let i = 0; i < x.byteLength; i++) {
diff |= x[i]! ^ y[i]!;
}
return diff === 0;
}

View file

@ -0,0 +1,24 @@
import type { Provider } from "./types";
import { githubProvider } from "./github";
import { giteaProvider } from "./gitea";
import { customProvider } from "./custom";
export type { Provider } from "./types";
export { verifySignature } from "./github/verify";
/**
* Detection order matters: Gitea webhooks also send GitHub-compatible headers
* (`X-GitHub-Event`, `X-Hub-Signature-256`, ...), so a Gitea request would
* match the GitHub provider too. Check Gitea first real GitHub requests
* never send `X-Gitea-Event`. Custom requests carry none of the forge headers,
* only `X-WebHooker-Signature`, so they are checked last.
*/
const providers: Provider[] = [giteaProvider, githubProvider, customProvider];
/**
* Pick the webhook provider for a request based on its headers (e.g.
* `X-GitHub-Event` / `X-Gitea-Event`). Returns null when no provider matches.
*/
export function detectProvider(headers: Record<string, string>): Provider | null {
return providers.find((p) => p.matches(headers)) ?? null;
}

View file

@ -0,0 +1,23 @@
import type { Env, WebhookEvent } from "../types";
/**
* A forge webhook provider (GitHub, Gitea, GitLab, ...). Each provider owns
* signature verification and payload parsing/normalization. The rest of the
* pipeline (route matching, formatting, dispatch) only sees the normalized
* {@link WebhookEvent} and never knows which forge produced it.
*/
export interface Provider {
readonly id: "github" | "gitea" | "gitlab" | "custom";
/**
* Whether the request headers belong to this provider (e.g. checks the
* `X-Gitea-Event` header).
*/
matches(headers: Record<string, string>): boolean;
/** Verify the webhook signature. Returns false when the secret is missing. */
verify(body: string, headers: Record<string, string>, env: Env): Promise<boolean>;
/**
* Parse the body and normalize it into a {@link WebhookEvent} whose payload
* is shaped like a GitHub event so the shared formatters can consume it.
*/
parse(body: string, headers: Record<string, string>): WebhookEvent | null;
}

230
server/lib/types.ts Normal file
View file

@ -0,0 +1,230 @@
export interface Env {
GITHUB_WEBHOOK_SECRET: string;
GITEA_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;
ADMIN_USER_IDS?: string;
LEGAL_CONTACT?: string;
DOCS_URL?: string;
GITHUB_REPO_URL?: string;
DISCORD_PUBLIC_KEY?: string;
DISCORD_APPLICATION_ID?: string;
TELEGRAM_TOKEN?: string;
TELEGRAM_WEBHOOK_SECRET?: string;
TELEGRAM_RICH_HEADER_HOST?: string;
/**
* When enabled ("1"/"true"), GitHub users without any group access get a
* personal group on first login instead of being blocked.
*/
ALLOW_SELF_SIGNUP?: string;
/** Audit log retention in days (default 90). */
AUDIT_RETENTION_DAYS?: string;
ASSETS?: Fetcher;
KV: KVNamespace;
DB: D1Database;
}
export interface Config {
baseUrl: string;
github: {
webhookSecret: string;
appId: number;
privateKey: string;
clientId: string;
clientSecret: string;
};
discord: {
token: string;
};
routes: Route[];
}
export interface RouteTarget {
platform?: "discord" | "telegram";
channelId?: string;
threadId?: string;
chatId?: string;
topicId?: string;
}
export interface Route {
id: string;
name: string;
enabled: boolean;
filters: Filter[];
targets: RouteTarget[];
groupId?: string;
/**
* Fallback route: only fires when no other (non-fallback) route matched the
* event. Multiple fallback routes may exist; they are all skipped whenever at
* least one regular route matches. Its own filters are ignored.
*/
fallback?: boolean;
/**
* Stop: when true and this route matches, no further routes are evaluated
* for this event. Useful for exclusive routing where a match should prevent
* fallthrough to subsequent routes.
*/
stop?: boolean;
/**
* Discord role () ids to mention/notify when this route fires. Roles
* are only mentioned in Discord targets; Telegram targets ignore this field.
*/
discordRoleIds?: string[];
}
export type GroupRole = "owner" | "admin" | "viewer";
export interface GroupMember {
/**
* GitHub login (case-insensitive). Ids are matched when stored, logins
* otherwise; identityMatches handles both.
*/
login: string;
role: GroupRole;
}
export interface Group {
id: string;
name: string;
/**
* Deprecated legacy field: GitHub user ids or logins allowed to manage this
* group. Kept for backward compatibility when `members` is absent, these
* are normalized to `members` with role "owner". New writes use `members`.
*/
adminIds: string[];
/**
* Group members with roles. Roles: owner (manage group + members + invites),
* admin (manage routes), viewer (read-only). Super admins always bypass.
*/
members?: GroupMember[];
/**
* GitHub organization/user logins (case-insensitive) whose webhook events are
* allowed into this group's routes. Empty/omitted = no owner restriction.
* Only super admins may edit this field.
*/
owners?: string[];
/**
* Webhook providers (source platforms) allowed into this group's routes
* (e.g. `["github"]`, `["gitea"]`). Empty/omitted = all providers.
*/
providers?: WebhookProvider[];
/**
* GitHub App installation id bound to this group. When set, only webhook
* events coming from that installation (org/user) are accepted into the
* group's routes hard tenant isolation on top of (or instead of) the
* `owners` list. Empty/omitted = no installation restriction.
*/
installationId?: number;
/**
* Whether to include emoji in messages sent through this group's routes.
* Defaults to true when omitted.
*/
emoji?: boolean;
/**
* Message language for every route in this group (e.g. "en", "zh"; custom
* via KV i18n:<lang>). Defaults to "en" when omitted.
*/
lang?: string;
/**
* Discord channel/thread or Telegram chat/topic that receives a summary of
* every webhook this group's routes dispatch (the group's webhook log).
*/
logTarget?: RouteTarget;
}
export interface Filter {
type: "event" | "repo" | "actor" | "action" | "branch" | "keyword";
match: string | string[];
exclude?: boolean;
}
export type WebhookProvider = "github" | "gitea" | "gitlab" | "custom";
export interface WebhookEvent {
event: string;
payload: Record<string, unknown>;
signature?: string;
deliveryId?: string;
provider?: WebhookProvider;
/**
* GitHub App installation id that produced this event (extracted from
* `payload.installation.id`). Gitea/custom events have none.
*/
installationId?: number;
}
export interface NeutralAuthor {
name: string;
iconUrl?: string;
url?: string;
}
export interface NeutralField {
name: string;
value: string;
inline?: boolean;
}
export type NeutralActionStyle = "primary" | "danger" | "secondary";
export interface NeutralAction {
id: string;
label: string;
style: NeutralActionStyle;
}
export interface NeutralMessage {
author?: NeutralAuthor;
title: string;
url?: string;
color?: number;
description?: string;
fields?: NeutralField[];
footer?: string;
timestamp?: string;
actions?: NeutralAction[];
/**
* Stable key identifying a message chain that should be updated in place
* (e.g. workflow run progress). When set, subsequent events edit the
* previously sent message instead of sending a new one.
*/
updateKey?: string;
/**
* Discord role ids to mention in the message content (set by dispatch from
* the route's `discordRoleIds`). Only used by the Discord driver.
*/
mentionRoleIds?: string[];
}
export interface FormattedMessage {
content?: string;
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;
}>;
components?: Array<{
type: number;
components: Array<{
type: number;
style?: number;
label?: string;
custom_id?: string;
}>;
}>;
}

168
server/lib/web/actions.ts Normal file
View file

@ -0,0 +1,168 @@
import type { H3Event } from "h3";
import { readBody, setResponseStatus } from "h3";
import { getUserOctokit } from "../github/oauth";
import { bearerUserId } from "./auth";
import { cfEnv } from "../cf";
import { log } from "../lib/log";
function isNonEmptyString(value: unknown): value is string {
return typeof value === "string" && value.length > 0;
}
function isValidId(value: unknown): value is number {
return typeof value === "number" && Number.isFinite(value) && value > 0;
}
async function readJsonBody(event: H3Event): Promise<Record<string, unknown> | null> {
try {
return (await readBody(event)) as Record<string, unknown>;
} catch {
return null;
}
}
async function userOctokit(event: H3Event, userId: string): Promise<Awaited<ReturnType<typeof getUserOctokit>>> {
return getUserOctokit(userId, cfEnv(event).KV);
}
function fail(event: H3Event, status: number, error: string): Record<string, unknown> {
setResponseStatus(event, status);
return { error };
}
/** POST /api/comment */
export async function apiComment(event: H3Event): Promise<Record<string, unknown>> {
const userId = await bearerUserId(event);
const body = await readJsonBody(event);
if (
!body ||
!isNonEmptyString(body.owner) ||
!isNonEmptyString(body.repo) ||
!isValidId(body.issueNumber) ||
!isNonEmptyString(body.body)
) {
return fail(event, 400, "Invalid request body");
}
const octokit = await userOctokit(event, userId);
if (!octokit) return fail(event, 401, "Not authorized");
try {
await octokit.rest.issues.createComment({
owner: body.owner,
repo: body.repo,
issue_number: body.issueNumber,
body: body.body,
});
} catch (err) {
log.error({ err }, "Failed to create comment");
return fail(event, 500, "GitHub API error");
}
return { ok: true };
}
/** POST /api/merge */
export async function apiMerge(event: H3Event): Promise<Record<string, unknown>> {
const userId = await bearerUserId(event);
const body = await readJsonBody(event);
if (
!body ||
!isNonEmptyString(body.owner) ||
!isNonEmptyString(body.repo) ||
!isValidId(body.pullNumber)
) {
return fail(event, 400, "Invalid request body");
}
const method = body.method === undefined ? "squash" : body.method;
if (method !== "merge" && method !== "squash" && method !== "rebase") {
return fail(event, 400, "Invalid request body");
}
const octokit = await userOctokit(event, userId);
if (!octokit) return fail(event, 401, "Not authorized");
try {
await octokit.rest.pulls.merge({
owner: body.owner,
repo: body.repo,
pull_number: body.pullNumber,
merge_method: method,
});
} catch (err) {
log.error({ err }, "Failed to merge pull request");
return fail(event, 500, "GitHub API error");
}
return { ok: true };
}
/** POST /api/close */
export async function apiClose(event: H3Event): Promise<Record<string, unknown>> {
const userId = await bearerUserId(event);
const body = await readJsonBody(event);
if (
!body ||
!isNonEmptyString(body.owner) ||
!isNonEmptyString(body.repo) ||
!isValidId(body.pullNumber)
) {
return fail(event, 400, "Invalid request body");
}
const octokit = await userOctokit(event, userId);
if (!octokit) return fail(event, 401, "Not authorized");
try {
await octokit.rest.pulls.update({
owner: body.owner,
repo: body.repo,
pull_number: body.pullNumber,
state: "closed",
});
} catch (err) {
log.error({ err }, "Failed to close pull request");
return fail(event, 500, "GitHub API error");
}
return { ok: true };
}
/** POST /api/react */
export async function apiReact(event: H3Event): Promise<Record<string, unknown>> {
const userId = await bearerUserId(event);
const body = await readJsonBody(event);
const reactions = [
"+1",
"-1",
"laugh",
"confused",
"heart",
"hooray",
"rocket",
"eyes",
] as const;
if (
!body ||
!isNonEmptyString(body.owner) ||
!isNonEmptyString(body.repo) ||
!isValidId(body.issueNumber) ||
!isNonEmptyString(body.reaction) ||
!(reactions as readonly string[]).includes(body.reaction)
) {
return fail(event, 400, "Invalid request body");
}
const octokit = await userOctokit(event, userId);
if (!octokit) return fail(event, 401, "Not authorized");
try {
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",
});
} catch (err) {
log.error({ err }, "Failed to create reaction");
return fail(event, 500, "GitHub API error");
}
return { ok: true };
}

948
server/lib/web/admin.ts Normal file
View file

@ -0,0 +1,948 @@
import type { H3Event } from "h3";
import {
getHeader,
getQuery,
readBody,
sendRedirect,
setResponseHeader,
setResponseStatus,
} from "h3";
import type { Route, Group, GroupMember, GroupRole } from "../types";
import { loadRoutes, saveRoutes } from "../config";
import { getAdminSession, destroyAdminSession, clearAdminCookie } from "./session";
import { saveGroups, loadGroups, identityMatches, normalizeGroupMembers } from "./groups";
import {
requireAnyAccess,
currentAuth,
requireGroup,
requireGroupRole,
roleAt,
clientIp,
type GroupAccess,
} from "./auth";
import { getSendLog, getSendLogById } from "../lib/send-log";
import { getAuditLog, recordAudit } from "../lib/audit";
import {
createInvite,
listInvites,
revokeInvite,
getInvite,
acceptInvite,
migrateInvites,
} from "./invites";
import { getTenantSecret, setTenantSecret, deleteTenantSecret } from "./tenants";
import { cfEnv } from "../cf";
import { log } from "../lib/log";
const VALID_FILTER_TYPES = new Set(["event", "repo", "actor", "action", "branch", "keyword"]);
const ID_RE = /^[a-z0-9][a-z0-9-]*$/;
function isValidMatch(match: unknown): match is string | string[] {
if (typeof match === "string") return match.trim().length > 0;
if (Array.isArray(match))
return match.length > 0 && match.every((m) => typeof m === "string" && m.trim().length > 0);
return false;
}
function deepEqual(a: unknown, b: unknown): boolean {
if (a === b) return true;
if (typeof a !== typeof b || a === null || b === null) return false;
if (Array.isArray(a) || Array.isArray(b)) {
if (!Array.isArray(a) || !Array.isArray(b) || a.length !== b.length) return false;
return a.every((x, i) => deepEqual(x, b[i]));
}
if (typeof a === "object" && typeof b === "object") {
const ao = a as Record<string, unknown>;
const bo = b as Record<string, unknown>;
const ak = Object.keys(ao);
const bk = Object.keys(bo);
if (ak.length !== bk.length) return false;
return ak.every((k) => Object.prototype.hasOwnProperty.call(bo, k) && deepEqual(ao[k], bo[k]));
}
return false;
}
/**
* Validates the submitted routes. Routes that are byte-for-byte identical to an
* entry in `unchanged` (keyed by id) skip the full content check, so a pre-existing
* incomplete route can never block edits to a different route. Only new or modified
* routes are fully validated. Structural checks (id shape, uniqueness) still run for all.
*/
function validateRoutes(
routes: unknown,
unchanged?: Map<string, Route>,
): { ok: true; routes: Route[] } | { ok: false; error: string } {
if (!Array.isArray(routes)) return { ok: false, error: "routes must be an array" };
if (routes.length > 200) return { ok: false, error: "too many routes" };
const seenByGroup = new Map<string, Set<string>>();
for (let i = 0; i < routes.length; i++) {
const r = routes[i] as Record<string, unknown>;
if (!r || typeof r !== "object") return { ok: false, error: `route[${i}] is not an object` };
if (typeof r.id !== "string" || !ID_RE.test(r.id)) {
return { ok: false, error: `route[${i}].id is invalid` };
}
const gid = (r.groupId as string) ?? "__nogroup__";
let groupSeen = seenByGroup.get(gid);
if (!groupSeen) {
groupSeen = new Set();
seenByGroup.set(gid, groupSeen);
}
if (groupSeen.has(r.id)) {
return { ok: false, error: `duplicate route id "${r.id}" in group "${gid}"` };
}
groupSeen.add(r.id);
// Skip full validation for routes that are unchanged from what is stored.
const prev = unchanged?.get(r.id);
if (prev && deepEqual(r, prev)) continue;
if (typeof r.name !== "string" || r.name.trim().length === 0) {
return { ok: false, error: `route "${r.id}" needs a name` };
}
if (typeof r.groupId !== "string" || r.groupId.trim().length === 0) {
return { ok: false, error: `route "${r.id}" needs a group` };
}
if (typeof r.enabled !== "boolean")
return { ok: false, error: `route "${r.id}".enabled must be boolean` };
if (r.fallback !== undefined && typeof r.fallback !== "boolean") {
return { ok: false, error: `route "${r.id}".fallback must be a boolean` };
}
if (r.stop !== undefined && typeof r.stop !== "boolean") {
return { ok: false, error: `route "${r.id}".stop must be a boolean` };
}
if (
r.discordRoleIds !== undefined &&
(!Array.isArray(r.discordRoleIds) ||
!r.discordRoleIds.every((d) => typeof d === "string" && d.trim().length > 0))
) {
return { ok: false, error: `route "${r.id}".discordRoleIds must be a list of strings` };
}
if (!Array.isArray(r.filters)) {
return { ok: false, error: `route "${r.id}".filters must be an array` };
}
if (r.fallback !== true && r.filters.length === 0) {
return { ok: false, error: `route "${r.id}" needs at least one filter` };
}
for (let j = 0; j < r.filters.length; j++) {
const f = r.filters[j] as Record<string, unknown>;
if (!f || typeof f !== "object")
return { ok: false, error: `route "${r.id}" filter[${j}] invalid` };
if (!VALID_FILTER_TYPES.has(f.type as string)) {
return { ok: false, error: `route "${r.id}" filter[${j}] has unknown type` };
}
if (!isValidMatch(f.match)) {
return { ok: false, error: `route "${r.id}" filter[${j}] needs a match value` };
}
if (f.exclude !== undefined && typeof f.exclude !== "boolean") {
return { ok: false, error: `route "${r.id}" filter[${j}].exclude must be boolean` };
}
}
const rawTarget = r.target as Record<string, unknown> | undefined;
const rawTargets = r.targets as unknown;
if (rawTargets === undefined && rawTarget && typeof rawTarget === "object") {
const legacy = validateTarget(`route "${r.id}"`, rawTarget);
if (!legacy.ok) return legacy;
(r as Record<string, unknown>).targets = [legacy.target];
delete (r as Record<string, unknown>).target;
} else if (Array.isArray(rawTargets)) {
if (rawTargets.length === 0) {
return { ok: false, error: `route "${r.id}" needs at least one target` };
}
const normalized: Route["targets"] = [];
for (let j = 0; j < rawTargets.length; j++) {
const t = rawTargets[j] as Record<string, unknown>;
if (!t || typeof t !== "object") {
return { ok: false, error: `route "${r.id}".targets[${j}] is not an object` };
}
const result = validateTarget(`route "${r.id}".targets[${j}]`, t);
if (!result.ok) return result;
normalized.push(result.target);
}
(r as Record<string, unknown>).targets = normalized;
} else {
return { ok: false, error: `route "${r.id}" needs a targets array` };
}
}
return { ok: true, routes: routes as Route[] };
}
function validateTarget(
label: string,
target: Record<string, unknown>,
): { ok: true; target: Route["targets"][number] } | { ok: false; error: string } {
const platform = target.platform === undefined ? "discord" : target.platform;
if (platform !== "discord" && platform !== "telegram") {
return { ok: false, error: `${label}.platform must be "discord" or "telegram"` };
}
if (platform === "telegram") {
if (typeof target.chatId !== "string" || target.chatId.trim().length === 0)
return { ok: false, error: `${label}.chatId is required` };
if (target.topicId !== undefined && typeof target.topicId !== "string") {
return { ok: false, error: `${label}.topicId must be a string` };
}
} else {
if (typeof target.channelId !== "string" || target.channelId.trim().length === 0)
return { ok: false, error: `${label}.channelId is required` };
if (target.threadId !== undefined && typeof target.threadId !== "string") {
return { ok: false, error: `${label}.threadId must be a string` };
}
}
return {
ok: true,
target: {
platform,
channelId: platform === "telegram" ? undefined : (target.channelId as string),
threadId: platform === "telegram" ? undefined : ((target.threadId as string) ?? undefined),
chatId: platform === "telegram" ? (target.chatId as string) : undefined,
topicId: platform === "telegram" ? ((target.topicId as string) ?? undefined) : undefined,
},
};
}
function validateMembers(
g: Record<string, unknown>,
gid: string,
): { ok: true; members: GroupMember[] } | { ok: false; error: string } {
const raw = g.members;
if (raw === undefined || raw === null) {
// Legacy payload without members: derive owners from adminIds.
const adminIds = g.adminIds;
if (!Array.isArray(adminIds)) {
return { ok: false, error: `group "${gid}" needs a members list` };
}
if (!adminIds.every((a) => typeof a === "string" && a.trim().length > 0)) {
return { ok: false, error: `group "${gid}".adminIds must be a list of strings` };
}
const members: GroupMember[] = [];
const seen = new Set<string>();
for (const a of adminIds as string[]) {
const login = a.trim();
if (!login || seen.has(login.toLowerCase())) continue;
seen.add(login.toLowerCase());
members.push({ login, role: "owner" });
}
return { ok: true, members };
}
if (!Array.isArray(raw)) return { ok: false, error: `group "${gid}".members must be an array` };
const seen = new Set<string>();
const members: GroupMember[] = [];
for (let i = 0; i < raw.length; i++) {
const m = raw[i] as Record<string, unknown>;
if (!m || typeof m !== "object")
return { ok: false, error: `group "${gid}".members[${i}] is not an object` };
const login = typeof m.login === "string" ? m.login.trim() : "";
if (!login) return { ok: false, error: `group "${gid}".members[${i}].login is required` };
const role = m.role;
if (role !== "owner" && role !== "admin" && role !== "viewer") {
return {
ok: false,
error: `group "${gid}".members[${i}].role must be "owner" | "admin" | "viewer"`,
};
}
if (seen.has(login.toLowerCase())) {
return { ok: false, error: `group "${gid}" has duplicate member "${login}"` };
}
seen.add(login.toLowerCase());
members.push({ login, role });
}
if (members.length > 0 && !members.some((m) => m.role === "owner")) {
return { ok: false, error: `group "${gid}" needs at least one owner` };
}
return { ok: true, members };
}
export function validateGroups(
groups: unknown,
): { ok: true; groups: Group[] } | { ok: false; error: string } {
if (!Array.isArray(groups)) return { ok: false, error: "groups must be an array" };
if (groups.length > 100) return { ok: false, error: "too many groups" };
const seen = new Set<string>();
for (let i = 0; i < groups.length; i++) {
const g = groups[i] as Record<string, unknown>;
if (!g || typeof g !== "object") return { ok: false, error: `group[${i}] is not an object` };
if (typeof g.id !== "string" || !ID_RE.test(g.id)) {
return { ok: false, error: `group[${i}].id is invalid` };
}
if (seen.has(g.id)) return { ok: false, error: `duplicate group id "${g.id}"` };
seen.add(g.id);
if (typeof g.name !== "string" || g.name.trim().length === 0) {
return { ok: false, error: `group "${g.id}" needs a name` };
}
const mres = validateMembers(g, g.id);
if (!mres.ok) return mres;
// `members` is the single source of truth; adminIds stays in sync so
// legacy consumers (isGroupAdmin, older UI) keep working.
g.members = mres.members;
g.adminIds = mres.members.filter((m) => m.role === "owner").map((m) => m.login);
if (
g.owners !== undefined &&
(!Array.isArray(g.owners) ||
!g.owners.every((o) => typeof o === "string" && o.trim().length > 0))
) {
return { ok: false, error: `group "${g.id}".owners must be a list of strings` };
}
if (
g.providers !== undefined &&
(!Array.isArray(g.providers) ||
!g.providers.every(
(p) => typeof p === "string" && ["github", "gitea", "gitlab"].includes(p),
))
) {
return {
ok: false,
error: `group "${g.id}".providers must be a list of "github" | "gitea" | "gitlab"`,
};
}
if (g.installationId !== undefined && g.installationId !== null) {
if (typeof g.installationId !== "number" || !Number.isInteger(g.installationId)) {
return { ok: false, error: `group "${g.id}".installationId must be an integer` };
}
} else {
delete g.installationId;
}
if (g.emoji !== undefined && typeof g.emoji !== "boolean") {
return { ok: false, error: `group "${g.id}".emoji must be a boolean` };
}
if (g.lang !== undefined && typeof g.lang !== "string") {
return { ok: false, error: `group "${g.id}".lang must be a string` };
}
if (g.logTarget !== undefined && g.logTarget !== null) {
if (typeof g.logTarget !== "object" || Array.isArray(g.logTarget)) {
return { ok: false, error: `group "${g.id}".logTarget must be an object` };
}
const tgt = validateTarget(
`group "${g.id}".logTarget`,
g.logTarget as Record<string, unknown>,
);
if (!tgt.ok) return tgt;
g.logTarget = tgt.target;
} else {
delete g.logTarget;
}
}
return { ok: true, groups: groups as Group[] };
}
function ownerCount(members: GroupMember[]): number {
return members.filter((m) => m.role === "owner").length;
}
function respondError(event: H3Event, status: number, error: string): Record<string, unknown> {
setResponseStatus(event, status);
return { error };
}
function accessError(
event: H3Event,
access: Extract<GroupAccess, { ok: false }>,
): Record<string, unknown> {
return respondError(
event,
access.status,
access.status === 404 ? "Group not found" : "Forbidden",
);
}
/** Read a JSON body, returning null when it is not valid JSON. */
async function readJsonBody(event: H3Event): Promise<Record<string, unknown> | null> {
try {
const body = await readBody(event);
return (body ?? {}) as Record<string, unknown>;
} catch {
return null;
}
}
function webhookUrl(event: H3Event, groupId: string): string {
const env = cfEnv(event);
const origin = env.BASE_URL ?? getRequestOrigin(event);
return `${origin.replace(/\/$/, "")}/webhook/${groupId}`;
}
function getRequestOrigin(event: H3Event): string {
const proto = getHeader(event, "x-forwarded-proto") ?? "https";
const host = getHeader(event, "host") ?? "localhost";
return `${proto}://${host}`;
}
// ---------------------------------------------------------------------------
// Handlers
// ---------------------------------------------------------------------------
/** GET /admin/login */
export async function adminLogin(event: H3Event): Promise<void> {
await sendRedirect(event, "/auth/github?redirect=/admin");
}
/** GET /admin/logout */
export async function adminLogout(event: H3Event): Promise<void> {
const env = cfEnv(event);
const session = await getAdminSession(env.KV, getHeader(event, "cookie"));
if (session) {
await recordAudit(env.DB, {
ts: Date.now(),
actorId: session.userId,
actorLogin: session.login,
action: "session.logout",
ip: clientIp(event),
});
}
await destroyAdminSession(env.KV, getHeader(event, "cookie"));
setResponseHeader(event, "Set-Cookie", clearAdminCookie());
await sendRedirect(event, "/admin");
}
/** GET /admin/invite?token=… */
export async function adminInvite(event: H3Event): Promise<void> {
const env = cfEnv(event);
const token = String(getQuery(event)["token"] ?? "");
if (!token) {
await sendRedirect(event, "/admin");
return;
}
const session = await getAdminSession(env.KV, getHeader(event, "cookie"));
if (!session) {
await sendRedirect(event, `/auth/github?redirect=${encodeURIComponent(`/admin/invite?token=${token}`)}`);
return;
}
const result = await acceptInvite(env.KV, token, session.userId, session.login);
if (result.ok) {
await recordAudit(env.DB, {
ts: Date.now(),
actorId: session.userId,
actorLogin: session.login,
action: "invite.accept",
targetType: "group",
targetId: result.groupId,
groupId: result.groupId,
detail: { role: result.role },
ip: clientIp(event),
});
}
await sendRedirect(event, `/admin?invite=${result.ok ? "ok" : result.reason}`);
}
/** GET /admin/api/me */
export async function adminApiMe(event: H3Event): Promise<Record<string, unknown>> {
const auth = await requireAnyAccess(event);
const roles: Record<string, GroupRole> = {};
for (const g of auth.scope.groups) {
const role = roleAt(auth.scope, g.id);
if (role) roles[g.id] = role;
}
return {
login: auth.session.login,
userId: auth.session.userId,
isSuper: auth.scope.isSuper,
groups: auth.scope.groups,
roles,
};
}
/** GET /admin/api/groups */
export async function adminApiGroupsGet(event: H3Event): Promise<Record<string, unknown>> {
const auth = await requireAnyAccess(event);
const roles: Record<string, GroupRole> = {};
for (const g of auth.scope.groups) {
const role = roleAt(auth.scope, g.id);
if (role) roles[g.id] = role;
}
return { groups: auth.scope.groups, isSuper: auth.scope.isSuper, roles };
}
/** PUT /admin/api/groups */
export async function adminApiGroupsPut(event: H3Event): Promise<Record<string, unknown>> {
const auth = await requireAnyAccess(event);
const env = cfEnv(event);
const body = await readJsonBody(event);
if (!body) return respondError(event, 400, "Invalid JSON body");
const result = validateGroups(body["groups"]);
if (!result.ok) return respondError(event, 400, result.error);
const existing = await loadGroups(env.KV);
const prevById = new Map(existing.map((g) => [g.id, g]));
let nextAll: Group[];
if (auth.scope.isSuper) {
// Super admins see and submit every group: full replace.
nextAll = result.groups;
} else {
// Owners may only write groups they own. Preserve every other group and
// never let a submission drop the last owner of a group.
const mine = new Set<string>();
for (const [gid, role] of auth.scope.roles) {
if (role === "owner") mine.add(gid);
}
for (const g of result.groups) {
if (!mine.has(g.id)) {
return respondError(event, 403, `group "${g.id}" is outside your ownership`);
}
const members = g.members ?? normalizeGroupMembers(g);
const stillMine = members.some(
(m) =>
m.role === "owner" &&
identityMatches([m.login], auth.session.userId, auth.session.login),
);
const otherOwner = ownerCount(members) > 1;
if (!stillMine && !otherOwner) {
return respondError(event, 403, `group "${g.id}" would be left without an owner by you`);
}
}
nextAll = [
...existing.filter((g) => !mine.has(g.id)),
...result.groups.map((g) => {
const prev = prevById.get(g.id);
if (prev && prev.owners !== undefined && g.owners === undefined) {
// Owners cannot edit the `owners` scope; keep the stored value.
return { ...g, owners: prev.owners };
}
return g;
}),
];
}
// Audit every create / update / delete.
const prevGroups = existing;
const nextById = new Map(nextAll.map((g) => [g.id, g]));
const actor = { actorId: auth.session.userId, actorLogin: auth.session.login };
for (const g of nextAll) {
const prev = prevById.get(g.id);
if (!prev) {
await recordAudit(env.DB, {
ts: Date.now(),
...actor,
action: "group.create",
targetType: "group",
targetId: g.id,
groupId: g.id,
ip: clientIp(event),
});
continue;
}
const fields: string[] = [];
if (prev.name !== g.name) fields.push("name");
if (prev.emoji !== g.emoji) fields.push("emoji");
if (prev.lang !== g.lang) fields.push("lang");
if (!deepEqual(prev.logTarget, g.logTarget)) fields.push("logTarget");
if (!deepEqual(prev.providers ?? [], g.providers ?? [])) fields.push("providers");
if (prev.installationId !== g.installationId) fields.push("installationId");
if (!deepEqual(prev.owners ?? [], g.owners ?? [])) fields.push("owners");
if (!deepEqual(prev.members ?? normalizeGroupMembers(prev), g.members)) fields.push("members");
if (fields.length > 0) {
await recordAudit(env.DB, {
ts: Date.now(),
...actor,
action: "group.update",
targetType: "group",
targetId: g.id,
groupId: g.id,
detail: { fields },
ip: clientIp(event),
});
}
}
for (const g of prevGroups) {
if (!nextById.has(g.id)) {
await recordAudit(env.DB, {
ts: Date.now(),
...actor,
action: "group.delete",
targetType: "group",
targetId: g.id,
groupId: g.id,
ip: clientIp(event),
});
await deleteTenantSecret(env.KV, g.id).catch(() => undefined);
}
}
try {
await saveGroups(env.KV, nextAll);
} catch (err) {
log.error({ err }, "Failed to save groups");
return respondError(event, 500, "Failed to save groups");
}
log.info({ count: nextAll.length }, "Groups updated via admin UI");
return { ok: true, count: nextAll.length };
}
/** GET /admin/api/routes */
export async function adminApiRoutesGet(event: H3Event): Promise<Record<string, unknown>> {
const auth = await requireAnyAccess(event);
const env = cfEnv(event);
const all = await loadRoutes(env.KV);
const routes = auth.scope.isSuper
? all
: all.filter((r) => r.groupId != null && auth.scope.groupIds.has(r.groupId));
return { routes };
}
/** PUT /admin/api/routes */
export async function adminApiRoutesPut(event: H3Event): Promise<Record<string, unknown>> {
const auth = await requireAnyAccess(event);
const env = cfEnv(event);
const body = await readJsonBody(event);
if (!body) return respondError(event, 400, "Invalid JSON body");
const existing = await loadRoutes(env.KV);
const unchanged = new Map(existing.map((r) => [r.id, r]));
const result = validateRoutes(body["routes"], unchanged);
if (!result.ok) return respondError(event, 400, result.error);
let nextAll: Route[];
if (auth.scope.isSuper) {
// Super admins see and submit every route: full replace.
nextAll = result.routes;
} else {
// Owners and admins may only write routes inside groups they manage.
// Reject any submitted route that targets a group they cannot edit,
// then splice their groups' routes in place while preserving all others.
const writable = new Set<string>();
for (const [gid, role] of auth.scope.roles) {
if (role === "owner" || role === "admin") writable.add(gid);
}
for (const r of result.routes) {
if (!r.groupId || !writable.has(r.groupId)) {
return respondError(event, 403, `route "${r.id}" is outside your groups`);
}
}
nextAll = [
...existing.filter((r) => !(r.groupId != null && writable.has(r.groupId))),
...result.routes,
];
}
try {
await saveRoutes(env.KV, nextAll);
} catch (err) {
log.error({ err }, "Failed to save routes");
return respondError(event, 500, "Failed to save routes");
}
await recordAudit(env.DB, {
ts: Date.now(),
actorId: auth.session.userId,
actorLogin: auth.session.login,
action: "routes.update",
targetType: "routes",
targetId: "all",
detail: { count: nextAll.length },
ip: clientIp(event),
});
log.info({ count: nextAll.length }, "Routes updated via admin UI");
return { ok: true, count: nextAll.length };
}
/** GET /admin/api/logs */
export async function adminApiLogs(event: H3Event): Promise<Record<string, unknown>> {
const auth = await requireAnyAccess(event);
const env = cfEnv(event);
const query = getQuery(event);
const limit = Math.min(Math.max(Number(String(query["limit"] ?? "50")), 1), 100);
const filterGroupId = String(query["groupId"] ?? "") || undefined;
const allLogs = await getSendLog(env.DB, 200);
const allowed = auth.scope.isSuper
? allLogs
: allLogs.filter((l) => l.groupId != null && auth.scope.groupIds.has(l.groupId));
const logs = filterGroupId ? allowed.filter((l) => l.groupId === filterGroupId) : allowed;
return { logs: logs.slice(0, limit) };
}
/** GET /admin/api/logs/:id */
export async function adminApiLogsById(event: H3Event, id: number): Promise<Record<string, unknown>> {
const auth = await requireAnyAccess(event);
const env = cfEnv(event);
if (!Number.isInteger(id) || id <= 0) return respondError(event, 400, "Invalid log id");
const entry = await getSendLogById(env.DB, id);
if (!entry) return respondError(event, 404, "Log entry not found");
if (!auth.scope.isSuper) {
if (!entry.groupId || !auth.scope.groupIds.has(entry.groupId)) {
return respondError(event, 403, "Forbidden");
}
}
return { log: entry };
}
/** GET /admin/api/groups/:groupId/routes */
export async function adminGroupRoutesGet(
event: H3Event,
groupId: string,
): Promise<Record<string, unknown>> {
await requireAnyAccess(event);
const env = cfEnv(event);
const access = requireGroup(event, groupId);
if (!access.ok) return accessError(event, access);
const all = await loadRoutes(env.KV);
return { group: access.group, routes: all.filter((r) => r.groupId === groupId) };
}
/** PUT /admin/api/groups/:groupId/routes */
export async function adminGroupRoutesPut(
event: H3Event,
groupId: string,
): Promise<Record<string, unknown>> {
await requireAnyAccess(event);
const env = cfEnv(event);
const access = requireGroupRole(event, groupId, "admin");
if (!access.ok) return accessError(event, access);
const body = await readJsonBody(event);
if (!body) return respondError(event, 400, "Invalid JSON body");
// Force every submitted route into this group so the client never has to
// carry a groupId; the path parameter is the single source of truth.
const submitted = body["routes"];
const scoped = Array.isArray(submitted)
? submitted.map((r) => ({ ...(r as Record<string, unknown>), groupId }))
: submitted;
const existing = await loadRoutes(env.KV);
const unchanged = new Map(existing.map((r) => [r.id, r]));
const result = validateRoutes(scoped, unchanged);
if (!result.ok) return respondError(event, 400, result.error);
// Replace only this group's routes; every other group is preserved untouched.
const others = existing.filter((r) => r.groupId !== groupId);
const nextAll = [...others, ...result.routes];
try {
await saveRoutes(env.KV, nextAll);
} catch (err) {
log.error({ err }, "Failed to save routes");
return respondError(event, 500, "Failed to save routes");
}
const auth = currentAuth(event);
await recordAudit(env.DB, {
ts: Date.now(),
actorId: auth.session.userId,
actorLogin: auth.session.login,
action: "group.routes.update",
targetType: "group",
targetId: groupId,
groupId,
detail: { count: result.routes.length },
ip: clientIp(event),
});
log.info({ groupId, count: result.routes.length }, "Group routes updated via admin UI");
return { ok: true, count: result.routes.length };
}
/** POST /admin/api/groups/:groupId/invites */
export async function adminGroupInvitesPost(
event: H3Event,
groupId: string,
): Promise<Record<string, unknown>> {
await requireAnyAccess(event);
const env = cfEnv(event);
const access = requireGroupRole(event, groupId, "owner");
if (!access.ok) return accessError(event, access);
const body = await readJsonBody(event);
if (!body) return respondError(event, 400, "Invalid JSON body");
const role = body["role"];
if (role !== "admin" && role !== "viewer") {
return respondError(event, 400, 'role must be "admin" or "viewer"');
}
const note = body["note"];
const auth = currentAuth(event);
const token = await createInvite(env.KV, {
groupId,
role,
expiresAt: Date.now() + 7 * 86400_000,
createdBy: auth.session.login,
note: typeof note === "string" && note.trim() ? note.trim() : undefined,
});
await recordAudit(env.DB, {
ts: Date.now(),
actorId: auth.session.userId,
actorLogin: auth.session.login,
action: "invite.create",
targetType: "group",
targetId: groupId,
groupId,
detail: { role },
ip: clientIp(event),
});
return {
ok: true,
token,
url: `/admin/invite?token=${token}`,
expiresAt: Date.now() + 7 * 86400_000,
};
}
/** GET /admin/api/groups/:groupId/invites */
export async function adminGroupInvitesGet(
event: H3Event,
groupId: string,
): Promise<Record<string, unknown>> {
await requireAnyAccess(event);
const env = cfEnv(event);
const access = requireGroupRole(event, groupId, "owner");
if (!access.ok) return accessError(event, access);
const invites = await listInvites(env.KV, groupId);
return { invites };
}
/** DELETE /admin/api/invites/:token */
export async function adminInviteDelete(event: H3Event, token: string): Promise<Record<string, unknown>> {
await requireAnyAccess(event);
const env = cfEnv(event);
const invite = await getInvite(env.KV, token);
if (!invite) return respondError(event, 404, "Invite not found");
const access = requireGroupRole(event, invite.groupId, "owner");
if (!access.ok) return respondError(event, 403, "Forbidden");
await revokeInvite(env.KV, token);
const auth = currentAuth(event);
await recordAudit(env.DB, {
ts: Date.now(),
actorId: auth.session.userId,
actorLogin: auth.session.login,
action: "invite.revoke",
targetType: "group",
targetId: invite.groupId,
groupId: invite.groupId,
ip: clientIp(event),
});
return { ok: true };
}
/** PUT /admin/api/groups/:groupId/rename */
export async function adminGroupRename(
event: H3Event,
groupId: string,
): Promise<Record<string, unknown>> {
await requireAnyAccess(event);
const env = cfEnv(event);
const access = requireGroupRole(event, groupId, "owner");
if (!access.ok) return accessError(event, access);
const body = await readJsonBody(event);
if (!body) return respondError(event, 400, "Invalid JSON body");
const newId = String(body["newId"] ?? "").trim();
if (!ID_RE.test(newId)) return respondError(event, 400, "newId is invalid");
if (newId === groupId) {
return respondError(event, 400, "newId must differ from the current id");
}
const auth = currentAuth(event);
const existing = await loadGroups(env.KV);
if (existing.some((g) => g.id === newId)) {
return respondError(event, 400, `group id "${newId}" already exists`);
}
const next = existing.map((g) => (g.id === groupId ? { ...g, id: newId } : g));
try {
await saveGroups(env.KV, next);
} catch (err) {
log.error({ err }, "Failed to save groups on rename");
return respondError(event, 500, "Failed to save groups");
}
// Re-point routes, the tenant webhook secret and pending invites.
const routes = await loadRoutes(env.KV);
const touched = routes.filter((r) => r.groupId === groupId);
if (touched.length > 0) {
await saveRoutes(
env.KV,
routes.map((r) => (r.groupId === groupId ? { ...r, groupId: newId } : r)),
);
}
const secret = await getTenantSecret(env.KV, groupId);
if (secret) {
await env.KV.put(`tenant:${newId}`, secret);
await env.KV.delete(`tenant:${groupId}`);
}
await migrateInvites(env.KV, groupId, newId);
await recordAudit(env.DB, {
ts: Date.now(),
actorId: auth.session.userId,
actorLogin: auth.session.login,
action: "group.rename",
targetType: "group",
targetId: newId,
groupId: newId,
detail: { from: groupId, to: newId, routes: touched.length },
ip: clientIp(event),
});
log.info({ from: groupId, to: newId }, "Group renamed via admin UI");
return { ok: true, id: newId };
}
/** GET /admin/api/groups/:groupId/webhook */
export async function adminGroupWebhookGet(
event: H3Event,
groupId: string,
): Promise<Record<string, unknown>> {
await requireAnyAccess(event);
const env = cfEnv(event);
const access = requireGroupRole(event, groupId, "owner");
if (!access.ok) return accessError(event, access);
return {
url: webhookUrl(event, groupId),
hasSecret: (await getTenantSecret(env.KV, groupId)) != null,
};
}
/** POST /admin/api/groups/:groupId/webhook/regenerate */
export async function adminGroupWebhookRegenerate(
event: H3Event,
groupId: string,
): Promise<Record<string, unknown>> {
await requireAnyAccess(event);
const env = cfEnv(event);
const access = requireGroupRole(event, groupId, "owner");
if (!access.ok) return accessError(event, access);
const secret = await setTenantSecret(env.KV, groupId);
const auth = currentAuth(event);
await recordAudit(env.DB, {
ts: Date.now(),
actorId: auth.session.userId,
actorLogin: auth.session.login,
action: "webhook.secret.regenerate",
targetType: "group",
targetId: groupId,
groupId,
ip: clientIp(event),
});
return { ok: true, url: webhookUrl(event, groupId), secret };
}
/** DELETE /admin/api/groups/:groupId/webhook */
export async function adminGroupWebhookDelete(
event: H3Event,
groupId: string,
): Promise<Record<string, unknown>> {
await requireAnyAccess(event);
const env = cfEnv(event);
const access = requireGroupRole(event, groupId, "owner");
if (!access.ok) return accessError(event, access);
await deleteTenantSecret(env.KV, groupId);
const auth = currentAuth(event);
await recordAudit(env.DB, {
ts: Date.now(),
actorId: auth.session.userId,
actorLogin: auth.session.login,
action: "webhook.secret.delete",
targetType: "group",
targetId: groupId,
groupId,
ip: clientIp(event),
});
return { ok: true };
}
/** GET /admin/api/audit */
export async function adminAudit(event: H3Event): Promise<Record<string, unknown>> {
const auth = await requireAnyAccess(event);
const env = cfEnv(event);
const query = getQuery(event);
const limit = Math.min(Math.max(Number(String(query["limit"] ?? "50")), 1), 200);
const groupId = String(query["groupId"] ?? "") || undefined;
if (groupId && !auth.scope.isSuper && !auth.scope.groupIds.has(groupId)) {
return respondError(event, 403, "Forbidden");
}
const entries = await getAuditLog(env.DB, { groupId, limit });
const visible = auth.scope.isSuper
? entries
: entries.filter((e) => e.groupId != null && auth.scope.groupIds.has(e.groupId));
return { audit: visible };
}

104
server/lib/web/auth.ts Normal file
View file

@ -0,0 +1,104 @@
import type { H3Event } from "h3";
import { createError, getHeader } from "h3";
import type { Group, GroupRole } from "../types";
import { getAdminSession, type AdminSession } from "./session";
import {
loadGroups,
resolveScope,
hasAnyAccess,
canEditGroup,
canEditRoutes,
roleAtLeast,
roleAt,
type AccessScope,
} from "./groups";
import { findUserIdByToken } from "../github/store";
import { cfEnv } from "../cf";
export interface AuthContext {
session: AdminSession;
scope: AccessScope;
groups: Group[];
}
const AUTH_KEY = "auth";
/** Read the admin session + access scope for a request (null when logged out). */
export async function loadAuth(event: H3Event): Promise<AuthContext | null> {
const env = cfEnv(event);
const session = await getAdminSession(env.KV, getHeader(event, "cookie"));
if (!session) return null;
const groups = await loadGroups(env.KV);
const scope = resolveScope(env, groups, session.userId, session.login);
return { session, scope, groups };
}
/**
* Authenticate + gate: throws 401 when logged out, 403 when the account has
* no group access at all. Returns the auth context and caches it on the event.
*/
export async function requireAnyAccess(event: H3Event): Promise<AuthContext> {
const auth = await loadAuth(event);
if (!auth) throw createError({ statusCode: 401, statusMessage: "Unauthorized" });
if (!hasAnyAccess(auth.scope)) throw createError({ statusCode: 403, statusMessage: "Forbidden" });
event.context[AUTH_KEY] = auth;
return auth;
}
/** The authenticated context; only valid after `requireAnyAccess`. */
export function currentAuth(event: H3Event): AuthContext {
return event.context[AUTH_KEY] as AuthContext;
}
export type GroupAccess = { ok: true; group: Group } | { ok: false; status: 403 | 404 };
/** Resolves the group and checks the user can at least view it. */
export function requireGroup(event: H3Event, groupId: string): GroupAccess {
const auth = currentAuth(event);
const group = auth.groups.find((g) => g.id === groupId);
if (!group) return { ok: false, status: 404 };
if (!auth.scope.isSuper && !auth.scope.groupIds.has(groupId)) {
return { ok: false, status: 403 };
}
return { ok: true, group };
}
/** Requires at least `min` role in the group (owner|admin|viewer). */
export function requireGroupRole(
event: H3Event,
groupId: string,
min: GroupRole,
): GroupAccess {
const access = requireGroup(event, groupId);
if (!access.ok) return access;
const auth = currentAuth(event);
if (!auth.scope.isSuper && !roleAtLeast(roleAt(auth.scope, groupId), min)) {
return { ok: false, status: 403 };
}
return access;
}
export { canEditGroup, canEditRoutes, roleAt };
/** Best-effort client IP for audit entries (Cloudflare header first). */
export function clientIp(event: H3Event): string | undefined {
return (
getHeader(event, "cf-connecting-ip") ??
getHeader(event, "x-forwarded-for")?.split(",")[0]?.trim()
);
}
/**
* Bearer-token auth for machine-to-machine action endpoints: resolves the
* GitHub userId owning the token, or throws 401.
*/
export async function bearerUserId(event: H3Event): Promise<string> {
const env = cfEnv(event);
const auth = getHeader(event, "authorization");
if (!auth?.startsWith("Bearer "))
throw createError({ statusCode: 401, statusMessage: "Missing authorization" });
const userId = await findUserIdByToken(env.KV, auth.slice(7));
if (!userId)
throw createError({ statusCode: 401, statusMessage: "Invalid or expired token" });
return userId;
}

213
server/lib/web/groups.ts Normal file
View file

@ -0,0 +1,213 @@
import type { Env, Group, GroupMember, GroupRole } from "../types";
import { isAdminUser } from "./session";
import { log } from "../lib/log";
const GROUPS_KEY = "config:groups";
/**
* Normalizes a group's member list. Groups stored with the legacy `adminIds`
* field (or with neither field) get their admins as role "owner" members, so
* every existing group keeps full control after the member-model migration.
* Never mutates the input; returns a fresh array.
*/
export function normalizeGroupMembers(group: Group): GroupMember[] {
const members = group.members;
if (Array.isArray(members) && members.length > 0) {
const seen = new Set<string>();
const out: GroupMember[] = [];
for (const m of members) {
const login = String(m?.login ?? "").trim();
if (!login || seen.has(login.toLowerCase())) continue;
const role: GroupRole = m?.role === "admin" || m?.role === "viewer" ? m.role : "owner";
seen.add(login.toLowerCase());
out.push({ login, role });
}
return out;
}
// Legacy path: adminIds → owners.
const seen = new Set<string>();
const out: GroupMember[] = [];
for (const a of group.adminIds ?? []) {
const login = a.trim();
if (!login || seen.has(login.toLowerCase())) continue;
seen.add(login.toLowerCase());
out.push({ login, role: "owner" });
}
return out;
}
export async function loadGroups(kv: KVNamespace): Promise<Group[]> {
try {
const stored = await kv.get<Group[]>(GROUPS_KEY, "json");
if (Array.isArray(stored)) return stored;
} catch (err) {
log.warn({ err }, "Failed to load groups from KV");
}
return [];
}
export async function saveGroups(kv: KVNamespace, groups: Group[]): Promise<void> {
await kv.put(GROUPS_KEY, JSON.stringify(groups));
}
/** Case-insensitive match of a GitHub userId or login against a list of ids/logins. */
export function identityMatches(ids: string[], userId: string, login: string): boolean {
const wanted = ids.map((s) => s.trim()).filter(Boolean);
if (wanted.length === 0) return false;
return wanted.some((id) => id === userId || id.toLowerCase() === login.toLowerCase());
}
export function isGroupAdmin(group: Group, userId: string, login: string): boolean {
return identityMatches(group.adminIds ?? [], userId, login);
}
/**
* The user's role in a group, derived from normalized members. Legacy
* `adminIds` groups resolve to "owner" for every listed admin.
*/
export function memberRole(group: Group, userId: string, login: string): GroupRole | undefined {
const members = normalizeGroupMembers(group);
const wanted = members.filter((m) => {
const candidate = m.login.trim().toLowerCase();
return candidate === login.toLowerCase() || candidate === userId;
});
return wanted.length > 0 ? wanted[0]!.role : undefined;
}
/**
* Whether an event originating from `owners` (org/user logins) is allowed into
* this group. A group with no owner restriction accepts everything.
*/
export function groupAcceptsOwners(group: Group, owners: string[]): boolean {
const restrict = (group.owners ?? []).map((s) => s.trim().toLowerCase()).filter(Boolean);
if (restrict.length === 0) return true;
const seen = owners.map((s) => s.trim().toLowerCase()).filter(Boolean);
return seen.some((o) => restrict.includes(o));
}
/**
* Whether an event from a webhook `provider` (source platform: github, gitea,
* ...) is allowed into this group. A group with no provider restriction
* accepts every provider. Events without a provider are treated as github.
*/
export function groupAcceptsProvider(group: Group, provider?: string): boolean {
const allowed = (group.providers ?? []).map((s) => s.trim().toLowerCase()).filter(Boolean);
if (allowed.length === 0) return true;
return allowed.includes(provider ?? "github");
}
/**
* Whether an event from a GitHub App `installationId` is allowed into this
* group. A group bound to an installation only accepts events from that
* installation (hard tenant isolation). Unbound groups accept everything here
* (their access is governed by `owners`/`providers` instead).
*/
export function groupAcceptsInstallation(group: Group, installationId?: number): boolean {
if (group.installationId == null) return true;
return installationId != null && group.installationId === installationId;
}
/**
* Auto-provision a GitHub App installation on `installation.created`:
* 1. No-op when a group is already bound to this installation id.
* 2. Otherwise bind every unbound group whose `owners` match the installing
* account login (so existing org groups light up automatically).
* 3. Otherwise create a dedicated `inst-{installationId}` group bound to the
* installation. Returns the group that now owns the installation.
*/
export async function ensureInstallationGroup(
kv: KVNamespace,
installationId: number,
accountLogin: string,
): Promise<Group | null> {
const groups = await loadGroups(kv);
const existing = groups.find((g) => g.installationId === installationId);
if (existing) return existing;
const login = accountLogin.trim().toLowerCase();
const candidates = groups.filter(
(g) =>
g.installationId == null &&
login.length > 0 &&
(g.owners ?? []).some((o) => o.trim().toLowerCase() === login),
);
if (candidates.length > 0) {
const next = groups.map((g) => (candidates.includes(g) ? { ...g, installationId } : g));
await saveGroups(kv, next);
return next.find((g) => g.id === candidates[0]!.id) ?? null;
}
const gid = `inst-${installationId}`;
const dedicated = groups.find((g) => g.id === gid);
const group: Group = {
id: gid,
name: accountLogin.trim() || `Installation ${installationId}`,
adminIds: [],
installationId,
};
await saveGroups(
kv,
dedicated ? groups.map((g) => (g.id === gid ? { ...g, ...group } : g)) : [...groups, group],
);
return group;
}
export interface AccessScope {
isSuper: boolean;
/** Groups the user may view. When isSuper, this is every group. */
groups: Group[];
/** Ids of accessible groups, for quick membership checks. */
groupIds: Set<string>;
/** The user's role per accessible group (absent for super admins). */
roles: Map<string, GroupRole>;
}
export function resolveScope(
env: Env,
groups: Group[],
userId: string,
login: string,
): AccessScope {
const isSuper = isAdminUser(env, userId, login);
const visible = isSuper ? groups : groups.filter((g) => memberRole(g, userId, login) != null);
const roles = new Map<string, GroupRole>();
for (const g of visible) {
const role = memberRole(g, userId, login);
if (role) roles.set(g.id, role);
}
return {
isSuper,
groups: visible,
groupIds: new Set(visible.map((g) => g.id)),
roles,
};
}
/** The user's role in a group, or undefined when they have no access. */
export function roleAt(scope: AccessScope, groupId: string): GroupRole | undefined {
if (scope.isSuper) return "owner";
return scope.roles.get(groupId);
}
/** Role hierarchy: viewer < admin < owner. */
const ROLE_RANK: Record<GroupRole, number> = { viewer: 1, admin: 2, owner: 3 };
export function roleAtLeast(role: GroupRole | undefined, min: GroupRole): boolean {
if (!role) return false;
return ROLE_RANK[role] >= ROLE_RANK[min];
}
/** owner | admin can edit routes; viewers are read-only. */
export function canEditRoutes(scope: AccessScope, groupId: string): boolean {
return roleAtLeast(roleAt(scope, groupId), "admin");
}
/** Only owners (and super admins) may manage a group's settings and members. */
export function canEditGroup(scope: AccessScope, groupId: string): boolean {
return roleAtLeast(roleAt(scope, groupId), "owner");
}
/** True if the user is a super admin or manages at least one group. */
export function hasAnyAccess(scope: AccessScope): boolean {
return scope.isSuper || scope.groups.length > 0;
}

193
server/lib/web/invites.ts Normal file
View file

@ -0,0 +1,193 @@
import { loadGroups, saveGroups, identityMatches, normalizeGroupMembers } from "./groups";
import { log } from "../lib/log";
export interface Invite {
groupId: string;
/** Invited users can never be owners; only admins or viewers. */
role: "admin" | "viewer";
expiresAt: number;
createdBy: string;
note?: string;
}
const INVITE_TTL = 7 * 24 * 3600;
function generateInviteToken(): string {
const bytes = new Uint8Array(32);
crypto.getRandomValues(bytes);
return Array.from(bytes)
.map((b) => b.toString(16).padStart(2, "0"))
.join("");
}
function inviteKey(token: string): string {
return `invite:${token}`;
}
/**
* Per-group index of invite tokens. Listing pending invites reads this key
* instead of `kv.list({ prefix })`, which is eventually consistent and can
* lag behind a fresh write by minutes the index keeps listing reliable.
*/
function indexKey(groupId: string): string {
return `invite:group:${groupId}`;
}
async function readIndex(kv: KVNamespace, groupId: string): Promise<string[]> {
try {
const raw = await kv.get<string[]>(indexKey(groupId), "json");
return Array.isArray(raw) ? raw : [];
} catch (err) {
log.warn({ err, groupId }, "Failed to read invite index");
return [];
}
}
async function writeIndex(kv: KVNamespace, groupId: string, tokens: string[]): Promise<void> {
await kv.put(indexKey(groupId), JSON.stringify(tokens));
}
async function removeFromIndex(kv: KVNamespace, groupId: string, token: string): Promise<void> {
const tokens = (await readIndex(kv, groupId)).filter((t) => t !== token);
await writeIndex(kv, groupId, tokens);
}
export async function createInvite(kv: KVNamespace, invite: Invite): Promise<string> {
const token = generateInviteToken();
await kv.put(inviteKey(token), JSON.stringify(invite), { expirationTtl: INVITE_TTL });
const tokens = await readIndex(kv, invite.groupId);
tokens.push(token);
await writeIndex(kv, invite.groupId, tokens);
return token;
}
export async function getInvite(kv: KVNamespace, token: string): Promise<Invite | null> {
try {
const raw = await kv.get<Invite>(inviteKey(token), "json");
if (!raw) return null;
if (Date.now() > raw.expiresAt) {
await kv.delete(inviteKey(token));
await removeFromIndex(kv, raw.groupId, token);
return null;
}
return raw;
} catch (err) {
log.warn({ err }, "Failed to load invite");
return null;
}
}
export async function consumeInvite(kv: KVNamespace, token: string): Promise<void> {
const raw = await kv.get<Invite>(inviteKey(token), "json");
await kv.delete(inviteKey(token));
if (raw) await removeFromIndex(kv, raw.groupId, token);
}
/** All pending (unexpired) invites of a group, via the per-group index. */
export async function listInvites(
kv: KVNamespace,
groupId: string,
): Promise<Array<Invite & { token: string }>> {
try {
const tokens = await readIndex(kv, groupId);
const out: Array<Invite & { token: string }> = [];
const stale: string[] = [];
for (const token of tokens) {
const invite = await getInvite(kv, token);
if (invite && invite.groupId === groupId) {
out.push({ ...invite, token });
} else {
stale.push(token);
}
}
if (stale.length > 0) {
await writeIndex(
kv,
groupId,
tokens.filter((t) => !stale.includes(t)),
);
}
return out;
} catch (err) {
log.warn({ err }, "Failed to list invites");
return [];
}
}
export async function revokeInvite(kv: KVNamespace, token: string): Promise<void> {
const raw = await kv.get<Invite>(inviteKey(token), "json");
await kv.delete(inviteKey(token));
if (raw) await removeFromIndex(kv, raw.groupId, token);
}
/**
* Re-point every pending invite of a group to its new id (group rename).
* Best-effort: a failure leaves the old invites in place (they will be
* rejected as group-missing after the rename).
*/
export async function migrateInvites(kv: KVNamespace, from: string, to: string): Promise<void> {
try {
const tokens = await readIndex(kv, from);
if (tokens.length === 0) {
await kv.delete(indexKey(from));
return;
}
const moved: string[] = [];
for (const token of tokens) {
const invite = await kv.get<Invite>(inviteKey(token), "json");
if (invite && invite.groupId === from) {
await kv.put(inviteKey(token), JSON.stringify({ ...invite, groupId: to }), {
expirationTtl: INVITE_TTL,
});
moved.push(token);
}
}
await kv.put(indexKey(to), JSON.stringify(moved));
await kv.delete(indexKey(from));
} catch (err) {
log.warn({ err, from, to }, "Failed to migrate invites on group rename");
}
}
/**
* Adds the accepting user to the invited group (or upgrades their role when
* they are already a viewer) and consumes the invite. The invited role is
* never an owner ownership stays with the inviter's discretion.
*/
export async function acceptInvite(
kv: KVNamespace,
token: string,
userId: string,
login: string,
): Promise<
| { ok: true; groupId: string; role: "admin" | "viewer" }
| { ok: false; reason: "invalid" | "group-missing" }
> {
const invite = await getInvite(kv, token);
if (!invite) return { ok: false, reason: "invalid" };
const groups = await loadGroups(kv);
const group = groups.find((g) => g.id === invite.groupId);
if (!group) return { ok: false, reason: "group-missing" };
const members = normalizeGroupMembers(group);
const idx = members.findIndex((m) => identityMatches([m.login], userId, login));
if (idx >= 0) {
if (invite.role === "admin" && members[idx]!.role === "viewer") {
members[idx]!.role = "admin";
}
} else {
members.push({ login, role: invite.role });
}
const next = groups.map((g) =>
g.id === group.id
? {
...g,
members,
adminIds: members.filter((m) => m.role === "owner").map((m) => m.login),
}
: g,
);
await saveGroups(kv, next);
await consumeInvite(kv, token);
return { ok: true, groupId: group.id, role: invite.role };
}

389
server/lib/web/oauth.ts Normal file
View file

@ -0,0 +1,389 @@
import type { H3Event } from "h3";
import {
appendResponseHeader,
createError,
getHeader,
getQuery,
readBody,
sendRedirect,
setResponseStatus,
} from "h3";
import {
getOAuthURL,
handleOAuthCallback as handleGithubOAuthCallback,
getInstallationAccount,
} from "../github/oauth";
import { removeToken, saveDiscordLink, saveTelegramLink } from "../github/store";
import { createAdminSession, adminCookie, getAdminSession } from "./session";
import {
loadGroups,
saveGroups,
resolveScope,
hasAnyAccess,
ensureInstallationGroup,
normalizeGroupMembers,
roleAt,
} from "./groups";
import { clientIp } from "./auth";
import { recordAudit } from "../lib/audit";
import { sendMessage } from "../drivers/telegram/rest";
import { cfEnv } from "../cf";
import type { Env, Group } from "../types";
interface PendingState {
redirectTo: string;
expiresAt: number;
discordUserId?: string;
telegramUserId?: string;
telegramChatId?: string;
}
function linkedPage(login: string): string {
return `<!doctype html><html lang="zh"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>绑定成功</title><style>body{margin:0;min-height:100vh;display:flex;align-items:center;justify-content:center;font-family:system-ui,-apple-system,'Segoe UI',sans-serif;background:#f6f7f9;color:#1f2328}.card{background:#fff;border:1px solid #e5e7eb;border-radius:12px;padding:32px 40px;text-align:center;box-shadow:0 1px 3px rgba(0,0,0,.06)}.ok{color:#16a34a;font-size:40px}h1{font-size:18px;margin:12px 0 4px}p{color:#57606a;font-size:14px;margin:0}</style></head><body><div class="card"><div class="ok">✓</div><h1>GitHub 账号已绑定</h1><p>已连接为 <b>@${login}</b>,现在可以回到 Discord 用 GitHub 评论了。</p></div></body></html>`;
}
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("");
}
function safeRedirectPath(value: string | undefined): string {
if (!value) return "/";
if (!value.startsWith("/")) return "/";
if (value.startsWith("//")) return "/";
if (/^\/\\/.test(value)) return "/";
return value;
}
function escapeHtml(s: string): string {
return s
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;");
}
function selfSignupEnabled(env: Env): boolean {
const flag = (env.ALLOW_SELF_SIGNUP ?? "").trim().toLowerCase();
return flag === "1" || flag === "true" || flag === "yes" || flag === "on";
}
/**
* Opt-in self service: users without any group access get a personal group
* they own, so they can configure their own routing without a super admin.
* The group id is deterministic (`u-{userId}`), so it is created at most once.
*/
async function ensurePersonalGroup(env: Env, userId: string, login: string): Promise<boolean> {
if (!selfSignupEnabled(env)) return false;
const groups = await loadGroups(env.KV);
const gid = `u-${userId}`;
if (groups.some((g) => g.id === gid)) return true;
const personal: Group = {
id: gid,
name: `@${login}`,
members: [{ login, role: "owner" }],
adminIds: [login],
};
await saveGroups(env.KV, [...groups, personal]);
await recordAudit(env.DB, {
ts: Date.now(),
actorId: userId,
actorLogin: login,
action: "group.create",
targetType: "group",
targetId: gid,
groupId: gid,
detail: { auto: true },
});
return true;
}
/**
* Post-install choice page: pick which group the installation binds to.
* Options are the groups the signed-in user owns (role `owner`), plus a
* default "create a new group" choice.
*/
function installPage(opts: {
installationId: number;
accountLogin: string;
owned: Group[];
}): string {
const { installationId, accountLogin, owned } = opts;
const accountLine = accountLogin
? `<p>账号:<b>${escapeHtml(accountLogin)}</b>(安装 ID <code>${installationId}</code></p>`
: `<p>安装 ID<code>${installationId}</code></p>`;
const ownedOptions = owned
.map(
(g) =>
`<label class="opt"><input type="radio" name="group" value="${escapeHtml(g.id)}"><span><b>${escapeHtml(g.name)}</b> <code>${escapeHtml(g.id)}</code></span></label>`,
)
.join("");
const ownedNote = owned.length
? '<p class="hint">也可以选择绑定到你有 owner 权限的已有分组:</p>'
: "";
return `<!doctype html><html lang="zh"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>安装 GitHub App</title><style>body{margin:0;min-height:100vh;display:flex;align-items:center;justify-content:center;font-family:system-ui,-apple-system,'Segoe UI',sans-serif;background:#f6f7f9;color:#1f2328}.card{background:#fff;border:1px solid #e5e7eb;border-radius:12px;padding:28px 32px;width:min(480px,92vw);box-shadow:0 1px 3px rgba(0,0,0,.06)}h1{font-size:17px;margin:0 0 4px}p{color:#57606a;font-size:13.5px;margin:6px 0}code{background:#f0f1f3;border-radius:4px;padding:1px 5px;font-size:12.5px}.opt{display:flex;align-items:flex-start;gap:8px;padding:9px 10px;border:1px solid #e5e7eb;border-radius:8px;margin-top:8px;cursor:pointer}.opt:hover{background:#fafbfc}.hint{font-size:12.5px;color:#8b949e;margin-top:10px}.btn{display:inline-block;margin-top:14px;background:#1f2328;color:#fff;border:0;border-radius:8px;padding:10px 18px;font-size:14px;cursor:pointer}.btn:hover{background:#32383f}.skip{margin-left:12px;color:#8b949e;font-size:13px;text-decoration:none}</style></head><body><div class="card"><h1>GitHub App 安装成功</h1>${accountLine}<p>将安装绑定到哪个分组?建议直接创建新分组,之后可以在控制台添加路由与成员。</p><form method="post" action="/auth/github/install/bind"><input type="hidden" name="installation_id" value="${installationId}"><label class="opt"><input type="radio" name="group" value="" checked><span><b>创建新分组</b> <code>inst-${installationId}</code></span></label>${ownedNote}${ownedOptions}<button class="btn" type="submit">确定</button></form></div></body></html>`;
}
/** GET /auth/github — start the OAuth flow. */
export async function handleOAuthStart(event: H3Event): Promise<void> {
const env = cfEnv(event);
const query = getQuery(event);
const redirectTo = safeRedirectPath(String(query["redirect"] ?? ""));
const state = generateRandomHex(16);
const pending: PendingState = {
redirectTo,
expiresAt: Date.now() + 10 * 60 * 1000,
};
await env.KV.put(`state:${state}`, JSON.stringify(pending), { expirationTtl: 600 });
await sendRedirect(event, getOAuthURL(env.GITHUB_CLIENT_ID ?? "", state));
}
/** GET /auth/github/install — post-install choice page. */
export async function handleInstallPage(event: H3Event): Promise<string | void> {
const env = cfEnv(event);
const query = getQuery(event);
const rawId = String(query["installation_id"] ?? "");
const installationId = Number(rawId);
if (!rawId || !Number.isInteger(installationId) || installationId <= 0) {
throw createError({ statusCode: 400, statusMessage: "Missing installation_id" });
}
const session = await getAdminSession(env.KV, getHeader(event, "cookie"));
if (!session) {
const target = `/auth/github/install?installation_id=${installationId}`;
await sendRedirect(event, `/auth/github?redirect=${encodeURIComponent(target)}`);
return;
}
const accountLogin =
(await getInstallationAccount(env.GITHUB_APP_ID ?? "", env.GITHUB_PRIVATE_KEY ?? "", installationId)) ??
"";
const groups = await loadGroups(env.KV);
const scope = resolveScope(env, groups, session.userId, session.login);
const owned = groups.filter((g) => roleAt(scope, g.id) === "owner");
return installPage({ installationId, accountLogin, owned });
}
/** POST /auth/github/install/bind — provision the chosen binding. */
export async function handleInstallBind(event: H3Event): Promise<void> {
const env = cfEnv(event);
const session = await getAdminSession(env.KV, getHeader(event, "cookie"));
if (!session) {
await sendRedirect(event, "/admin?error=forbidden");
return;
}
const body = (await readBody(event).catch(() => ({}))) as Record<string, string | undefined>;
const rawId = String(body["installation_id"] ?? "");
const installationId = Number(rawId);
if (!Number.isInteger(installationId) || installationId <= 0) {
throw createError({ statusCode: 400, statusMessage: "Missing installation_id" });
}
const chosenGroupId = String(body["group"] ?? "").trim();
const groups = await loadGroups(env.KV);
const scope = resolveScope(env, groups, session.userId, session.login);
const bind = async (groupId: string, group: Group | null): Promise<void> => {
if (!group) {
await sendRedirect(event, "/admin?error=install");
return;
}
const next = groups.map((g) => (g.id === group.id ? { ...g, installationId } : g));
await saveGroups(env.KV, next);
await recordAudit(env.DB, {
ts: Date.now(),
actorId: session.userId,
actorLogin: session.login,
action: "installation.bind",
targetType: "group",
targetId: groupId,
groupId,
detail: { installationId },
ip: clientIp(event),
});
await sendRedirect(event, "/admin?install=ok");
};
if (chosenGroupId) {
// Binding to an existing group requires owner permission on it.
const group = groups.find((g) => g.id === chosenGroupId);
if (!group || roleAt(scope, chosenGroupId) !== "owner") {
await sendRedirect(event, "/admin?error=forbidden");
return;
}
return bind(chosenGroupId, group);
}
// Default: auto-create a dedicated inst-{id} group.
const accountLogin =
(await getInstallationAccount(env.GITHUB_APP_ID ?? "", env.GITHUB_PRIVATE_KEY ?? "", installationId)) ??
"";
const group = await ensureInstallationGroup(env.KV, installationId, accountLogin);
if (!group) {
await sendRedirect(event, "/admin?error=install");
return;
}
await recordAudit(env.DB, {
ts: Date.now(),
actorId: session.userId,
actorLogin: session.login,
action: "installation.created",
targetType: "group",
targetId: group.id,
groupId: group.id,
detail: { source: "setup_url", account: accountLogin || undefined },
ip: clientIp(event),
});
// Self-service SaaS: the installer manages their own auto-created group.
if (selfSignupEnabled(env)) {
const members = normalizeGroupMembers(group);
const alreadyMember = members.some(
(m) => m.login.toLowerCase() === session.login.toLowerCase() || m.login === session.userId,
);
if (!alreadyMember) {
const updated: Group = {
...group,
members: [...members, { login: session.login, role: "owner" }],
adminIds: [...new Set([...(group.adminIds ?? []), session.login])],
};
const all = await loadGroups(env.KV);
await saveGroups(env.KV, all.map((g) => (g.id === group.id ? updated : g)));
await recordAudit(env.DB, {
ts: Date.now(),
actorId: session.userId,
actorLogin: session.login,
action: "group.member.add",
targetType: "group",
targetId: group.id,
groupId: group.id,
detail: { login: session.login, role: "owner", auto: true },
ip: clientIp(event),
});
}
}
await sendRedirect(event, "/admin?install=ok");
}
/** GET /auth/github/callback — OAuth callback. */
export async function handleOAuthCallback(event: H3Event): Promise<unknown> {
const env = cfEnv(event);
const query = getQuery(event);
const code = String(query["code"] ?? "");
const state = String(query["state"] ?? "");
if (!code || !state) {
setResponseStatus(event, 400);
return { error: "Missing code or state" };
}
const raw = await env.KV.get(`state:${state}`, "json");
if (!raw) {
setResponseStatus(event, 400);
return { error: "Invalid or expired state" };
}
const pending = raw as PendingState;
if (Date.now() > pending.expiresAt) {
await env.KV.delete(`state:${state}`);
setResponseStatus(event, 400);
return { error: "Invalid or expired state" };
}
await env.KV.delete(`state:${state}`);
const result = await handleGithubOAuthCallback(
env.GITHUB_CLIENT_ID ?? "",
env.GITHUB_CLIENT_SECRET ?? "",
code,
state,
env.KV,
);
if (!result) {
setResponseStatus(event, 400);
return { error: "OAuth failed" };
}
// Discord account-linking flow: bind the Discord user to this GitHub account.
if (pending.discordUserId) {
await saveDiscordLink(env.DB, pending.discordUserId, result.userId);
const isBrowserLink = (getHeader(event, "accept") ?? "").includes("text/html");
if (isBrowserLink) return linkedPage(result.login);
return { ok: true, discordUserId: pending.discordUserId, login: result.login };
}
// Telegram account-linking flow: bind the Telegram user to this GitHub account.
if (pending.telegramUserId) {
await saveTelegramLink(env.DB, pending.telegramUserId, result.userId);
if (pending.telegramChatId && env.TELEGRAM_TOKEN) {
await sendMessage(
env.TELEGRAM_TOKEN,
pending.telegramChatId,
`✅ GitHub 账号已绑定:**@${result.login}**。现在可以用 /gh comment 评论了。`,
).catch(() => undefined);
}
return { ok: true, telegramUserId: pending.telegramUserId, login: result.login };
}
const isBrowser = (getHeader(event, "accept") ?? "").includes("text/html");
if (isBrowser) {
// Invite accept flow: the redirect target is the invite page, which
// processes the token after the session exists. Skip the access gate so
// non-members can get in and accept.
const isInviteFlow =
pending.redirectTo.startsWith("/admin/invite") ||
pending.redirectTo.startsWith("/admin/invite?");
let groups = await loadGroups(env.KV);
let scope = resolveScope(env, groups, result.userId, result.login);
if (!hasAnyAccess(scope) && !isInviteFlow) {
const created = await ensurePersonalGroup(env, result.userId, result.login);
if (created) {
groups = await loadGroups(env.KV);
scope = resolveScope(env, groups, result.userId, result.login);
}
}
if (!hasAnyAccess(scope) && !isInviteFlow) {
await sendRedirect(event, "/admin?error=forbidden");
return;
}
const sessionId = await createAdminSession(env.KV, result.userId, result.login);
appendResponseHeader(event, "Set-Cookie", adminCookie(sessionId));
await recordAudit(env.DB, {
ts: Date.now(),
actorId: result.userId,
actorLogin: result.login,
action: "session.login",
ip: clientIp(event),
});
await sendRedirect(event, pending.redirectTo);
return;
}
return {
userId: result.userId,
login: result.login,
redirectTo: pending.redirectTo,
};
}
/** DELETE /auth/token/:userId — revoke a stored user token. */
export async function handleTokenDelete(event: H3Event, userId: string): Promise<unknown> {
const env = cfEnv(event);
const session = await getAdminSession(env.KV, getHeader(event, "cookie"));
if (!session) {
setResponseStatus(event, 401);
return { error: "Unauthorized" };
}
await removeToken(env.KV, userId);
await recordAudit(env.DB, {
ts: Date.now(),
actorId: session.userId,
actorLogin: session.login,
action: "token.delete",
targetType: "token",
targetId: userId,
ip: clientIp(event),
});
return { ok: true };
}

View file

@ -0,0 +1,35 @@
import type { H3Event } from "h3";
import { getQuery } from "h3";
function esc(s: string): string {
return String(s)
.replace(/&/g, "&amp;")
.replace(/"/g, "&quot;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;");
}
/** GET /api/richheader — Open Graph page for Telegram avatar link-preview cards. */
export function handleRichHeader(event: H3Event): string {
const query = getQuery(event);
const title = String(query["title"] ?? "");
const content = String(query["content"] ?? "");
const avatar = String(query["avatar"] ?? "");
return `<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<meta property="og:type" content="website">
<meta property="og:site_name" content="${esc(title)}">
${content ? `<meta property="og:title" content="${esc(content)}">` : ""}
${avatar ? `<meta property="og:image" content="${esc(avatar)}">` : ""}
</head>
<body style="font-family: system-ui, -apple-system, sans-serif; text-align: center; padding: 40px; max-width: 600px; margin: 0 auto;">
${avatar ? `<img src="${esc(avatar)}" alt="Avatar" style="width: 128px; height: 128px; border-radius: 50%; margin-bottom: 20px;">` : ""}
${title ? `<h1 style="margin: 0 0 10px 0; color: #333;">${esc(title)}</h1>` : ""}
${content ? `<p style="margin: 0; color: #666;">${esc(content)}</p>` : ""}
<p style="margin-top: 40px; font-size: 14px; color: #999;">This page is used for Open Graph meta tags (richheader) only.</p>
</body>
</html>`;
}

77
server/lib/web/session.ts Normal file
View file

@ -0,0 +1,77 @@
import type { Env } from "../types";
const SESSION_COOKIE = "wh_admin_session";
const SESSION_TTL = 7 * 24 * 3600;
export interface AdminSession {
userId: string;
login: string;
}
export function isAdminUser(env: Env, userId: string, login: string): boolean {
const ids = (env.ADMIN_USER_IDS ?? "")
.split(",")
.map((s) => s.trim())
.filter(Boolean);
if (ids.length === 0) return false;
return ids.includes(userId) || ids.some((id) => id.toLowerCase() === login.toLowerCase());
}
function generateSessionId(): string {
const bytes = new Uint8Array(32);
crypto.getRandomValues(bytes);
return Array.from(bytes)
.map((b) => b.toString(16).padStart(2, "0"))
.join("");
}
export async function createAdminSession(
kv: KVNamespace,
userId: string,
login: string,
): Promise<string> {
const sessionId = generateSessionId();
const session: AdminSession = { userId, login };
await kv.put(`session:${sessionId}`, JSON.stringify(session), {
expirationTtl: SESSION_TTL,
});
return sessionId;
}
export function adminCookie(sessionId: string): string {
return `${SESSION_COOKIE}=${sessionId}; Path=/; HttpOnly; Secure; SameSite=Lax; Max-Age=${SESSION_TTL}`;
}
export function clearAdminCookie(): string {
return `${SESSION_COOKIE}=; Path=/; HttpOnly; Secure; SameSite=Lax; Max-Age=0`;
}
function parseCookies(header: string | undefined): Record<string, string> {
const out: Record<string, string> = {};
if (!header) return out;
for (const part of header.split(";")) {
const idx = part.indexOf("=");
if (idx === -1) continue;
out[part.slice(0, idx).trim()] = part.slice(idx + 1).trim();
}
return out;
}
export async function getAdminSession(
kv: KVNamespace,
cookieHeader: string | undefined,
): Promise<AdminSession | null> {
const sessionId = parseCookies(cookieHeader)[SESSION_COOKIE];
if (!sessionId) return null;
const raw = await kv.get<AdminSession>(`session:${sessionId}`, "json");
if (!raw) return null;
return raw;
}
export async function destroyAdminSession(
kv: KVNamespace,
cookieHeader: string | undefined,
): Promise<void> {
const sessionId = parseCookies(cookieHeader)[SESSION_COOKIE];
if (sessionId) await kv.delete(`session:${sessionId}`);
}

38
server/lib/web/tenants.ts Normal file
View file

@ -0,0 +1,38 @@
import { log } from "../lib/log";
/**
* Per-group webhook tenant secrets. A group can opt into its own webhook
* ingress (`POST /webhook/{groupId}`) with an independent secret, so SaaS
* users can configure their own GitHub/Gitea/custom webhooks without sharing
* (or even knowing) the operator's global secrets.
*/
const TENANT_KEY = (groupId: string): string => `tenant:${groupId}`;
/** 32 random bytes → 64 hex chars. */
export function generateTenantSecret(): string {
const bytes = new Uint8Array(32);
crypto.getRandomValues(bytes);
return Array.from(bytes)
.map((b) => b.toString(16).padStart(2, "0"))
.join("");
}
export async function getTenantSecret(kv: KVNamespace, groupId: string): Promise<string | null> {
try {
return await kv.get(TENANT_KEY(groupId), "text");
} catch (err) {
log.warn({ err, groupId }, "Failed to read tenant webhook secret");
return null;
}
}
/** Generate (or regenerate) the group's webhook secret. */
export async function setTenantSecret(kv: KVNamespace, groupId: string): Promise<string> {
const secret = generateTenantSecret();
await kv.put(TENANT_KEY(groupId), secret);
return secret;
}
export async function deleteTenantSecret(kv: KVNamespace, groupId: string): Promise<void> {
await kv.delete(TENANT_KEY(groupId));
}

143
server/lib/webhook.ts Normal file
View file

@ -0,0 +1,143 @@
import type { H3Event } from "h3";
import { getHeader, readRawBody, setResponseStatus } from "h3";
import type { Env } from "./types";
import { detectProvider } from "./providers";
import { dispatchEvent } from "./core/dispatch";
import { loadConfig } from "./config";
import { loadGroups, ensureInstallationGroup } from "./web/groups";
import { getTenantSecret } from "./web/tenants";
import { recordAudit } from "./lib/audit";
import { cfEnv, cfWaitUntil, headersFrom } from "./cf";
import { log } from "./lib/log";
const MAX_BODY_SIZE = 1024 * 1024;
export interface WebhookResult {
status: 200 | 400 | 401 | 404 | 413;
body: unknown;
}
/**
* Core webhook processing. Without `tenantId` this is the legacy global
* endpoint (`POST /webhook`): events verify against the operator's global
* secrets and may dispatch into every route. With a `tenantId` (a group id,
* `POST /webhook/{groupId}`) the group's own secret is used for verification
* (GITHUB_WEBHOOK_SECRET/GITEA_WEBHOOK_SECRET are overridden per request) and
* only that group's routes are eligible.
*/
export async function processWebhook(
env: Env,
body: string,
headers: Record<string, string>,
waitUntil: (promise: Promise<unknown>) => void,
tenantId?: string,
): Promise<WebhookResult> {
let effectiveEnv = env;
if (tenantId) {
const groups = await loadGroups(env.KV);
if (!groups.some((g) => g.id === tenantId)) {
return { status: 404, body: { error: "Group not found" } };
}
const secret = await getTenantSecret(env.KV, tenantId);
if (!secret) {
return { status: 404, body: { error: "Webhook disabled for this group" } };
}
effectiveEnv = { ...env, GITHUB_WEBHOOK_SECRET: secret, GITEA_WEBHOOK_SECRET: secret };
}
const provider = detectProvider(headers);
if (!provider) {
return { status: 400, body: { error: "Unknown webhook provider" } };
}
if (!(await provider.verify(body, headers, effectiveEnv))) {
return { status: 401, body: { error: "Invalid signature" } };
}
const event = provider.parse(body, headers);
if (!event) {
return { status: 400, body: { error: "Invalid event" } };
}
// Auto-provision GitHub App installations so tenant isolation is configured
// without manual id entry: a group is created (or existing matching groups
// are bound) before the event is dispatched.
if (
provider.id === "github" &&
event.event === "installation" &&
event.payload.action === "created" &&
event.installationId != null
) {
const install = event.payload.installation as { account?: { login?: string } } | undefined;
const account = install?.account?.login ?? "";
try {
const group = await ensureInstallationGroup(env.KV, event.installationId, account);
if (group) {
await recordAudit(env.DB, {
ts: Date.now(),
actorLogin: account || undefined,
action: "installation.created",
targetType: "group",
targetId: group.id,
groupId: group.id,
});
}
} catch (err) {
log.warn(
{ err, installationId: event.installationId },
"Failed to auto-provision installation group",
);
}
}
if (event.deliveryId) {
// Tenant-scoped dedup keys: different accounts can reuse the same
// delivery id, so the global key would wrongly dedupe across tenants.
const key = tenantId
? `delivery:${tenantId}:${event.deliveryId}`
: `delivery:${event.deliveryId}`;
const seen = await env.KV.get(key);
if (seen) {
return { status: 200, body: { ok: true, duplicate: true } };
}
await env.KV.put(key, "1", { expirationTtl: 300 });
}
const config = await loadConfig(env);
if (tenantId) {
config.routes = config.routes.filter((r) => r.groupId === tenantId);
}
const dispatch = dispatchEvent(config, event, env).catch((err) =>
log.error(err, "Dispatch failed"),
);
waitUntil(dispatch);
return { status: 200, body: { ok: true } };
}
/** h3 wrapper for `POST /webhook` / `POST /webhook/:groupId`. */
export async function handleWebhookRequest(
event: H3Event,
tenantId?: string,
): Promise<unknown> {
const contentLength = Number(getHeader(event, "content-length") ?? 0);
if (contentLength > MAX_BODY_SIZE) {
setResponseStatus(event, 413);
return { error: "Request too large" };
}
const body = (await readRawBody(event, "utf8")) ?? "";
if (body.length > MAX_BODY_SIZE) {
setResponseStatus(event, 413);
return { error: "Request too large" };
}
const result = await processWebhook(
cfEnv(event),
body,
headersFrom(event),
cfWaitUntil(event),
tenantId,
);
setResponseStatus(event, result.status);
return result.body;
}