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
+5
View File
@@ -0,0 +1,5 @@
WEBHOOK_SECRET=replace-with-a-long-random-secret
WECOM_WEBHOOK_URL=https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=replace-with-key
# ALLOWED_ORGS=my-org,another-org
# MAX_MESSAGE_LENGTH=1800
# DELIVERY_TTL_SECONDS=600
+9
View File
@@ -0,0 +1,9 @@
node_modules/
dist/
.env
.dev.vars
.DS_Store
coverage/
.wrangler/
.serena/
.commandcode/
+138
View File
@@ -0,0 +1,138 @@
# GitHub-GCC Bot
Cloudflare Worker that forwards GitHub or Gitea repository/organization events to an Enterprise WeChat group robot.
The Worker receives webhook payloads (GitHub sends GitHub headers natively; Gitea sends GitHub-compatible headers), verifies `X-Hub-Signature-256`, formats a short text notification, and posts it to the 企业微信群机器人 Webhook.
## What This Version Does
- Runs on Cloudflare Workers.
- Does not require your own server.
- Sends plain text Enterprise WeChat group robot messages.
- Supports GitHub webhooks and Gitea webhooks (including intranet Gitea instances, as long as the Gitea server has outbound internet access).
- Uses KV when configured for delivery de-duplication.
## Limits
- This version only sends text messages.
- It depends on 企业微信群机器人 Webhook availability and platform rate limits.
- The WeCom robot Webhook URL contains a secret key; store it only as a Cloudflare secret.
- Links in messages point to the Git host. For an intranet Gitea (e.g. `https://192.168.87.52:18473`), links are only reachable from the intranet/VPN.
## Setup
Install dependencies:
```bash
npm install
```
Create an optional KV namespace:
```bash
wrangler kv namespace create WEBHOOK_CACHE
```
Put the returned namespace id into `wrangler.toml` by uncommenting the `[[kv_namespaces]]` block.
Set Cloudflare Worker secrets:
```bash
wrangler secret put WEBHOOK_SECRET
wrangler secret put WECOM_WEBHOOK_URL
```
`WEBHOOK_SECRET` is a random string you generate; the same value goes into the Git host's webhook secret field.
`WECOM_WEBHOOK_URL` should look like:
```text
https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx
```
Optional non-secret variables can be set in `wrangler.toml`:
```toml
[vars]
MAX_MESSAGE_LENGTH = "1800"
DELIVERY_TTL_SECONDS = "600"
ALLOWED_ORGS = "my-org"
```
## Run
Local development:
```bash
npm run dev
```
Deploy:
```bash
npm run deploy
```
The webhook endpoint is:
```text
POST /github/webhook
POST /gitea/webhook
```
Both paths accept the same payload. Gitea can use either URL.
The health endpoint is:
```text
GET /healthz
```
## 企业微信群机器人配置
1. 在企业微信 App 中进入一个**内部群**(含微信联系人的外部群不支持群机器人)。
2. 群聊右上角「群设置」→「群机器人」→「添加机器人」,设置名字和头像后创建。
3. 复制机器人的 Webhook 地址(`https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=...`)。
4.`wrangler secret put WECOM_WEBHOOK_URL` 保存该地址。
## Gitea Webhook 配置
前提:Gitea 服务器能访问外网(能连 `workers.dev``qyapi.weixin.qq.com`)。如需代理,在 Gitea 的 `app.ini` 中配置 `[webhook] PROXY_URL`
在仓库、组织或系统管理页面添加 webhook(Gitea 类型):
- 目标 URL: `https://your-worker.your-subdomain.workers.dev/gitea/webhook``/github/webhook` 也可以)
- POST Content Type: `application/json`
- Secret: 与 `WEBHOOK_SECRET` 相同的值
- Trigger On: 按需选择 `Push Events``Issues``Issue Comment``Pull Request``Releases``Workflow Run`
- 保存后点「Test Delivery」发送一个模拟 push 事件验证;失败时在「最近推送记录」中查看请求/响应详情
## GitHub Organization Webhook
In GitHub organization settings, add a webhook:
- Payload URL: `https://your-worker.your-subdomain.workers.dev/github/webhook`
- Content type: `application/json`
- Secret: the same value as `WEBHOOK_SECRET`
- SSL verification: enabled
- Events: select `push`, `pull_request`, `issues`, `issue_comment`, `release`, and `workflow_run`
`ping` events are accepted but do not send WeCom messages.
## Supported Events
- `push`
- `pull_request`
- `issues`
- `issue_comment` (and Gitea `pull_request_comment`)
- `release`
- `workflow_run` when completed
Unsupported events return success without sending a message, so the Git host will not retry them.
## Verify
```bash
npm test
npm run build
```
+1617
View File
File diff suppressed because it is too large Load Diff
+23
View File
@@ -0,0 +1,23 @@
{
"name": "github-gcc",
"version": "0.1.0",
"private": true,
"type": "module",
"scripts": {
"build": "tsc -p tsconfig.json --noEmit",
"dev": "wrangler dev",
"deploy": "wrangler deploy",
"test": "node --import tsx --test tests/**/*.test.ts"
},
"dependencies": {},
"devDependencies": {
"@cloudflare/workers-types": "^4.20260702.1",
"@types/node": "^24.0.0",
"tsx": "^4.20.6",
"typescript": "^5.9.3",
"wrangler": "^4.107.0"
},
"engines": {
"node": ">=20"
}
}
+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);
}
+157
View File
@@ -0,0 +1,157 @@
import assert from "node:assert/strict";
import test from "node:test";
import { formatGitHubEvent, getOrganizationLogin } from "../src/githubEvents.js";
test("formats push events with commit summaries", () => {
const message = formatGitHubEvent(
"push",
{
ref: "refs/heads/main",
repository: {
full_name: "acme/app",
owner: { login: "acme" }
},
pusher: { name: "alice" },
commits: [
{
id: "1234567890",
message: "Add feature\n\nLong body",
author: { name: "Alice" },
added: ["src/new.ts"],
modified: ["src/app.ts", "README.md"],
removed: ["old.txt"],
url: "https://github.com/acme/app/commit/1234567"
}
],
compare: "https://github.com/acme/app/compare/a...b"
},
3500
);
assert.ok(message);
assert.match(message, /^━━━━━━━━━━━━━━\n📦 GitHub-GCC Push/);
assert.match(message, /📁 acme\/app/);
assert.match(message, /🌿 main/);
assert.match(message, /👤 alice/);
assert.match(message, /• 1 commit/);
assert.match(message, /• 4 files changed/);
assert.match(message, /• latest: 1234567 Add feature/);
assert.doesNotMatch(message, /https:\/\/github\.com\/acme\/app\/commit\/1234567/);
assert.match(message, /🔗 https:\/\/github\.com\/acme\/app\/compare\/a\.\.\.b/);
assert.match(message, /━━━━━━━━━━━━━━$/);
});
test("formats merged pull requests as merged", () => {
const message = formatGitHubEvent(
"pull_request",
{
action: "closed",
repository: { full_name: "acme/app" },
sender: { login: "bob" },
pull_request: {
merged: true,
number: 12,
title: "Improve deployment",
base: { ref: "main" },
head: { ref: "deploy" },
html_url: "https://github.com/acme/app/pull/12"
}
},
3500
);
assert.ok(message);
assert.match(message, /📦 GitHub-GCC Pull Request/);
assert.match(message, /🔀 #12 Improve deployment/);
assert.match(message, /• merged/);
assert.match(message, /• deploy -> main/);
});
test("ignores ping and non-completed workflow_run events", () => {
assert.equal(formatGitHubEvent("ping", {}, 3500), null);
assert.equal(formatGitHubEvent("workflow_run", { action: "requested" }, 3500), null);
});
test("extracts organization login from organization or repository owner", () => {
assert.equal(getOrganizationLogin({ organization: { login: "Acme" } }), "acme");
assert.equal(getOrganizationLogin({ repository: { owner: { login: "Other" } } }), "other");
});
test("formats Gitea-style push payloads with compare_url and pusher login", () => {
const message = formatGitHubEvent(
"push",
{
ref: "refs/heads/main",
repository: { full_name: "gcc/app", owner: { login: "gcc" } },
pusher: { login: "alice", username: "alice" },
sender: { login: "alice" },
commits: [],
total_commits: 0,
compare_url: "https://192.168.87.52:18473/gcc/app/compare/a...b"
},
3500
);
assert.ok(message);
assert.match(message, /👤 alice/);
assert.match(message, /🔗 https:\/\/192\.168\.87\.52:18473\/gcc\/app\/compare\/a\.\.\.b/);
});
test("formats Gitea pull_request_comment events like issue comments", () => {
const message = formatGitHubEvent(
"pull_request_comment",
{
action: "created",
repository: { full_name: "gcc/app" },
issue: { number: 3, title: "Add login" },
comment: { body: "LGTM", html_url: "https://192.168.87.52:18473/gcc/app/pulls/3#issuecomment-1" },
sender: { login: "bob" },
is_pull: true
},
3500
);
assert.ok(message);
assert.match(message, /📦 GitHub-GCC Issue Comment/);
assert.match(message, /🎫 #3 Add login/);
assert.match(message, /• LGTM/);
});
test("extracts organization login from Gitea username field", () => {
assert.equal(getOrganizationLogin({ organization: { username: "GCC" } }), "gcc");
assert.equal(getOrganizationLogin({ repository: { owner: { username: "GCC" } } }), "gcc");
});
test("formats Gitea payloads that only have sender.username", () => {
const message = formatGitHubEvent(
"issues",
{
action: "opened",
repository: { full_name: "gcc/app" },
issue: { number: 8, title: "Broken login", html_url: "https://192.168.87.52:18473/gcc/app/issues/8" },
sender: { username: "carol" }
},
3500
);
assert.ok(message);
assert.match(message, /👤 carol/);
});
test("truncates long messages", () => {
const message = formatGitHubEvent(
"issue_comment",
{
repository: { full_name: "acme/app" },
action: "created",
issue: { number: 1, title: "Bug" },
comment: { body: "x".repeat(200), html_url: "https://example.test" },
sender: { login: "alice" }
},
120
);
assert.ok(message);
assert.ok(message.length <= 120);
assert.match(message, /\.\.\. truncated$/);
});
+21
View File
@@ -0,0 +1,21 @@
import type { AppConfig } from "../src/config.js";
export function testConfig(overrides: Partial<AppConfig> = {}): AppConfig {
return {
webhookSecret: "test-secret",
wecomWebhookUrl: "https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=test-key",
maxMessageLength: 1800,
deliveryTtlSeconds: 600,
...overrides
};
}
export function testEnv(overrides: Record<string, unknown> = {}) {
return {
WEBHOOK_SECRET: "test-secret",
WECOM_WEBHOOK_URL: "https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=test-key",
MAX_MESSAGE_LENGTH: "1800",
DELIVERY_TTL_SECONDS: "600",
...overrides
} as never;
}
+25
View File
@@ -0,0 +1,25 @@
import assert from "node:assert/strict";
import test from "node:test";
import { signBody, verifyGitHubSignature } from "../src/signature.js";
test("verifies a valid GitHub sha256 signature", async () => {
const body = Buffer.from(JSON.stringify({ zen: "Keep it logically awesome." }));
const signature = await signBody("secret", body);
assert.equal(await verifyGitHubSignature("secret", body, signature), true);
});
test("rejects an invalid GitHub sha256 signature", async () => {
const body = Buffer.from(JSON.stringify({ ok: true }));
assert.equal(await verifyGitHubSignature("secret", body, "sha256=bad"), false);
assert.equal(await verifyGitHubSignature("secret", body, undefined), false);
assert.equal(await verifyGitHubSignature("secret", body, "sha1=bad"), false);
});
test("verifies a raw Gitea hex signature", async () => {
const body = Buffer.from(JSON.stringify({ zen: "Keep it logically awesome." }));
const signature = await signBody("secret", body);
assert.equal(await verifyGitHubSignature("secret", body, signature.slice("sha256=".length)), true);
});
+43
View File
@@ -0,0 +1,43 @@
import assert from "node:assert/strict";
import test from "node:test";
import { sendWeComTextMessage } from "../src/wecomSender.js";
import { testConfig } from "./helpers.js";
test("sends a WeCom text message to the configured webhook", async () => {
const requests: Array<{ url: string; init: RequestInit }> = [];
const fetchImpl = async (url: string | URL | Request, init?: RequestInit) => {
requests.push({ url: String(url), init: init ?? {} });
return new Response(JSON.stringify({ errcode: 0, errmsg: "ok" }), { status: 200 });
};
const result = await sendWeComTextMessage(testConfig(), "hello", fetchImpl as typeof fetch);
assert.deepEqual(result, { errcode: 0, errmsg: "ok" });
assert.equal(requests.length, 1);
assert.equal(requests[0]?.url, "https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=test-key");
assert.deepEqual(JSON.parse(String(requests[0]?.init.body)), {
msgtype: "text",
text: {
content: "hello"
}
});
});
test("throws when WeCom webhook returns an API error", async () => {
const fetchImpl = async () =>
new Response(JSON.stringify({ errcode: 93000, errmsg: "invalid webhook" }), { status: 200 });
await assert.rejects(
() => sendWeComTextMessage(testConfig(), "hello", fetchImpl as typeof fetch),
/WeCom webhook failed: errcode=93000/
);
});
test("throws when WeCom webhook returns an HTTP error", async () => {
const fetchImpl = async () => new Response("bad request", { status: 400 });
await assert.rejects(
() => sendWeComTextMessage(testConfig(), "hello", fetchImpl as typeof fetch),
/WeCom webhook failed: HTTP 400/
);
});
+169
View File
@@ -0,0 +1,169 @@
import assert from "node:assert/strict";
import test from "node:test";
import type { AppConfig } from "../src/config.js";
import { signBody } from "../src/signature.js";
import { handleRequest } from "../src/worker.js";
import { testEnv } from "./helpers.js";
test("health check returns ok", async () => {
const response = await handleRequest(new Request("https://worker.test/healthz"), testEnv());
assert.equal(response.status, 200);
assert.deepEqual(await response.json(), { ok: true });
});
test("accepts a valid push webhook and sends one WeCom message", async () => {
const sent: string[] = [];
const response = await postGitHubEvent("push", "delivery-1", {
ref: "refs/heads/main",
repository: { full_name: "acme/app", owner: { login: "acme" } },
pusher: { name: "alice" },
commits: []
}, async (_config, message) => {
sent.push(message);
});
assert.equal(response.status, 202);
assert.deepEqual(await response.json(), { sent: true });
assert.equal(sent.length, 1);
assert.match(sent[0] ?? "", /📁 acme\/app/);
});
test("rejects invalid signatures", async () => {
const body = JSON.stringify({ repository: { full_name: "acme/app" } });
const response = await handleRequest(
new Request("https://worker.test/github/webhook", {
method: "POST",
headers: {
"content-type": "application/json",
"x-github-event": "push",
"x-github-delivery": "bad-sig",
"x-hub-signature-256": "sha256=bad"
},
body
}),
testEnv(),
undefined,
async () => {
throw new Error("should not send");
}
);
assert.equal(response.status, 401);
});
test("deduplicates GitHub delivery ids", async () => {
const sent: string[] = [];
const payload = {
ref: "refs/heads/main",
repository: { full_name: "acme/app", owner: { login: "acme" } },
commits: []
};
const first = await postGitHubEvent("push", "same-delivery", payload, async (_config, message) => {
sent.push(message);
});
const second = await postGitHubEvent("push", "same-delivery", payload, async (_config, message) => {
sent.push(message);
});
assert.equal(first.status, 202);
assert.equal(second.status, 202);
assert.deepEqual(await second.json(), { duplicate: true });
assert.equal(sent.length, 1);
});
test("ignores events outside the allowed organization list", async () => {
const sent: string[] = [];
const response = await postGitHubEvent(
"push",
"wrong-org",
{
ref: "refs/heads/main",
repository: { full_name: "other/app", owner: { login: "other" } },
commits: []
},
async (_config, message) => {
sent.push(message);
},
{ ALLOWED_ORGS: "acme" }
);
assert.equal(response.status, 202);
assert.equal((await response.json() as { ignored: boolean }).ignored, true);
assert.equal(sent.length, 0);
});
test("accepts Gitea event headers on the Gitea webhook path", async () => {
const sent: string[] = [];
const payload = {
ref: "refs/heads/main",
repository: { full_name: "gcc/app", owner: { username: "gcc" } },
pusher: { username: "alice" },
commits: []
};
const body = JSON.stringify(payload);
const signature = await signBody("test-secret", new TextEncoder().encode(body));
const response = await handleRequest(
new Request("https://worker.test/gitea/webhook", {
method: "POST",
headers: {
"content-type": "application/json",
"x-gitea-event": "push",
"x-gitea-delivery": "gitea-delivery-1",
"x-gitea-signature": signature.slice("sha256=".length)
},
body
}),
testEnv(),
undefined,
async (_config, message) => {
sent.push(message);
}
);
assert.equal(response.status, 202);
assert.deepEqual(await response.json(), { sent: true });
assert.equal(sent.length, 1);
assert.match(sent[0] ?? "", /📁 gcc\/app/);
});
test("returns 500 when sending to WeCom fails so GitHub can retry", async () => {
const response = await postGitHubEvent("push", "send-fails", {
ref: "refs/heads/main",
repository: { full_name: "acme/app", owner: { login: "acme" } },
commits: []
}, async () => {
throw new Error("WeCom failed");
});
assert.equal(response.status, 500);
});
async function postGitHubEvent(
eventName: string,
deliveryId: string,
payload: Record<string, unknown>,
sender: (config: AppConfig, message: string) => Promise<unknown>,
envOverrides: Record<string, unknown> = {}
) {
const body = JSON.stringify(payload);
const signature = await signBody("test-secret", new TextEncoder().encode(body));
return handleRequest(
new Request("https://worker.test/github/webhook", {
method: "POST",
headers: {
"content-type": "application/json",
"x-github-event": eventName,
"x-github-delivery": deliveryId,
"x-hub-signature-256": signature
},
body
}),
testEnv(envOverrides),
undefined,
sender
);
}
+15
View File
@@ -0,0 +1,15 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"strict": true,
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true,
"skipLibCheck": true,
"outDir": "dist",
"rootDir": "src",
"types": ["node", "@cloudflare/workers-types"]
},
"include": ["src/**/*.ts"]
}
+16
View File
@@ -0,0 +1,16 @@
name = "github-gcc"
main = "src/index.ts"
compatibility_date = "2026-07-02"
# Non-secret defaults. Put secrets in Cloudflare with `wrangler secret put`.
[vars]
MAX_MESSAGE_LENGTH = "1800"
DELIVERY_TTL_SECONDS = "600"
# Optional KV cache for GitHub delivery de-duplication.
# Create it with:
# wrangler kv namespace create WEBHOOK_CACHE
# Then uncomment and replace the id below.
# [[kv_namespaces]]
# binding = "WEBHOOK_CACHE"
# id = "replace-with-kv-namespace-id"