Initial commit: GitHub-GCC WeCom bot for Gitea

This commit is contained in:
2026-09-17 17:43:45 +08:00
commit 74d9523652
18 changed files with 2908 additions and 0 deletions
+63
View File
@@ -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;
}
+320
View File
@@ -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);
}
+1
View File
@@ -0,0 +1 @@
export { default } from "./worker.js";
+70
View File
@@ -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;
}
+50
View File
@@ -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
View File
@@ -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);
}