mirror of
https://github.com/ReCloudStudio/WebHooker.git
synced 2026-09-23 00:21:28 +00:00
feat: migrate to Nuxt 4 (Nitro) and Tailwind CSS v3
This commit is contained in:
parent
f4959eebf8
commit
b139712a91
166 changed files with 19790 additions and 5539 deletions
22
server/error-handler.ts
Normal file
22
server/error-handler.ts
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
import defaultNitroErrorHandler, {
|
||||
defineNitroErrorHandler,
|
||||
} from "nitropack/runtime/error";
|
||||
import { setResponseStatus } from "h3";
|
||||
|
||||
/**
|
||||
* Keep the legacy API error contract (`{ error: string }` JSON) for the
|
||||
* machine-facing endpoints; everything else (pages) uses the default handler.
|
||||
*/
|
||||
export default defineNitroErrorHandler((error, event) => {
|
||||
const path = event.path ?? "";
|
||||
if (
|
||||
path.startsWith("/admin/api/") ||
|
||||
path.startsWith("/api/") ||
|
||||
path.startsWith("/auth/") ||
|
||||
path.startsWith("/webhook")
|
||||
) {
|
||||
setResponseStatus(event, error.statusCode || 500);
|
||||
return { error: error.statusMessage || error.message || "Internal Server Error" };
|
||||
}
|
||||
return defaultNitroErrorHandler(error, event);
|
||||
});
|
||||
39
server/lib/cf.ts
Normal file
39
server/lib/cf.ts
Normal 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
65
server/lib/config.ts
Normal 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
281
server/lib/core/dispatch.ts
Normal 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");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
174
server/lib/drivers/discord/commands.ts
Normal file
174
server/lib/drivers/discord/commands.ts
Normal 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);
|
||||
}
|
||||
31
server/lib/drivers/discord/index.ts
Normal file
31
server/lib/drivers/discord/index.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
534
server/lib/drivers/discord/interactions.ts
Normal file
534
server/lib/drivers/discord/interactions.ts
Normal 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 OAuth(GITHUB_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));
|
||||
}
|
||||
}
|
||||
}
|
||||
52
server/lib/drivers/discord/render.ts
Normal file
52
server/lib/drivers/discord/render.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
103
server/lib/drivers/discord/rest.ts
Normal file
103
server/lib/drivers/discord/rest.ts
Normal 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);
|
||||
}
|
||||
16
server/lib/drivers/index.ts
Normal file
16
server/lib/drivers/index.ts
Normal 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;
|
||||
}
|
||||
247
server/lib/drivers/telegram/commands.ts
Normal file
247
server/lib/drivers/telegram/commands.ts
Normal 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 OAuth(GITHUB_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");
|
||||
}
|
||||
}
|
||||
64
server/lib/drivers/telegram/index.ts
Normal file
64
server/lib/drivers/telegram/index.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
63
server/lib/drivers/telegram/render.ts
Normal file
63
server/lib/drivers/telegram/render.ts
Normal file
|
|
@ -0,0 +1,63 @@
|
|||
import type { NeutralMessage } from "../../types";
|
||||
|
||||
function esc(s: string): string {
|
||||
return s
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """);
|
||||
}
|
||||
|
||||
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");
|
||||
}
|
||||
189
server/lib/drivers/telegram/rest.ts
Normal file
189
server/lib/drivers/telegram/rest.ts
Normal 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);
|
||||
}
|
||||
45
server/lib/drivers/telegram/updates.ts
Normal file
45
server/lib/drivers/telegram/updates.ts
Normal 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");
|
||||
}
|
||||
25
server/lib/drivers/types.ts
Normal file
25
server/lib/drivers/types.ts
Normal 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
127
server/lib/events/match.ts
Normal 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));
|
||||
}
|
||||
222
server/lib/formatters/check.ts
Normal file
222
server/lib/formatters/check.ts
Normal 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,
|
||||
);
|
||||
}
|
||||
64
server/lib/formatters/colors.ts
Normal file
64
server/lib/formatters/colors.ts
Normal 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: "🔄",
|
||||
};
|
||||
43
server/lib/formatters/comments.ts
Normal file
43
server/lib/formatters/comments.ts
Normal 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,
|
||||
);
|
||||
}
|
||||
51
server/lib/formatters/commit-comment.ts
Normal file
51
server/lib/formatters/commit-comment.ts
Normal 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,
|
||||
);
|
||||
}
|
||||
88
server/lib/formatters/create.ts
Normal file
88
server/lib/formatters/create.ts
Normal 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,
|
||||
);
|
||||
}
|
||||
89
server/lib/formatters/custom.ts
Normal file
89
server/lib/formatters/custom.ts
Normal 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,
|
||||
);
|
||||
}
|
||||
179
server/lib/formatters/deployment.ts
Normal file
179
server/lib/formatters/deployment.ts
Normal 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,
|
||||
);
|
||||
}
|
||||
96
server/lib/formatters/discussion.ts
Normal file
96
server/lib/formatters/discussion.ts
Normal 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,
|
||||
);
|
||||
}
|
||||
26
server/lib/formatters/generic.ts
Normal file
26
server/lib/formatters/generic.ts
Normal 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,
|
||||
);
|
||||
}
|
||||
65
server/lib/formatters/helpers.ts
Normal file
65
server/lib/formatters/helpers.ts
Normal 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}\``;
|
||||
}
|
||||
109
server/lib/formatters/index.ts
Normal file
109
server/lib/formatters/index.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
87
server/lib/formatters/issues.ts
Normal file
87
server/lib/formatters/issues.ts
Normal 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,
|
||||
);
|
||||
}
|
||||
64
server/lib/formatters/label.ts
Normal file
64
server/lib/formatters/label.ts
Normal 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,
|
||||
);
|
||||
}
|
||||
34
server/lib/formatters/member.ts
Normal file
34
server/lib/formatters/member.ts
Normal 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,
|
||||
);
|
||||
}
|
||||
83
server/lib/formatters/milestone.ts
Normal file
83
server/lib/formatters/milestone.ts
Normal 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,
|
||||
);
|
||||
}
|
||||
36
server/lib/formatters/ping.ts
Normal file
36
server/lib/formatters/ping.ts
Normal 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,
|
||||
);
|
||||
}
|
||||
116
server/lib/formatters/pull-request.ts
Normal file
116
server/lib/formatters/pull-request.ts
Normal 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,
|
||||
);
|
||||
}
|
||||
109
server/lib/formatters/push.ts
Normal file
109
server/lib/formatters/push.ts
Normal 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,
|
||||
);
|
||||
}
|
||||
62
server/lib/formatters/release.ts
Normal file
62
server/lib/formatters/release.ts
Normal 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,
|
||||
);
|
||||
}
|
||||
58
server/lib/formatters/repo.ts
Normal file
58
server/lib/formatters/repo.ts
Normal 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,
|
||||
);
|
||||
}
|
||||
92
server/lib/formatters/repository.ts
Normal file
92
server/lib/formatters/repository.ts
Normal 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,
|
||||
);
|
||||
}
|
||||
114
server/lib/formatters/review.ts
Normal file
114
server/lib/formatters/review.ts
Normal 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,
|
||||
);
|
||||
}
|
||||
173
server/lib/formatters/security.ts
Normal file
173
server/lib/formatters/security.ts
Normal 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,
|
||||
);
|
||||
}
|
||||
194
server/lib/formatters/workflow.ts
Normal file
194
server/lib/formatters/workflow.ts
Normal 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
258
server/lib/github/oauth.ts
Normal 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
121
server/lib/github/store.ts
Normal 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
28
server/lib/http.ts
Normal 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
107
server/lib/lib/audit.ts
Normal 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
63
server/lib/lib/i18n.ts
Normal 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;
|
||||
}
|
||||
208
server/lib/lib/locales/en.ts
Normal file
208
server/lib/lib/locales/en.ts
Normal 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}",
|
||||
},
|
||||
};
|
||||
208
server/lib/lib/locales/zh.ts
Normal file
208
server/lib/lib/locales/zh.ts
Normal 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
10
server/lib/lib/log.ts
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
export const log = {
|
||||
info: (msg: string | object, ...args: unknown[]): void =>
|
||||
console.log(JSON.stringify({ level: "info", msg, ...args })),
|
||||
warn: (msg: string | object, ...args: unknown[]): void =>
|
||||
console.warn(JSON.stringify({ level: "warn", msg, ...args })),
|
||||
error: (msg: string | object, ...args: unknown[]): void =>
|
||||
console.error(JSON.stringify({ level: "error", msg, ...args })),
|
||||
fatal: (msg: string | object, ...args: unknown[]): void =>
|
||||
console.error(JSON.stringify({ level: "fatal", msg, ...args })),
|
||||
};
|
||||
132
server/lib/lib/send-log.ts
Normal file
132
server/lib/lib/send-log.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
44
server/lib/providers/custom/index.ts
Normal file
44
server/lib/providers/custom/index.ts
Normal 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;
|
||||
}
|
||||
},
|
||||
};
|
||||
20
server/lib/providers/gitea/index.ts
Normal file
20
server/lib/providers/gitea/index.ts
Normal 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);
|
||||
},
|
||||
};
|
||||
76
server/lib/providers/gitea/parse.ts
Normal file
76
server/lib/providers/gitea/parse.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
15
server/lib/providers/gitea/verify.ts
Normal file
15
server/lib/providers/gitea/verify.ts
Normal 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);
|
||||
}
|
||||
20
server/lib/providers/github/index.ts
Normal file
20
server/lib/providers/github/index.ts
Normal 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);
|
||||
},
|
||||
};
|
||||
20
server/lib/providers/github/parse.ts
Normal file
20
server/lib/providers/github/parse.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
11
server/lib/providers/github/verify.ts
Normal file
11
server/lib/providers/github/verify.ts
Normal 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);
|
||||
}
|
||||
36
server/lib/providers/hmac.ts
Normal file
36
server/lib/providers/hmac.ts
Normal 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;
|
||||
}
|
||||
24
server/lib/providers/index.ts
Normal file
24
server/lib/providers/index.ts
Normal 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;
|
||||
}
|
||||
23
server/lib/providers/types.ts
Normal file
23
server/lib/providers/types.ts
Normal 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
230
server/lib/types.ts
Normal 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
168
server/lib/web/actions.ts
Normal 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
948
server/lib/web/admin.ts
Normal 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
104
server/lib/web/auth.ts
Normal 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
213
server/lib/web/groups.ts
Normal 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
193
server/lib/web/invites.ts
Normal 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
389
server/lib/web/oauth.ts
Normal 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, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """);
|
||||
}
|
||||
|
||||
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 };
|
||||
}
|
||||
35
server/lib/web/richheader.ts
Normal file
35
server/lib/web/richheader.ts
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
import type { H3Event } from "h3";
|
||||
import { getQuery } from "h3";
|
||||
|
||||
function esc(s: string): string {
|
||||
return String(s)
|
||||
.replace(/&/g, "&")
|
||||
.replace(/"/g, """)
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">");
|
||||
}
|
||||
|
||||
/** 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
77
server/lib/web/session.ts
Normal 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
38
server/lib/web/tenants.ts
Normal 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
143
server/lib/webhook.ts
Normal 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;
|
||||
}
|
||||
3
server/routes/api/close.post.ts
Normal file
3
server/routes/api/close.post.ts
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
import { apiClose } from "../../lib/web/actions";
|
||||
|
||||
export default defineEventHandler((event) => apiClose(event));
|
||||
3
server/routes/api/comment.post.ts
Normal file
3
server/routes/api/comment.post.ts
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
import { apiComment } from "../../lib/web/actions";
|
||||
|
||||
export default defineEventHandler((event) => apiComment(event));
|
||||
3
server/routes/api/merge.post.ts
Normal file
3
server/routes/api/merge.post.ts
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
import { apiMerge } from "../../lib/web/actions";
|
||||
|
||||
export default defineEventHandler((event) => apiMerge(event));
|
||||
3
server/routes/api/react.post.ts
Normal file
3
server/routes/api/react.post.ts
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
import { apiReact } from "../../lib/web/actions";
|
||||
|
||||
export default defineEventHandler((event) => apiReact(event));
|
||||
3
server/routes/api/richheader.get.ts
Normal file
3
server/routes/api/richheader.get.ts
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
import { handleRichHeader } from "../../lib/web/richheader";
|
||||
|
||||
export default defineEventHandler((event) => handleRichHeader(event));
|
||||
3
server/routes/auth/github.get.ts
Normal file
3
server/routes/auth/github.get.ts
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
import { handleOAuthStart } from "../../lib/web/oauth";
|
||||
|
||||
export default defineEventHandler((event) => handleOAuthStart(event));
|
||||
3
server/routes/auth/github/callback.get.ts
Normal file
3
server/routes/auth/github/callback.get.ts
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
import { handleOAuthCallback } from "../../../lib/web/oauth";
|
||||
|
||||
export default defineEventHandler((event) => handleOAuthCallback(event));
|
||||
3
server/routes/auth/github/install.get.ts
Normal file
3
server/routes/auth/github/install.get.ts
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
import { handleInstallPage } from "../../../lib/web/oauth";
|
||||
|
||||
export default defineEventHandler((event) => handleInstallPage(event));
|
||||
3
server/routes/auth/github/install/bind.post.ts
Normal file
3
server/routes/auth/github/install/bind.post.ts
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
import { handleInstallBind } from "../../../../lib/web/oauth";
|
||||
|
||||
export default defineEventHandler((event) => handleInstallBind(event));
|
||||
6
server/routes/auth/token/[userId].delete.ts
Normal file
6
server/routes/auth/token/[userId].delete.ts
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
import { getRouterParam } from "h3";
|
||||
import { handleTokenDelete } from "../../../lib/web/oauth";
|
||||
|
||||
export default defineEventHandler((event) =>
|
||||
handleTokenDelete(event, getRouterParam(event, "userId") ?? ""),
|
||||
);
|
||||
6
server/routes/discord/interactions.post.ts
Normal file
6
server/routes/discord/interactions.post.ts
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
import { handleInteractionRequest } from "../../lib/drivers/discord/interactions";
|
||||
import { cfEnv } from "../../lib/cf";
|
||||
|
||||
export default defineEventHandler((event) =>
|
||||
handleInteractionRequest(event.web!.request!, cfEnv(event)),
|
||||
);
|
||||
1
server/routes/health.get.ts
Normal file
1
server/routes/health.get.ts
Normal file
|
|
@ -0,0 +1 @@
|
|||
export default defineEventHandler(() => ({ status: "ok" }));
|
||||
6
server/routes/telegram/webhook.post.ts
Normal file
6
server/routes/telegram/webhook.post.ts
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
import { handleTelegramWebhookRequest } from "../../lib/drivers/telegram/updates";
|
||||
import { cfEnv } from "../../lib/cf";
|
||||
|
||||
export default defineEventHandler((event) =>
|
||||
handleTelegramWebhookRequest(event.web!.request!, cfEnv(event)),
|
||||
);
|
||||
3
server/routes/webhook.post.ts
Normal file
3
server/routes/webhook.post.ts
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
import { handleWebhookRequest } from "../lib/webhook";
|
||||
|
||||
export default defineEventHandler((event) => handleWebhookRequest(event));
|
||||
6
server/routes/webhook/[groupId].post.ts
Normal file
6
server/routes/webhook/[groupId].post.ts
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
import { getRouterParam } from "h3";
|
||||
import { handleWebhookRequest } from "../../lib/webhook";
|
||||
|
||||
export default defineEventHandler((event) =>
|
||||
handleWebhookRequest(event, getRouterParam(event, "groupId") ?? ""),
|
||||
);
|
||||
20
server/tasks/audit-prune.ts
Normal file
20
server/tasks/audit-prune.ts
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
import { pruneAuditLogs } from "../lib/lib/audit";
|
||||
import { cfEnv } from "../lib/cf";
|
||||
import { log } from "../lib/lib/log";
|
||||
|
||||
/** Scheduled: prune audit log entries past AUDIT_RETENTION_DAYS (cron every 5 minutes). */
|
||||
export default defineTask({
|
||||
meta: { name: "audit:prune", description: "Prune old audit log entries" },
|
||||
async run(event) {
|
||||
try {
|
||||
const env = cfEnv(event);
|
||||
const days = Math.max(Number(env.AUDIT_RETENTION_DAYS ?? 90) || 90, 1);
|
||||
const removed = await pruneAuditLogs(env.DB, days);
|
||||
if (removed > 0) log.info({ removed }, "Pruned audit logs");
|
||||
return { result: "ok", removed };
|
||||
} catch (err) {
|
||||
log.error({ err }, "Audit log prune from cron failed");
|
||||
return { result: "error" };
|
||||
}
|
||||
},
|
||||
});
|
||||
17
server/tasks/discord-sync.ts
Normal file
17
server/tasks/discord-sync.ts
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
import { syncCommands } from "../lib/drivers/discord/commands";
|
||||
import { cfEnv } from "../lib/cf";
|
||||
import { log } from "../lib/lib/log";
|
||||
|
||||
/** Scheduled: sync Discord application commands (cron every 5 minutes). */
|
||||
export default defineTask({
|
||||
meta: { name: "discord:sync", description: "Sync Discord application commands" },
|
||||
async run(event) {
|
||||
try {
|
||||
await syncCommands(cfEnv(event));
|
||||
return { result: "ok" };
|
||||
} catch (err) {
|
||||
log.error({ err }, "Discord command sync from cron failed");
|
||||
return { result: "error" };
|
||||
}
|
||||
},
|
||||
});
|
||||
17
server/tasks/telegram-sync.ts
Normal file
17
server/tasks/telegram-sync.ts
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
import { syncTelegramWebhook } from "../lib/drivers/telegram/commands";
|
||||
import { cfEnv } from "../lib/cf";
|
||||
import { log } from "../lib/lib/log";
|
||||
|
||||
/** Scheduled: sync the Telegram webhook URL (cron every 5 minutes). */
|
||||
export default defineTask({
|
||||
meta: { name: "telegram:sync", description: "Sync Telegram webhook URL" },
|
||||
async run(event) {
|
||||
try {
|
||||
await syncTelegramWebhook(cfEnv(event));
|
||||
return { result: "ok" };
|
||||
} catch (err) {
|
||||
log.error({ err }, "Telegram webhook sync from cron failed");
|
||||
return { result: "error" };
|
||||
}
|
||||
},
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue