Initial commit: GitHub-GCC WeCom bot for Gitea
This commit is contained in:
@@ -0,0 +1,63 @@
|
||||
export interface WorkerEnv {
|
||||
WEBHOOK_SECRET: string;
|
||||
WECOM_WEBHOOK_URL: string;
|
||||
WEBHOOK_CACHE?: KVNamespace;
|
||||
ALLOWED_ORGS?: string;
|
||||
MAX_MESSAGE_LENGTH?: string;
|
||||
DELIVERY_TTL_SECONDS?: string;
|
||||
}
|
||||
|
||||
export interface AppConfig {
|
||||
webhookSecret: string;
|
||||
wecomWebhookUrl: string;
|
||||
allowedOrgs?: Set<string>;
|
||||
maxMessageLength: number;
|
||||
deliveryTtlSeconds: number;
|
||||
cache?: KVNamespace;
|
||||
}
|
||||
|
||||
export function loadConfig(env: WorkerEnv): AppConfig {
|
||||
const wecomWebhookUrl = required(env.WECOM_WEBHOOK_URL, "WECOM_WEBHOOK_URL");
|
||||
if (!wecomWebhookUrl.startsWith("https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=")) {
|
||||
throw new Error("WECOM_WEBHOOK_URL must be an Enterprise WeChat group robot webhook URL");
|
||||
}
|
||||
|
||||
return {
|
||||
webhookSecret: required(env.WEBHOOK_SECRET, "WEBHOOK_SECRET"),
|
||||
wecomWebhookUrl,
|
||||
allowedOrgs: parseAllowedOrgs(env.ALLOWED_ORGS),
|
||||
maxMessageLength: parsePositiveInt(env.MAX_MESSAGE_LENGTH, 1800, "MAX_MESSAGE_LENGTH"),
|
||||
deliveryTtlSeconds: parsePositiveInt(env.DELIVERY_TTL_SECONDS, 10 * 60, "DELIVERY_TTL_SECONDS"),
|
||||
cache: env.WEBHOOK_CACHE
|
||||
};
|
||||
}
|
||||
|
||||
function required(value: string | undefined, name: string): string {
|
||||
const trimmed = value?.trim();
|
||||
if (!trimmed) {
|
||||
throw new Error(`${name} is required`);
|
||||
}
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
function parsePositiveInt(value: string | undefined, fallback: number, name: string): number {
|
||||
if (!value?.trim()) {
|
||||
return fallback;
|
||||
}
|
||||
|
||||
const parsed = Number(value);
|
||||
if (!Number.isInteger(parsed) || parsed <= 0) {
|
||||
throw new Error(`${name} must be a positive integer`);
|
||||
}
|
||||
|
||||
return parsed;
|
||||
}
|
||||
|
||||
function parseAllowedOrgs(value: string | undefined): Set<string> | undefined {
|
||||
const orgs = value
|
||||
?.split(",")
|
||||
.map((item) => item.trim().toLowerCase())
|
||||
.filter(Boolean);
|
||||
|
||||
return orgs?.length ? new Set(orgs) : undefined;
|
||||
}
|
||||
@@ -0,0 +1,320 @@
|
||||
export type GitHubPayload = Record<string, unknown>;
|
||||
|
||||
const CARD_BORDER = "━━━━━━━━━━━━━━";
|
||||
|
||||
export function formatGitHubEvent(eventName: string, payload: GitHubPayload, maxLength: number): string | null {
|
||||
const message = formatEvent(eventName, payload);
|
||||
return message ? truncateMessage(message, maxLength) : null;
|
||||
}
|
||||
|
||||
export function getOrganizationLogin(payload: GitHubPayload): string | undefined {
|
||||
return (
|
||||
stringAt(payload, ["organization", "login"]) ??
|
||||
stringAt(payload, ["organization", "username"]) ??
|
||||
stringAt(payload, ["repository", "owner", "login"]) ??
|
||||
stringAt(payload, ["repository", "owner", "username"]) ??
|
||||
stringAt(payload, ["repository", "owner", "name"])
|
||||
)?.toLowerCase();
|
||||
}
|
||||
|
||||
function formatEvent(eventName: string, payload: GitHubPayload): string | null {
|
||||
switch (eventName) {
|
||||
case "ping":
|
||||
return null;
|
||||
case "push":
|
||||
return formatPush(payload);
|
||||
case "pull_request":
|
||||
return formatPullRequest(payload);
|
||||
case "issues":
|
||||
return formatIssue(payload);
|
||||
case "issue_comment":
|
||||
case "pull_request_comment":
|
||||
return formatIssueComment(payload);
|
||||
case "release":
|
||||
return formatRelease(payload);
|
||||
case "workflow_run":
|
||||
return formatWorkflowRun(payload);
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function formatPush(payload: GitHubPayload): string {
|
||||
const repo = repoName(payload);
|
||||
const branch = branchName(stringAt(payload, ["ref"]) ?? "unknown");
|
||||
const pusher =
|
||||
stringAt(payload, ["pusher", "name"]) ??
|
||||
stringAt(payload, ["pusher", "login"]) ??
|
||||
stringAt(payload, ["pusher", "username"]) ??
|
||||
actorLogin(payload);
|
||||
const commits = arrayAt(payload, ["commits"]);
|
||||
const compareUrl = stringAt(payload, ["compare"]) ?? stringAt(payload, ["compare_url"]);
|
||||
const headUrl = stringAt(payload, ["head_commit", "url"]);
|
||||
const changedFiles = changedFileCount(commits);
|
||||
const latest = latestCommitSummary(payload, commits);
|
||||
|
||||
return formatCard("📦 GitHub-GCC Push", [
|
||||
`📁 ${repo}`,
|
||||
`🌿 ${branch}`,
|
||||
`👤 ${pusher}`,
|
||||
"",
|
||||
"📝 Changes",
|
||||
`• ${formatCount(commits.length, "commit")}`,
|
||||
changedFiles !== undefined ? `• ${formatCount(changedFiles, "file")} changed` : undefined,
|
||||
latest ? `• latest: ${latest}` : undefined,
|
||||
"",
|
||||
compareUrl || headUrl ? `🔗 ${compareUrl ?? headUrl}` : undefined
|
||||
]);
|
||||
}
|
||||
|
||||
function formatPullRequest(payload: GitHubPayload): string {
|
||||
const action = stringAt(payload, ["action"]) ?? "updated";
|
||||
const merged = booleanAt(payload, ["pull_request", "merged"]);
|
||||
const state = action === "closed" && merged ? "merged" : action;
|
||||
const title = stringAt(payload, ["pull_request", "title"]) ?? "(no title)";
|
||||
const number = numberAt(payload, ["pull_request", "number"]) ?? numberAt(payload, ["number"]);
|
||||
const user = actorLogin(payload, [
|
||||
["pull_request", "user", "login"],
|
||||
["pull_request", "user", "username"]
|
||||
]);
|
||||
const base = stringAt(payload, ["pull_request", "base", "ref"]);
|
||||
const head = stringAt(payload, ["pull_request", "head", "ref"]);
|
||||
const url = stringAt(payload, ["pull_request", "html_url"]);
|
||||
|
||||
return formatCard("📦 GitHub-GCC Pull Request", [
|
||||
`📁 ${repoName(payload)}`,
|
||||
`🔀 ${number ? `#${number} ` : ""}${firstLine(title)}`,
|
||||
`👤 ${user}`,
|
||||
"",
|
||||
"📝 Changes",
|
||||
`• ${state}`,
|
||||
base && head ? `• ${head} -> ${base}` : undefined,
|
||||
"",
|
||||
url ? `🔗 ${url}` : undefined
|
||||
]);
|
||||
}
|
||||
|
||||
function formatIssue(payload: GitHubPayload): string {
|
||||
const action = stringAt(payload, ["action"]) ?? "updated";
|
||||
const title = stringAt(payload, ["issue", "title"]) ?? "(no title)";
|
||||
const number = numberAt(payload, ["issue", "number"]) ?? numberAt(payload, ["number"]);
|
||||
const user = actorLogin(payload, [
|
||||
["issue", "user", "login"],
|
||||
["issue", "user", "username"]
|
||||
]);
|
||||
const url = stringAt(payload, ["issue", "html_url"]);
|
||||
|
||||
return formatCard("📦 GitHub-GCC Issue", [
|
||||
`📁 ${repoName(payload)}`,
|
||||
`🎫 ${number ? `#${number} ` : ""}${firstLine(title)}`,
|
||||
`👤 ${user}`,
|
||||
"",
|
||||
"📝 Changes",
|
||||
`• ${action}`,
|
||||
"",
|
||||
url ? `🔗 ${url}` : undefined
|
||||
]);
|
||||
}
|
||||
|
||||
function formatIssueComment(payload: GitHubPayload): string {
|
||||
const action = stringAt(payload, ["action"]) ?? "updated";
|
||||
const issueTitle = stringAt(payload, ["issue", "title"]) ?? "(no title)";
|
||||
const issueNumber = numberAt(payload, ["issue", "number"]);
|
||||
const user = actorLogin(payload, [
|
||||
["comment", "user", "login"],
|
||||
["comment", "user", "username"]
|
||||
]);
|
||||
const body = firstLine(stringAt(payload, ["comment", "body"]) ?? "(empty comment)");
|
||||
const url = stringAt(payload, ["comment", "html_url"]);
|
||||
|
||||
return formatCard("📦 GitHub-GCC Issue Comment", [
|
||||
`📁 ${repoName(payload)}`,
|
||||
`🎫 ${issueNumber ? `#${issueNumber} ` : ""}${firstLine(issueTitle)}`,
|
||||
`👤 ${user}`,
|
||||
"",
|
||||
"📝 Changes",
|
||||
`• ${action}`,
|
||||
`• ${body}`,
|
||||
"",
|
||||
url ? `🔗 ${url}` : undefined
|
||||
]);
|
||||
}
|
||||
|
||||
function formatRelease(payload: GitHubPayload): string {
|
||||
const action = stringAt(payload, ["action"]) ?? "updated";
|
||||
const name = stringAt(payload, ["release", "name"]) ?? stringAt(payload, ["release", "tag_name"]) ?? "(unnamed release)";
|
||||
const tag = stringAt(payload, ["release", "tag_name"]);
|
||||
const user = actorLogin(payload, [
|
||||
["release", "author", "login"],
|
||||
["release", "author", "username"]
|
||||
]);
|
||||
const url = stringAt(payload, ["release", "html_url"]);
|
||||
|
||||
return formatCard("📦 GitHub-GCC Release", [
|
||||
`📁 ${repoName(payload)}`,
|
||||
`🏷️ ${tag ?? firstLine(name)}`,
|
||||
`👤 ${user}`,
|
||||
"",
|
||||
"📝 Changes",
|
||||
`• ${action}`,
|
||||
tag && tag !== name ? `• ${firstLine(name)}` : undefined,
|
||||
"",
|
||||
url ? `🔗 ${url}` : undefined
|
||||
]);
|
||||
}
|
||||
|
||||
function formatWorkflowRun(payload: GitHubPayload): string | null {
|
||||
const action = stringAt(payload, ["action"]) ?? "updated";
|
||||
const status = stringAt(payload, ["workflow_run", "status"]);
|
||||
const conclusion = stringAt(payload, ["workflow_run", "conclusion"]);
|
||||
const workflow = stringAt(payload, ["workflow_run", "name"]) ?? stringAt(payload, ["workflow", "name"]) ?? "(unnamed workflow)";
|
||||
const branch = stringAt(payload, ["workflow_run", "head_branch"]);
|
||||
const actor = actorLogin(payload, [
|
||||
["workflow_run", "actor", "login"],
|
||||
["workflow_run", "actor", "username"]
|
||||
]);
|
||||
const url = stringAt(payload, ["workflow_run", "html_url"]);
|
||||
|
||||
if (action !== "completed") {
|
||||
return null;
|
||||
}
|
||||
|
||||
return formatCard("📦 GitHub-GCC Workflow Run", [
|
||||
`📁 ${repoName(payload)}`,
|
||||
`⚙️ ${workflow}`,
|
||||
`👤 ${actor}`,
|
||||
"",
|
||||
"📝 Changes",
|
||||
`• ${conclusion ?? status ?? "unknown"}`,
|
||||
branch ? `• ${branch}` : undefined,
|
||||
"",
|
||||
url ? `🔗 ${url}` : undefined
|
||||
]);
|
||||
}
|
||||
|
||||
function actorLogin(payload: GitHubPayload, extraPaths: string[][] = []): string {
|
||||
return (
|
||||
stringAt(payload, ["sender", "login"]) ??
|
||||
stringAt(payload, ["sender", "username"]) ??
|
||||
extraPaths.reduce<string | undefined>(
|
||||
(found, path) => found ?? stringAt(payload, path),
|
||||
undefined
|
||||
) ??
|
||||
"unknown"
|
||||
);
|
||||
}
|
||||
|
||||
function repoName(payload: GitHubPayload): string {
|
||||
return stringAt(payload, ["repository", "full_name"]) ?? stringAt(payload, ["repository", "name"]) ?? "unknown/repo";
|
||||
}
|
||||
|
||||
function branchName(ref: string): string {
|
||||
return ref.replace(/^refs\/heads\//, "").replace(/^refs\/tags\//, "tag:");
|
||||
}
|
||||
|
||||
function changedFileCount(commits: unknown[]): number | undefined {
|
||||
const files = new Set<string>();
|
||||
|
||||
for (const commit of commits) {
|
||||
if (!isObject(commit)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
for (const key of ["added", "modified", "removed"]) {
|
||||
for (const file of arrayAt(commit, [key])) {
|
||||
if (typeof file === "string") {
|
||||
files.add(file);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return files.size > 0 ? files.size : undefined;
|
||||
}
|
||||
|
||||
function latestCommitSummary(payload: GitHubPayload, commits: unknown[]): string | undefined {
|
||||
const headCommit = at(payload, ["head_commit"]);
|
||||
const latest = isObject(headCommit) ? headCommit : lastObject(commits);
|
||||
|
||||
if (!latest) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const id = (stringAt(latest, ["id"]) ?? "").slice(0, 7);
|
||||
const message = firstLine(stringAt(latest, ["message"]) ?? "(no commit message)");
|
||||
return id ? `${id} ${message}` : message;
|
||||
}
|
||||
|
||||
function lastObject(values: unknown[]): GitHubPayload | undefined {
|
||||
for (let index = values.length - 1; index >= 0; index -= 1) {
|
||||
const value = values[index];
|
||||
if (isObject(value)) {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function firstLine(value: string): string {
|
||||
return value.split(/\r?\n/, 1)[0]?.trim() || "(empty)";
|
||||
}
|
||||
|
||||
function truncateMessage(message: string, maxLength: number): string {
|
||||
if (message.length <= maxLength) {
|
||||
return message;
|
||||
}
|
||||
|
||||
const suffix = "\n... truncated";
|
||||
const keep = Math.max(0, maxLength - suffix.length);
|
||||
return `${message.slice(0, keep).trimEnd()}${suffix}`;
|
||||
}
|
||||
|
||||
function formatCard(title: string, lines: Array<string | undefined>): string {
|
||||
return compactLines([CARD_BORDER, title, "", ...lines, CARD_BORDER]);
|
||||
}
|
||||
|
||||
function compactLines(lines: Array<string | undefined>): string {
|
||||
return lines.filter((line): line is string => line !== undefined).join("\n");
|
||||
}
|
||||
|
||||
function formatCount(count: number, label: string): string {
|
||||
return `${count} ${label}${count === 1 ? "" : "s"}`;
|
||||
}
|
||||
|
||||
function stringAt(value: unknown, path: string[]): string | undefined {
|
||||
const current = at(value, path);
|
||||
return typeof current === "string" ? current : undefined;
|
||||
}
|
||||
|
||||
function numberAt(value: unknown, path: string[]): number | undefined {
|
||||
const current = at(value, path);
|
||||
return typeof current === "number" ? current : undefined;
|
||||
}
|
||||
|
||||
function booleanAt(value: unknown, path: string[]): boolean | undefined {
|
||||
const current = at(value, path);
|
||||
return typeof current === "boolean" ? current : undefined;
|
||||
}
|
||||
|
||||
function arrayAt(value: unknown, path: string[]): unknown[] {
|
||||
const current = at(value, path);
|
||||
return Array.isArray(current) ? current : [];
|
||||
}
|
||||
|
||||
function at(value: unknown, path: string[]): unknown {
|
||||
let current = value;
|
||||
|
||||
for (const key of path) {
|
||||
if (!isObject(current)) {
|
||||
return undefined;
|
||||
}
|
||||
current = current[key];
|
||||
}
|
||||
|
||||
return current;
|
||||
}
|
||||
|
||||
function isObject(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { default } from "./worker.js";
|
||||
@@ -0,0 +1,70 @@
|
||||
const signaturePrefix = "sha256=";
|
||||
|
||||
export async function signBody(secret: string, body: ArrayBuffer | Uint8Array): Promise<string> {
|
||||
const secretBytes = new TextEncoder().encode(secret);
|
||||
const key = await crypto.subtle.importKey(
|
||||
"raw",
|
||||
toArrayBuffer(secretBytes),
|
||||
{ name: "HMAC", hash: "SHA-256" },
|
||||
false,
|
||||
["sign"]
|
||||
);
|
||||
const signature = await crypto.subtle.sign("HMAC", key, toArrayBuffer(body));
|
||||
return `${signaturePrefix}${toHex(new Uint8Array(signature))}`;
|
||||
}
|
||||
|
||||
export async function verifyGitHubSignature(
|
||||
secret: string,
|
||||
body: ArrayBuffer | Uint8Array,
|
||||
signatureHeader: string | null | undefined
|
||||
): Promise<boolean> {
|
||||
const normalized = normalizeSignature(signatureHeader);
|
||||
if (!normalized) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const expected = await signBody(secret, body);
|
||||
return constantTimeEqual(expected, normalized);
|
||||
}
|
||||
|
||||
function normalizeSignature(signatureHeader: string | null | undefined): string | undefined {
|
||||
if (!signatureHeader) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
if (signatureHeader.startsWith(signaturePrefix)) {
|
||||
return signatureHeader;
|
||||
}
|
||||
|
||||
// Gitea/Gogs X-Gitea-Signature is raw HMAC-SHA256 hex without the GitHub prefix.
|
||||
if (/^[0-9a-f]{64}$/i.test(signatureHeader)) {
|
||||
return `${signaturePrefix}${signatureHeader}`;
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function toArrayBuffer(value: ArrayBuffer | Uint8Array): ArrayBuffer {
|
||||
if (value instanceof ArrayBuffer) {
|
||||
return value;
|
||||
}
|
||||
|
||||
const copy = new Uint8Array(value.byteLength);
|
||||
copy.set(value);
|
||||
return copy.buffer;
|
||||
}
|
||||
|
||||
function toHex(bytes: Uint8Array): string {
|
||||
return Array.from(bytes, (byte) => byte.toString(16).padStart(2, "0")).join("");
|
||||
}
|
||||
|
||||
function constantTimeEqual(a: string, b: string): boolean {
|
||||
let mismatch = a.length ^ b.length;
|
||||
const maxLength = Math.max(a.length, b.length);
|
||||
|
||||
for (let index = 0; index < maxLength; index += 1) {
|
||||
mismatch |= (a.charCodeAt(index) || 0) ^ (b.charCodeAt(index) || 0);
|
||||
}
|
||||
|
||||
return mismatch === 0;
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
import type { AppConfig } from "./config.js";
|
||||
|
||||
export interface WeComResponse {
|
||||
errcode?: number;
|
||||
errmsg?: string;
|
||||
}
|
||||
|
||||
export async function sendWeComTextMessage(
|
||||
config: AppConfig,
|
||||
content: string,
|
||||
fetchImpl: typeof fetch = fetch
|
||||
): Promise<WeComResponse> {
|
||||
if (!content.trim()) {
|
||||
throw new Error("WeCom message content must not be empty");
|
||||
}
|
||||
|
||||
const response = await fetchImpl(config.wecomWebhookUrl, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json"
|
||||
},
|
||||
body: JSON.stringify({
|
||||
msgtype: "text",
|
||||
text: {
|
||||
content
|
||||
}
|
||||
})
|
||||
});
|
||||
|
||||
const bodyText = await response.text();
|
||||
if (!response.ok) {
|
||||
throw new Error(`WeCom webhook failed: HTTP ${response.status} ${bodyText.slice(0, 500)}`);
|
||||
}
|
||||
|
||||
const body = parseJsonObject<WeComResponse>(bodyText) ?? {};
|
||||
if (typeof body.errcode === "number" && body.errcode !== 0) {
|
||||
throw new Error(`WeCom webhook failed: errcode=${body.errcode} errmsg=${body.errmsg ?? ""}`.trim());
|
||||
}
|
||||
|
||||
return body;
|
||||
}
|
||||
|
||||
function parseJsonObject<T>(value: string): T | undefined {
|
||||
try {
|
||||
const parsed: unknown = JSON.parse(value);
|
||||
return typeof parsed === "object" && parsed !== null && !Array.isArray(parsed) ? (parsed as T) : undefined;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
+166
@@ -0,0 +1,166 @@
|
||||
import { loadConfig, type AppConfig, type WorkerEnv } from "./config.js";
|
||||
import { formatGitHubEvent, getOrganizationLogin, type GitHubPayload } from "./githubEvents.js";
|
||||
import { verifyGitHubSignature } from "./signature.js";
|
||||
import { sendWeComTextMessage } from "./wecomSender.js";
|
||||
|
||||
export interface ExecutionContextLike {
|
||||
waitUntil(promise: Promise<unknown>): void;
|
||||
}
|
||||
|
||||
export type MessageSender = (config: AppConfig, message: string) => Promise<unknown>;
|
||||
|
||||
const memoryDeliveryCache = new Map<string, number>();
|
||||
|
||||
export default {
|
||||
async fetch(request: Request, env: WorkerEnv, ctx: ExecutionContext): Promise<Response> {
|
||||
return handleRequest(request, env, ctx);
|
||||
}
|
||||
};
|
||||
|
||||
export async function handleRequest(
|
||||
request: Request,
|
||||
env: WorkerEnv,
|
||||
ctx?: ExecutionContextLike,
|
||||
sender: MessageSender = sendWeComTextMessage
|
||||
): Promise<Response> {
|
||||
const url = new URL(request.url);
|
||||
|
||||
if (request.method === "GET" && url.pathname === "/healthz") {
|
||||
return json({ ok: true });
|
||||
}
|
||||
|
||||
if (request.method !== "POST" || !isWebhookPath(url.pathname)) {
|
||||
return json({ error: "not found" }, 404);
|
||||
}
|
||||
|
||||
let config: AppConfig;
|
||||
try {
|
||||
config = loadConfig(env);
|
||||
} catch (error) {
|
||||
return json({ error: errorMessage(error) }, 500);
|
||||
}
|
||||
|
||||
const eventName =
|
||||
request.headers.get("x-github-event") ??
|
||||
request.headers.get("x-gitea-event") ??
|
||||
request.headers.get("x-gogs-event");
|
||||
const deliveryId =
|
||||
request.headers.get("x-github-delivery") ??
|
||||
request.headers.get("x-gitea-delivery") ??
|
||||
request.headers.get("x-gogs-delivery");
|
||||
const signature =
|
||||
request.headers.get("x-hub-signature-256") ??
|
||||
request.headers.get("x-gitea-signature") ??
|
||||
request.headers.get("x-gogs-signature");
|
||||
const rawBody = await request.arrayBuffer();
|
||||
|
||||
if (!eventName || !deliveryId) {
|
||||
return json({ error: "missing webhook event headers" }, 400);
|
||||
}
|
||||
|
||||
if (!(await verifyGitHubSignature(config.webhookSecret, rawBody, signature))) {
|
||||
return json({ error: "invalid webhook signature" }, 401);
|
||||
}
|
||||
|
||||
if (await hasDelivery(config, deliveryId)) {
|
||||
return json({ duplicate: true }, 202);
|
||||
}
|
||||
|
||||
const payload = parsePayload(rawBody);
|
||||
if (!payload) {
|
||||
return json({ error: "invalid JSON payload" }, 400);
|
||||
}
|
||||
|
||||
if (config.allowedOrgs) {
|
||||
const org = getOrganizationLogin(payload);
|
||||
if (!org || !config.allowedOrgs.has(org)) {
|
||||
await rememberDelivery(config, deliveryId, ctx);
|
||||
return json({ ignored: true, reason: "organization not allowed" }, 202);
|
||||
}
|
||||
}
|
||||
|
||||
const message = formatGitHubEvent(eventName, payload, config.maxMessageLength);
|
||||
if (!message) {
|
||||
await rememberDelivery(config, deliveryId, ctx);
|
||||
return new Response(null, { status: 204 });
|
||||
}
|
||||
|
||||
try {
|
||||
await sender(config, message);
|
||||
} catch (error) {
|
||||
return json({ error: errorMessage(error) }, 500);
|
||||
}
|
||||
|
||||
await rememberDelivery(config, deliveryId, ctx);
|
||||
return json({ sent: true }, 202);
|
||||
}
|
||||
|
||||
async function hasDelivery(config: AppConfig, deliveryId: string): Promise<boolean> {
|
||||
pruneMemoryDeliveries();
|
||||
const cacheKey = deliveryCacheKey(deliveryId);
|
||||
|
||||
if (config.cache) {
|
||||
return (await config.cache.get(cacheKey)) !== null;
|
||||
}
|
||||
|
||||
const expiresAt = memoryDeliveryCache.get(cacheKey);
|
||||
return expiresAt !== undefined && expiresAt > Date.now();
|
||||
}
|
||||
|
||||
async function rememberDelivery(config: AppConfig, deliveryId: string, ctx?: ExecutionContextLike): Promise<void> {
|
||||
const cacheKey = deliveryCacheKey(deliveryId);
|
||||
|
||||
if (config.cache) {
|
||||
const write = config.cache.put(cacheKey, "1", { expirationTtl: config.deliveryTtlSeconds });
|
||||
ctx?.waitUntil(write);
|
||||
if (!ctx) {
|
||||
await write;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
memoryDeliveryCache.set(cacheKey, Date.now() + config.deliveryTtlSeconds * 1000);
|
||||
}
|
||||
|
||||
function isWebhookPath(pathname: string): boolean {
|
||||
return pathname === "/github/webhook" || pathname === "/gitea/webhook";
|
||||
}
|
||||
|
||||
function deliveryCacheKey(deliveryId: string): string {
|
||||
return `webhook:delivery:${deliveryId}`;
|
||||
}
|
||||
|
||||
function pruneMemoryDeliveries(): void {
|
||||
const now = Date.now();
|
||||
for (const [key, expiresAt] of memoryDeliveryCache) {
|
||||
if (expiresAt <= now) {
|
||||
memoryDeliveryCache.delete(key);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function parsePayload(rawBody: ArrayBuffer): GitHubPayload | undefined {
|
||||
try {
|
||||
const parsed: unknown = JSON.parse(new TextDecoder().decode(rawBody));
|
||||
return isGitHubPayload(parsed) ? parsed : undefined;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
function isGitHubPayload(value: unknown): value is GitHubPayload {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function json(value: unknown, status = 200): Response {
|
||||
return new Response(JSON.stringify(value), {
|
||||
status,
|
||||
headers: {
|
||||
"Content-Type": "application/json; charset=utf-8"
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function errorMessage(error: unknown): string {
|
||||
return error instanceof Error ? error.message : String(error);
|
||||
}
|
||||
Reference in New Issue
Block a user