feat: add gitea/forgejo support

This commit is contained in:
RhenCloud 2026-08-11 22:01:04 +08:00
parent ace036c209
commit aec0d1a257
43 changed files with 683 additions and 130 deletions

View file

@ -5,6 +5,9 @@ GITHUB_PRIVATE_KEY="-----BEGIN PRIVATE KEY-----\n...\n-----END PRIVATE KEY-----"
GITHUB_CLIENT_ID=your-client-id
GITHUB_CLIENT_SECRET=your-client-secret
# Gitea (optional — to receive Gitea webhooks)
GITEA_WEBHOOK_SECRET=your-gitea-webhook-secret
# Discord
DISCORD_TOKEN=your-bot-token
DISCORD_PUBLIC_KEY=your-public-key

View file

@ -12,7 +12,8 @@ Core pipeline: GitHub Webhook → Worker (verify + filter + format) → Discord
- HTTP framework: Hono
- Discord interactions: HTTPS Interactions Endpoint (`POST /discord/interactions`, Ed25519-signed) — no Discord Gateway / Durable Object; bot stays offline, messages always sent via REST
- Storage: Cloudflare KV (tokens, OAuth state, route config `config:routes`, group config `config:groups`, admin sessions, delivery dedup, message-update tracking `msg:*`, i18n overrides `i18n:*`) + D1 (`send_logs`, `discord_links`, `telegram_links`)
- Signature verification: Web Crypto API (HMAC-SHA256 for GitHub, Ed25519 for Discord, timing-safe secret-token compare for Telegram)
- Signature verification: Web Crypto API (HMAC-SHA256 for GitHub/Gitea, Ed25519 for Discord, timing-safe secret-token compare for Telegram)
- Webhook providers: pluggable forge adapters under `src/providers/` (github, gitea) — each verifies its own signature format and normalizes its payload to a GitHub-shaped `WebhookEvent`; GitLab etc. can be added later
- GitHub OAuth: octokit (token is stored hashed for reverse lookup)
- Admin WebUI: `/admin` config console, OAuth-session protected via `ADMIN_USER_IDS` whitelist
- Local dev: wrangler + Miniflare
@ -27,10 +28,18 @@ src/
├── server.ts # Hono app: /health, /webhook, /discord/interactions, /telegram/webhook, mounts /auth, /admin + /
├── core/
│ └── dispatch.ts # Platform-neutral dispatch: match routes → formatEvent → driver.send/edit (recordSend + group filter)
├── events/ # GitHub webhook pipeline: verify signature, parse event, match route
│ ├── verify.ts # HMAC signature verify (Web Crypto, timing-safe)
│ ├── parse.ts # parseEvent (headers + body → WebhookEvent)
├── events/ # Provider-agnostic route matching
│ └── match.ts # matchRoute, eventOwners, extractBranch, keyword regex filtering
├── providers/ # Forge webhook providers (verify + parse/normalize to GitHub-shaped events)
│ ├── types.ts # Provider interface (matches/verify/parse)
│ ├── hmac.ts # HMAC-SHA256 + timing-safe compare helpers
│ ├── index.ts # detectProvider() registry (github, gitea)
│ ├── github/ # X-GitHub-Event + X-Hub-Signature-256 ("sha256=" prefix)
│ │ ├── verify.ts # HMAC signature verify
│ │ └── parse.ts # parseEvent (headers + body → WebhookEvent)
│ └── gitea/ # X-Gitea-Event + X-Gitea-Signature (plain hex HMAC)
│ ├── verify.ts # HMAC signature verify (no prefix)
│ └── parse.ts # parse + normalize Gitea payloads to GitHub shape
├── formatters/ # Platform-neutral message formatters (was formatter.ts)
│ ├── index.ts # formatEvent: 28-event switch → NeutralMessage + re-exports
│ ├── colors.ts # GITHUB_COLORS + WORKFLOW_CONCLUSION_EMOJI
@ -76,11 +85,13 @@ src/__tests__/ # bun test unit tests (webhook, formatter, discord, te
## Responsibilities
- Verify GitHub webhook signatures (Web Crypto HMAC-SHA256)
- Verify GitHub webhook signatures (Web Crypto HMAC-SHA256, `X-Hub-Signature-256`)
- Verify Gitea webhook signatures (Web Crypto HMAC-SHA256, plain hex `X-Gitea-Signature`)
- Normalize Gitea webhook payloads to a GitHub-shaped `WebhookEvent` (push `compare_url``compare`, `pull_request_comment``pull_request_review_comment`, ...)
- Verify Discord interactions (Web Crypto Ed25519, X-Signature-Ed25519 over timestamp + body)
- Verify Telegram webhook calls (X-Telegram-Bot-Api-Secret-Token when configured)
- Filter events by: event type, repo name, actor, action, branch, keyword (regex supported)
- Filter routes by group owner restriction (`Group.owners`) and skip fallback routes whenever a regular route matched; stop evaluating further routes when a matched route has `stop: true`
- Filter routes by group owner restriction (`Group.owners`), group source-platform restriction (`Group.providers`: github/gitea), and skip fallback routes whenever a regular route matched; stop evaluating further routes when a matched route has `stop: true`
- Mention Discord roles on route trigger: route-level `discordRoleIds` are rendered as `<@&id>` into the Discord message `content` (Telegram targets ignore the field)
- Format 28 event types as platform-neutral messages (Discord embeds + Telegram HTML)
- Route messages to Discord channels/threads and Telegram chats/topics via REST
@ -135,6 +146,7 @@ Rule: no functional change ships without its documentation; docs and code must n
- **D1 database**: Binding `DB` (database `webhooker`, id `214a0104-3235-47c0-b7bf-ddda95f3c8ac`) for `send_logs` + `discord_links` + `telegram_links` tables
- **Discord**: `DISCORD_PUBLIC_KEY` (Interactions Endpoint signature verification, from Discord Developer Portal) and `DISCORD_APPLICATION_ID` (optional, auto-resolved via `GET /oauth2/applications/@me` when omitted) are required for interactions
- **Telegram**: `TELEGRAM_TOKEN` (Bot API token from BotFather) required for Telegram routes; `TELEGRAM_WEBHOOK_SECRET` (optional secret token for `POST /telegram/webhook` verification); avatars are sent as a link-preview card via the built-in `GET /api/richheader` (overridable with `TELEGRAM_RICH_HEADER_HOST`)
- **Webhook providers**: `GITEA_WEBHOOK_SECRET` (required to receive Gitea webhooks; Gitea signs `X-Gitea-Signature` with the hex HMAC-SHA256 of the body)
## Deployment
@ -150,7 +162,8 @@ npm run db:migrate:prod # wrangler d1 migrations apply webhooker --remote (mig
npx wrangler deploy
```
Full list of secrets used: `GITHUB_WEBHOOK_SECRET`, `GITHUB_APP_ID`, `GITHUB_PRIVATE_KEY`
Full list of secrets used: `GITHUB_WEBHOOK_SECRET`, `GITEA_WEBHOOK_SECRET`,
`GITHUB_APP_ID`, `GITHUB_PRIVATE_KEY`
(PKCS#8 PEM), `GITHUB_CLIENT_ID`, `GITHUB_CLIENT_SECRET`, `DISCORD_TOKEN`,
`DISCORD_PUBLIC_KEY`, `TELEGRAM_TOKEN`, `TELEGRAM_WEBHOOK_SECRET`, `ADMIN_USER_IDS`,
plus optional `BASE_URL`, `DISCORD_APPLICATION_ID`, `TELEGRAM_RICH_HEADER_HOST`,

View file

@ -1,10 +1,11 @@
# WebHooker
GitHub webhook → Discord / Telegram dispatcher. Receives webhook events via Cloudflare Workers, applies filters, and routes formatted messages to Discord channels/threads and Telegram chats/topics.
GitHub / Gitea webhook → Discord / Telegram dispatcher. Receives webhook events via Cloudflare Workers, applies filters, and routes formatted messages to Discord channels/threads and Telegram chats/topics. Forge-specific adapters live under `src/providers/` (GitHub + Gitea today; GitLab etc. can be added later).
## Features
- **28 event formatters** — push, pull_request, issues, issue_comment, workflow_run, workflow_job, status, deployment, deployment_status, check_run, check_suite, ping, release, create, delete, star, fork, pull_request_review, pull_request_review_comment, commit_comment, member, label, milestone, discussion, discussion_comment, repository, code_scanning_alert, dependabot_alert (+ generic fallback)
- **Multi-provider webhooks** — GitHub (`X-Hub-Signature-256`) and Gitea (`X-Gitea-Signature`) share one `/webhook` endpoint; the provider is auto-detected from headers
- HMAC-SHA256 signature verification (Web Crypto API)
- Filter by event type, repo, actor, action, branch, keyword (supports regex)
- Rich messages with color coding, author avatars, fields, and timestamps — rendered as Discord embeds and Telegram HTML
@ -51,6 +52,7 @@ npx wrangler dev # Start local dev server
| Variable | Description |
| --------------------------- | ---------------------------------------------------------------------------------------------- |
| `GITHUB_WEBHOOK_SECRET` | Webhook secret from GitHub |
| `GITEA_WEBHOOK_SECRET` | Webhook secret from Gitea (required only to receive Gitea webhooks) |
| `GITHUB_APP_ID` | GitHub App ID (not currently used by the code; kept for compatibility) |
| `GITHUB_PRIVATE_KEY` | App private key (PKCS#8 PEM; not currently used by the code; kept for compatibility) |
| `GITHUB_CLIENT_ID` | OAuth client ID |
@ -104,7 +106,7 @@ Set `discordRoleIds` on a route to ping Discord roles (身份组) whenever it fi
}
```
Routes belong to **groups** (KV `config:groups`) that scope admin access and can restrict which org/user events flow in. See `config.example.yaml` and `docs/guide/configuration.md` for the full schema.
Routes belong to **groups** (KV `config:groups`) that scope admin access and can restrict which org/user events flow in — including which source platform (`providers`: `github` / `gitea`). See `config.example.yaml` and `docs/guide/configuration.md` for the full schema.
### Web UI (`/admin`)

View file

@ -1,10 +1,11 @@
# WebHooker
GitHub webhook → Discord / Telegram 分发服务。通过 Cloudflare Workers 接收 webhook 事件,应用过滤器,将格式化消息路由到 Discord 频道/子区与 Telegram 群组/话题。
GitHub / Gitea webhook → Discord / Telegram 分发服务。通过 Cloudflare Workers 接收 webhook 事件,应用过滤器,将格式化消息路由到 Discord 频道/子区与 Telegram 群组/话题。各 forge 适配器位于 `src/providers/`(目前支持 GitHub + GiteaGitLab 等可后续扩展)。
## 功能特性
- **28 种事件格式化** — push、pull_request、issues、issue_comment、workflow_run、workflow_job、status、deployment、deployment_status、check_run、check_suite、ping、release、create、delete、star、fork、pull_request_review、pull_request_review_comment、commit_comment、member、label、milestone、discussion、discussion_comment、repository、code_scanning_alert、dependabot_alert+ 通用回退)
- **多提供方 webhook** — GitHub`X-Hub-Signature-256`)与 Gitea`X-Gitea-Signature`)共用 `/webhook` 端点,按请求头自动识别来源
- HMAC-SHA256 签名验证Web Crypto API
- 按事件类型、仓库、操作人、操作、分支、关键词(支持正则)过滤
- 富消息:颜色编码、作者头像、字段、时间戳——渲染为 Discord embed 与 Telegram HTML
@ -51,6 +52,7 @@ npx wrangler dev # 启动本地开发服务器
| 变量 | 说明 |
| --------------------------- | --------------------------------------------------------------------------- |
| `GITHUB_WEBHOOK_SECRET` | GitHub webhook 密钥 |
| `GITEA_WEBHOOK_SECRET` | Gitea webhook 密钥(仅接收 Gitea webhook 时需要) |
| `GITHUB_APP_ID` | GitHub App ID当前代码未使用为兼容保留 |
| `GITHUB_PRIVATE_KEY` | App 私钥PKCS#8 PEM当前代码未使用为兼容保留 |
| `GITHUB_CLIENT_ID` | OAuth Client ID |
@ -104,7 +106,7 @@ npx wrangler dev # 启动本地开发服务器
}
```
路由隶属于**分组**KV `config:groups`),分组用于限定管理权限,并可限制哪些组织/用户的事件流入。完整模式见 `config.example.yaml``docs/zh/guide/configuration.md`
路由隶属于**分组**KV `config:groups`),分组用于限定管理权限,并可限制哪些组织/用户的事件流入——包括来源平台(`providers``github` / `gitea`。完整模式见 `config.example.yaml``docs/zh/guide/configuration.md`
### Web 控制台(`/admin`

View file

@ -56,6 +56,31 @@
/>
<div class="hint">{{ t("groupEditor.ownersHint") }}</div>
</div>
<div class="field">
<label
>{{ t("groupEditor.providers") }}
<span class="lbl-note">{{ t("groupEditor.providersNote") }}</span></label
>
<div class="provider-options">
<label class="inline">
<input
type="checkbox"
:checked="form.providers.includes('github')"
@change="toggleProvider('github', $event)"
/>
<span>GitHub</span>
</label>
<label class="inline">
<input
type="checkbox"
:checked="form.providers.includes('gitea')"
@change="toggleProvider('gitea', $event)"
/>
<span>Gitea</span>
</label>
</div>
<div class="hint">{{ t("groupEditor.providersHint") }}</div>
</div>
<label class="inline">
<input v-model="form.emoji" type="checkbox" />
<span>{{ t("groupEditor.emoji") }}</span>
@ -95,6 +120,7 @@ const form = reactive({
name: "",
adminIds: "",
owners: "",
providers: [] as ("github" | "gitea")[],
emoji: true,
});
@ -105,6 +131,13 @@ function splitList(text: string): string[] {
.filter(Boolean);
}
function toggleProvider(p: "github" | "gitea", e: Event): void {
const checked = (e.target as HTMLInputElement).checked;
form.providers = checked
? [...new Set([...form.providers, p])]
: form.providers.filter((x) => x !== p);
}
watch(
() => props.open,
(open) => {
@ -114,6 +147,9 @@ watch(
form.name = g?.name ?? "";
form.adminIds = (g?.adminIds ?? []).join(", ");
form.owners = (g?.owners ?? []).join(", ");
form.providers = (g?.providers ?? []).filter(
(p): p is "github" | "gitea" => p === "github" || p === "gitea",
);
form.emoji = g?.emoji ?? true;
formError.value = "";
},
@ -141,6 +177,7 @@ function save(): void {
name,
adminIds: splitList(form.adminIds),
owners: owners.length ? owners : undefined,
providers: form.providers.length ? form.providers : undefined,
emoji: form.emoji,
});
}

View file

@ -126,6 +126,10 @@ const en: Dict = {
"groupEditor.ownersPlaceholder": "my-org, some-user",
"groupEditor.ownersHint":
"Only webhook events from these orgs/users enter this group's routes. Leave empty for no restriction.",
"groupEditor.providers": "Source platforms",
"groupEditor.providersNote": "(leave both unchecked = all)",
"groupEditor.providersHint":
"Only webhook events from the checked forges (GitHub / Gitea) enter this group's routes.",
"groupEditor.emoji": "Show emojis in messages",
"groupEditor.cancel": "Cancel",
"groupEditor.save": "Save group",
@ -292,6 +296,10 @@ const zh: Dict = {
"groupEditor.ownersPlaceholder": "my-org, some-user",
"groupEditor.ownersHint":
"只有来自这些组织/用户的 webhook 事件才会进入本分组的路由。留空表示不限制。",
"groupEditor.providers": "来源平台",
"groupEditor.providersNote": "(都不勾选 = 全部来源)",
"groupEditor.providersHint":
"只有来自所勾选 forgeGitHub / Gitea的 webhook 事件才会进入本分组的路由。",
"groupEditor.emoji": "消息中显示表情符号",
"groupEditor.cancel": "取消",
"groupEditor.save": "保存分组",

View file

@ -30,6 +30,7 @@ export interface Group {
name: string;
adminIds: string[];
owners?: string[];
providers?: ("github" | "gitea" | "gitlab")[];
emoji?: boolean;
}

View file

@ -8,6 +8,7 @@ groups:
name: "Default"
adminIds: ["your-github-id"]
# owners: ["myorg"] # restrict events to this org/user (optional)
# providers: ["github", "gitea"] # restrict to source platforms (optional; empty = all)
# emoji: true # include emoji in messages (default true)
routes:

View file

@ -13,7 +13,7 @@ https://your-worker.workers.dev
| Method | Path | Auth | Description |
| -------- | ------------------------------ | ----------------- | --------------------------------------------------------- |
| `GET` | `/health` | None | Health check |
| `POST` | `/webhook` | HMAC signature | GitHub webhook ingestion |
| `POST` | `/webhook` | HMAC signature | GitHub / Gitea webhook ingestion (provider auto-detected) |
| `POST` | `/discord/interactions` | Ed25519 signature | Discord interactions (slash commands, buttons, modals) |
| `POST` | `/telegram/webhook` | Secret token | Telegram updates (bot `/gh` commands) |
| `GET` | `/api/richheader` | None | Open Graph page for the Telegram avatar link-preview card |

View file

@ -20,10 +20,13 @@ src/
├── server.ts # Hono app: /health, /webhook, /discord/interactions, /telegram/webhook, mounts /auth, /admin + /
├── core/
│ └── dispatch.ts # Platform-neutral dispatch: match routes → formatEvent → getDriver().send/edit
├── events/ # GitHub webhook pipeline: verify signature, parse event, match route
│ ├── verify.ts # HMAC signature verification (Web Crypto, timing-safe)
│ ├── parse.ts # parseEvent (headers + body → WebhookEvent)
├── events/ # Provider-agnostic route matching
│ └── match.ts # matchRoute, eventOwners, extractBranch, keyword filtering
├── providers/ # Forge webhook providers (verify + parse/normalize)
│ ├── types.ts # Provider interface (matches/verify/parse)
│ ├── index.ts # detectProvider() registry (github, gitea)
│ ├── github/ # X-GitHub-Event + X-Hub-Signature-256
│ └── gitea/ # X-Gitea-Event + X-Gitea-Signature (normalized payloads)
├── formatters/ # Platform-neutral formatters (produce NeutralMessage)
│ ├── index.ts # formatEvent: 28-event switch → NeutralMessage + re-exports
│ ├── colors.ts # GITHUB_COLORS + WORKFLOW_CONCLUSION_EMOJI

View file

@ -6,13 +6,14 @@ WebHooker requires several secrets to function. For local development, store the
### Required Secrets
| Variable | Description |
| ----------------------- | ------------------------------------------------------------------ |
| `GITHUB_WEBHOOK_SECRET` | Webhook secret from your GitHub App settings |
| `GITHUB_CLIENT_ID` | OAuth client ID from App settings |
| `GITHUB_CLIENT_SECRET` | OAuth client secret from App settings |
| `DISCORD_TOKEN` | Discord bot token |
| `TELEGRAM_TOKEN` | Telegram bot token (from BotFather) — required for Telegram routes |
| Variable | Description |
| ----------------------- | ------------------------------------------------------------------------ |
| `GITHUB_WEBHOOK_SECRET` | Webhook secret from your GitHub App settings |
| `GITEA_WEBHOOK_SECRET` | Webhook secret from your Gitea instance (only to receive Gitea webhooks) |
| `GITHUB_CLIENT_ID` | OAuth client ID from App settings |
| `GITHUB_CLIENT_SECRET` | OAuth client secret from App settings |
| `DISCORD_TOKEN` | Discord bot token |
| `TELEGRAM_TOKEN` | Telegram bot token (from BotFather) — required for Telegram routes |
> [!NOTE]
> `GITHUB_APP_ID` and `GITHUB_PRIVATE_KEY` are not currently used by the code — the
@ -30,6 +31,17 @@ WebHooker requires several secrets to function. For local development, store the
| `BASE_URL` | Public URL for OAuth callbacks | `http://localhost:8787` |
| `ADMIN_USER_IDS` | Comma-separated GitHub user IDs (or logins) allowed to access the Web UI | Disabled |
## Webhook Providers
WebHooker ingests webhooks from multiple forges through the same `POST /webhook` endpoint; the provider is auto-detected from the request headers, so point every forge's webhook at `{BASE_URL}/webhook`.
| Provider | Event header | Signature header | Signature format | Secret |
| -------- | ---------------- | --------------------- | -------------------------- | ----------------------- |
| GitHub | `X-GitHub-Event` | `X-Hub-Signature-256` | `sha256=<hex>` HMAC-SHA256 | `GITHUB_WEBHOOK_SECRET` |
| Gitea | `X-Gitea-Event` | `X-Gitea-Signature` | plain hex HMAC-SHA256 | `GITEA_WEBHOOK_SECRET` |
Gitea payloads are normalized to the same internal shape as GitHub events, so routes, filters, and the 28 formatters work unchanged. Unknown or unmapped Gitea events fall back to the generic formatter. Repository/commit/user links are derived from the payload's `repository.html_url`, so they point at your Gitea instance.
## Web UI
WebHooker ships with a built-in config console at `/admin` for managing routes in the browser. It is protected by GitHub OAuth plus an admin whitelist.
@ -156,17 +168,19 @@ Routes belong to groups. Groups scope admin access and can restrict which events
"id": "backend-team",
"name": "Backend Team",
"adminIds": ["rhencloud"],
"owners": ["myorg"]
"owners": ["myorg"],
"providers": ["github", "gitea"]
}
```
| Field | Type | Required | Description |
| ---------- | -------- | -------- | ---------------------------------------------------------------------- |
| `id` | string | Yes | Lowercase id (`a-z0-9`, `-`); referenced by each route's `groupId` |
| `name` | string | Yes | Human-readable group name |
| `adminIds` | string[] | Yes | GitHub user IDs or logins who may manage this group's routes |
| `owners` | string[] | No | Org/user logins whose events are accepted into this group; empty = all |
| `emoji` | boolean | No | Whether to include emoji in this group's messages (default `true`) |
| Field | Type | Required | Description |
| ----------- | -------- | -------- | ------------------------------------------------------------------------- |
| `id` | string | Yes | Lowercase id (`a-z0-9`, `-`); referenced by each route's `groupId` |
| `name` | string | Yes | Human-readable group name |
| `adminIds` | string[] | Yes | GitHub user IDs or logins who may manage this group's routes |
| `owners` | string[] | No | Org/user logins whose events are accepted into this group; empty = all |
| `providers` | string[] | No | Source platforms allowed into this group (`github`, `gitea`); empty = all |
| `emoji` | boolean | No | Whether to include emoji in this group's messages (default `true`) |
### Access Model
@ -174,6 +188,7 @@ Routes belong to groups. Groups scope admin access and can restrict which events
- **Group admins** (`adminIds`) only see and edit the groups they manage; submitting a route outside their groups returns `403`.
- Group admin endpoints operate on a single group at a time via `/admin/api/groups/:id/routes`; `groupId` is forced from the path parameter.
- The `owners` list restricts which event actors (sender logins) the group's routes will dispatch at all.
- The `providers` list restricts which forge's events (`github`, `gitea`) the group's routes will dispatch. This lets you keep GitHub and Gitea groups separate even when org/user names collide.
## Filter Types

View file

@ -99,6 +99,14 @@ Your worker is now live at `https://webhooker.<your-subdomain>.workers.dev`.
2. Set **Webhook URL** to `https://webhooker.<your-subdomain>.workers.dev/webhook`
3. Set **Webhook secret** to match `GITHUB_WEBHOOK_SECRET`
### 6. (Optional) Configure Gitea Webhook
1. In your Gitea repo, go to **Settings → Webhooks → Add Webhook → Gitea**
2. Set **Target URL** to `https://webhooker.<your-subdomain>.workers.dev/webhook`
3. Set **HTTP Method** to `POST` and **Content Type** to `application/json`
4. Set **Secret** to match `GITEA_WEBHOOK_SECRET`
5. Choose the events to trigger (push, issues, pull requests, releases, ...)
## GitHub App Setup
### 1. Create App

View file

@ -1,12 +1,12 @@
# Introduction
WebHooker is a GitHub webhook dispatcher built on Cloudflare Workers. It receives GitHub webhook events, applies configurable filters, formats them into rich messages, and delivers them to Discord channels/threads (embeds) and Telegram chats/topics (HTML) via their REST APIs. In-Discord `/gh` interactions arrive via an HTTPS Interactions Endpoint (Ed25519-verified); Telegram `/gh` commands arrive via the Telegram webhook. Routes and groups are managed through a built-in Web UI.
WebHooker is a GitHub/Gitea webhook dispatcher built on Cloudflare Workers. It receives webhook events from supported forges (GitHub, Gitea — more can be added via `src/providers/`), applies configurable filters, formats them into rich messages, and delivers them to Discord channels/threads (embeds) and Telegram chats/topics (HTML) via their REST APIs. In-Discord `/gh` interactions arrive via an HTTPS Interactions Endpoint (Ed25519-verified); Telegram `/gh` commands arrive via the Telegram webhook. Routes and groups are managed through a built-in Web UI.
## Architecture
```text
GitHub Webhook → Cloudflare Worker (Hono)
├── POST /webhook → verify → dedup → filter → format → Discord (REST) / Telegram (Bot API)
GitHub / Gitea Webhook → Cloudflare Worker (Hono)
├── POST /webhook → detect provider → verify → dedup → filter → format → Discord (REST) / Telegram (Bot API)
├── POST /discord/interactions → verify (Ed25519) → handle /gh slash & context commands
├── POST /telegram/webhook → verify (secret token) → handle /gh commands
├── GET /auth/github → OAuth flow
@ -27,10 +27,10 @@ GitHub Webhook → Cloudflare Worker (Hono)
### Data Flow
1. GitHub sends a webhook to `POST /webhook`
2. Worker verifies the HMAC-SHA256 signature
3. Worker deduplicates by `X-GitHub-Delivery` (KV, short TTL) to drop repeat deliveries
4. Worker parses the event type and payload
1. A forge (GitHub or Gitea) sends a webhook to `POST /webhook`
2. Worker detects the provider from its headers (`X-GitHub-Event` / `X-Gitea-Event`) and verifies the provider-specific HMAC-SHA256 signature
3. Worker deduplicates by the delivery id (KV, short TTL) to drop repeat deliveries
4. Worker parses the event type and normalizes the payload to a GitHub-shaped event
5. Routes are evaluated against filters (event, repo, actor, action, branch, keyword) and group owner restrictions
6. Matching routes trigger formatter functions that produce platform-neutral messages
7. Each message is sent to its route's target(s) via the Discord or Telegram REST API with rate-limit retry; `workflow_run` progress is edited in place. Every attempt is recorded in the D1 send log

View file

@ -3,8 +3,8 @@ layout: home
hero:
name: WebHooker
text: GitHub Webhook → Discord
tagline: Receive GitHub events via Cloudflare Workers, apply filters, and route formatted messages to Discord channels/threads and Telegram chats/topics.
text: GitHub / Gitea Webhook → Discord
tagline: Receive GitHub and Gitea events via Cloudflare Workers, apply filters, and route formatted messages to Discord channels/threads and Telegram chats/topics.
actions:
- theme: brand
text: Get Started
@ -23,7 +23,7 @@ features:
- title: Web UI, Groups & Commands
details: "Manage routes, groups and send logs from a built-in admin console. Link your GitHub account and comment on issues/PRs as yourself via /gh commands on Discord or Telegram."
- title: Signature Verification
details: HMAC-SHA256 webhook signature verification and Ed25519 interaction signature verification using the Web Crypto API with timing-safe comparison.
details: Provider-aware HMAC-SHA256 webhook signature verification (GitHub X-Hub-Signature-256, Gitea X-Gitea-Signature) and Ed25519 interaction signature verification using the Web Crypto API with timing-safe comparison.
- title: In-Place Updates
details: workflow_run progress is edited in place on a single message as the run advances, on both Discord and Telegram.
---

View file

@ -13,7 +13,7 @@ https://your-worker.workers.dev
| 方法 | 路径 | 鉴权 | 说明 |
| -------- | ------------------------------ | ------------ | ------------------------------------------------ |
| `GET` | `/health` | 无 | 健康检查 |
| `POST` | `/webhook` | HMAC 签名 | GitHub webhook 接入 |
| `POST` | `/webhook` | HMAC 签名 | GitHub / Gitea webhook 接入(自动识别来源) |
| `POST` | `/discord/interactions` | Ed25519 签名 | Discord 交互斜杠命令、按钮、modal |
| `POST` | `/telegram/webhook` | Secret token | Telegram 更新bot `/gh` 命令) |
| `GET` | `/api/richheader` | 无 | 用于 Telegram 头像链接预览卡片的 Open Graph 页面 |

View file

@ -20,10 +20,13 @@ src/
├── server.ts # Hono 应用: /health、/webhook、/discord/interactions、/telegram/webhook挂载 /auth、/admin + /
├── core/
│ └── dispatch.ts # 平台中立分发:匹配路由 → formatEvent → getDriver().send/edit
├── events/ # GitHub webhook 事件流水线:验证签名、解析事件、匹配路由
│ ├── verify.ts # HMAC 签名验证 (Web Crypto时间安全)
│ ├── parse.ts # parseEvent (headers + body → WebhookEvent)
├── events/ # 与提供方无关的路由匹配
│ └── match.ts # matchRoute、eventOwners、extractBranch、关键词过滤
├── providers/ # Forge webhook 提供方(验证 + 解析/归一化)
│ ├── types.ts # Provider 接口matches/verify/parse
│ ├── index.ts # detectProvider() 注册表github、gitea
│ ├── github/ # X-GitHub-Event + X-Hub-Signature-256
│ └── gitea/ # X-Gitea-Event + X-Gitea-Signature归一化载荷
├── formatters/ # 平台中立格式化器(产出 NeutralMessage
│ ├── index.ts # formatEvent28 事件 switch → NeutralMessage + re-export
│ ├── colors.ts # GITHUB_COLORS + WORKFLOW_CONCLUSION_EMOJI

View file

@ -9,6 +9,7 @@ WebHooker 需要多个密钥才能运行。本地开发时存储在 `.dev.vars`
| 变量 | 说明 |
| ----------------------- | -------------------------------------------------------- |
| `GITHUB_WEBHOOK_SECRET` | GitHub App 设置中的 Webhook 密钥 |
| `GITEA_WEBHOOK_SECRET` | Gitea 实例的 Webhook 密钥(仅接收 Gitea webhook 时需要) |
| `GITHUB_CLIENT_ID` | App 设置中的 OAuth 客户端 ID |
| `GITHUB_CLIENT_SECRET` | App 设置中的 OAuth 客户端密钥 |
| `DISCORD_TOKEN` | Discord Bot Token |
@ -30,6 +31,17 @@ WebHooker 需要多个密钥才能运行。本地开发时存储在 `.dev.vars`
| `TELEGRAM_WEBHOOK_SECRET` | `POST /telegram/webhook` 验签密钥X-Telegram-Bot-Api-Secret-Token | 未设置时不校验 |
| `TELEGRAM_RICH_HEADER_HOST` | 外部 rich-header 服务的基础 URL未设置时使用内置的 `GET /api/richheader` 生成 Telegram 头像卡片 | 内置 `/api/richheader` |
## Webhook 提供方
WebHooker 通过同一个 `POST /webhook` 端点接收多个 forge 的 webhook按请求头自动识别来源只需把各 forge 的 webhook 指向 `{BASE_URL}/webhook` 即可。
| 提供方 | 事件请求头 | 签名请求头 | 签名格式 | 密钥 |
| ------ | ---------------- | --------------------- | -------------------------- | ----------------------- |
| GitHub | `X-GitHub-Event` | `X-Hub-Signature-256` | `sha256=<hex>` HMAC-SHA256 | `GITHUB_WEBHOOK_SECRET` |
| Gitea | `X-Gitea-Event` | `X-Gitea-Signature` | 纯 hex HMAC-SHA256 | `GITEA_WEBHOOK_SECRET` |
Gitea payload 会被归一化为与 GitHub 相同的内部结构,因此路由、过滤器与 28 个格式化器无需改动即可复用;未知或未映射的 Gitea 事件回退到通用格式化器。仓库/提交/用户链接基于 payload 的 `repository.html_url` 生成,会指向你的 Gitea 实例。
## Web 控制台
WebHooker 内置了位于 `/admin` 的配置控制台,可在浏览器中管理路由。它由 GitHub OAuth 和管理员白名单保护。
@ -156,17 +168,19 @@ WebHooker 内置了位于 `/admin` 的配置控制台,可在浏览器中管理
"id": "backend-team",
"name": "后端团队",
"adminIds": ["rhencloud"],
"owners": ["myorg"]
"owners": ["myorg"],
"providers": ["github", "gitea"]
}
```
| 字段 | 类型 | 必需 | 说明 |
| ---------- | -------- | ---- | ----------------------------------------------------- |
| `id` | string | 是 | 小写 id`a-z0-9``-`),由每条路由的 `groupId` 引用 |
| `name` | string | 是 | 可读的分组名称 |
| `adminIds` | string[] | 是 | 可管理该分组路由的 GitHub 用户 ID 或登录名 |
| `owners` | string[] | 否 | 允许事件进入该分组的组织/用户登录名;为空表示不限制 |
| `emoji` | boolean | 否 | 是否在该分组消息中显示 emoji默认 `true` |
| 字段 | 类型 | 必需 | 说明 |
| ----------- | -------- | ---- | ----------------------------------------------------------- |
| `id` | string | 是 | 小写 id`a-z0-9``-`),由每条路由的 `groupId` 引用 |
| `name` | string | 是 | 可读的分组名称 |
| `adminIds` | string[] | 是 | 可管理该分组路由的 GitHub 用户 ID 或登录名 |
| `owners` | string[] | 否 | 允许事件进入该分组的组织/用户登录名;为空表示不限制 |
| `providers` | string[] | 否 | 允许进入该分组的来源平台(`github``gitea`);为空表示全部 |
| `emoji` | boolean | 否 | 是否在该分组消息中显示 emoji默认 `true` |
### 权限模型
@ -174,6 +188,7 @@ WebHooker 内置了位于 `/admin` 的配置控制台,可在浏览器中管理
- **分组管理员**`adminIds`)只能查看和编辑其管理的分组;提交其分组之外的路由返回 `403`
- 分组管理端点通过 `/admin/api/groups/:id/routes` 一次只操作一个分组;`groupId` 由路径参数强制指定。
- `owners` 列表限定哪些事件参与者(发送者登录名)的事件会被该分组的路由投递。
- `providers` 列表限定哪个 forge`github``gitea`)的事件会被该分组的路由投递。即使组织/用户同名,也可以借此将 GitHub 与 Gitea 分组区分开。
## 过滤器类型

View file

@ -98,6 +98,14 @@ Worker 现在可通过 `https://webhooker.<your-subdomain>.workers.dev` 访问
2. 设置 **Webhook URL**`https://webhooker.<your-subdomain>.workers.dev/webhook`
3. 设置 **Webhook secret**`GITHUB_WEBHOOK_SECRET` 一致
### 6.(可选)配置 Gitea Webhook
1. 在 Gitea 仓库中进入 **设置 → Web 钩子 → 添加 Web 钩子 → Gitea**
2. 设置 **目标 URL**`https://webhooker.<your-subdomain>.workers.dev/webhook`
3. 设置 **HTTP 方法**`POST`、**Content Type** 为 `application/json`
4. 设置 **密钥**`GITEA_WEBHOOK_SECRET` 一致
5. 选择要触发的事件push、议题、拉取请求、发布等
## GitHub App 设置
### 1. 创建 App

View file

@ -1,12 +1,12 @@
# 简介
WebHooker 是一个基于 Cloudflare Workers 构建的 GitHub webhook 调度器。它接收 GitHub webhook 事件,应用可配置的过滤器,将事件格式化为富消息,并通过各自 REST API 投递到 Discord 频道/子区embed与 Telegram 群组/话题HTML。Discord 内的 `/gh` 交互通过 HTTPS Interactions EndpointEd25519 验签送达Telegram 的 `/gh` 命令通过 Telegram webhook 送达。路由与分组通过内置的 Web UI 管理。
WebHooker 是一个基于 Cloudflare Workers 构建的 GitHub/Gitea webhook 调度器。它接收来自受支持 forgeGitHub、Gitea——更多可通过 `src/providers/` 扩展)的 webhook 事件,应用可配置的过滤器,将事件格式化为富消息,并通过各自 REST API 投递到 Discord 频道/子区embed与 Telegram 群组/话题HTML。Discord 内的 `/gh` 交互通过 HTTPS Interactions EndpointEd25519 验签送达Telegram 的 `/gh` 命令通过 Telegram webhook 送达。路由与分组通过内置的 Web UI 管理。
## 架构
```text
GitHub Webhook → Cloudflare Worker (Hono)
├── POST /webhook → 验证 → 去重 → 过滤 → 格式化 → Discord (REST) / Telegram (Bot API)
GitHub / Gitea Webhook → Cloudflare Worker (Hono)
├── POST /webhook → 识别提供方 → 验证 → 去重 → 过滤 → 格式化 → Discord (REST) / Telegram (Bot API)
├── POST /discord/interactions → 验证 (Ed25519) → 处理 /gh 斜杠与右键命令
├── POST /telegram/webhook → 验证 (secret token) → 处理 /gh 命令
├── GET /auth/github → OAuth 流程
@ -27,10 +27,10 @@ GitHub Webhook → Cloudflare Worker (Hono)
### 数据流
1. GitHub 发送 webhook 到 `POST /webhook`
2. Worker 验证 HMAC-SHA256 签名
3. Worker 按 `X-GitHub-Delivery` 去重KV短 TTL丢弃重复投递
4. Worker 解析事件类型和载荷
1. 某个 forgeGitHub 或 Gitea发送 webhook 到 `POST /webhook`
2. Worker 根据请求头识别提供方(`X-GitHub-Event` / `X-Gitea-Event`)并验证对应提供的 HMAC-SHA256 签名
3. Worker 按投递 ID 去重KV短 TTL丢弃重复投递
4. Worker 解析事件类型并将载荷归一化为 GitHub 形状的事件
5. 根据过滤器event、repo、actor、action、branch、keyword与分组所有者限制评估路由
6. 匹配的路由触发格式化器函数生成平台中立消息
7. 每条消息通过 Discord 或 Telegram REST API 发送到对应路由的目标,并处理速率限制重试;`workflow_run` 进度原地更新。每次尝试都记录到 D1 发送日志

View file

@ -3,8 +3,8 @@ layout: home
hero:
name: WebHooker
text: GitHub Webhook → Discord
tagline: 通过 Cloudflare Workers 接收 GitHub 事件,应用过滤器,将格式化消息路由到 Discord 频道/子区与 Telegram 群组/话题。
text: GitHub / Gitea Webhook → Discord
tagline: 通过 Cloudflare Workers 接收 GitHub 与 Gitea 事件,应用过滤器,将格式化消息路由到 Discord 频道/子区与 Telegram 群组/话题。
actions:
- theme: brand
text: 快速开始

View file

@ -7,8 +7,9 @@ import {
adminCookie,
clearAdminCookie,
} from "../web/session";
import { groupAcceptsProvider } from "../web/groups";
import { loadRoutes, saveRoutes, loadConfig } from "../config";
import type { Env, Route } from "../types";
import type { Env, Route, Group } from "../types";
function createMockKV(): KVNamespace {
const store = new Map<string, { value: string; expiration?: number }>();
@ -111,6 +112,26 @@ describe("admin-session", () => {
});
});
describe("groupAcceptsProvider", () => {
const base: Group = { id: "g", name: "G", adminIds: [] };
it("accepts every provider when none are configured", () => {
expect(groupAcceptsProvider(base, "github")).toBe(true);
expect(groupAcceptsProvider(base, "gitea")).toBe(true);
expect(groupAcceptsProvider(base, undefined)).toBe(true);
});
it("restricts to the configured providers", () => {
expect(groupAcceptsProvider({ ...base, providers: ["gitea"] }, "gitea")).toBe(true);
expect(groupAcceptsProvider({ ...base, providers: ["gitea"] }, "github")).toBe(false);
expect(groupAcceptsProvider({ ...base, providers: ["gitea"] }, undefined)).toBe(false);
});
it("matches case-insensitively and trims whitespace", () => {
expect(groupAcceptsProvider({ ...base, providers: [" Gitea "] }, "gitea")).toBe(true);
});
});
describe("config routes persistence", () => {
it("saves and loads routes from KV", async () => {
const kv = createMockKV();

View file

@ -215,4 +215,48 @@ describe("dispatchEvent fallback routing", () => {
expect(parsed.content).toBe("<@&111> <@&222>");
expect(parsed.embeds?.[0]).toBeDefined();
});
it("filters events by the group's source provider", async () => {
const sent: string[] = [];
mockFetch((url) => {
sent.push(url);
return new Response("{}", { status: 200 });
});
const kv = createMockKV();
await kv.put(
"config:groups",
JSON.stringify([
{ id: "gh", name: "GH", adminIds: [], providers: ["github"] },
{ id: "gitea", name: "Gitea", adminIds: [], providers: ["gitea"] },
]),
);
const env = createEnv({ KV: kv, DB: createMockDB() });
const routes: Route[] = [
{
id: "gh-push",
name: "GH",
enabled: true,
groupId: "gh",
filters: [{ type: "event", match: "push" }],
targets: [{ channelId: "111" }],
},
{
id: "gitea-push",
name: "Gitea",
enabled: true,
groupId: "gitea",
filters: [{ type: "event", match: "push" }],
targets: [{ channelId: "222" }],
},
];
await dispatchEvent(
{ ...baseConfig, routes },
{ event: "push", payload: {}, provider: "gitea" },
env,
);
expect(sent.filter((u) => u.includes("/222/"))).toHaveLength(1);
expect(sent.filter((u) => u.includes("/111/"))).toHaveLength(0);
});
});

View file

@ -0,0 +1,142 @@
import { describe, it, expect } from "bun:test";
import { createHmac } from "crypto";
import { detectProvider } from "../providers";
import { verifyGiteaSignature } from "../providers/gitea/verify";
import { parseGiteaEvent } from "../providers/gitea/parse";
import { formatEvent } from "../formatters";
import type { Route } from "../types";
function giteaSign(body: string, secret: string): string {
return createHmac("sha256", secret).update(body).digest("hex");
}
describe("provider detection", () => {
it("detects github by X-GitHub-Event header", () => {
const p = detectProvider({ "x-github-event": "push" });
expect(p?.id).toBe("github");
});
it("detects gitea by X-Gitea-Event header", () => {
const p = detectProvider({ "x-gitea-event": "push" });
expect(p?.id).toBe("gitea");
});
it("prefers gitea when both gitea and github headers are present", () => {
// Gitea webhooks also send GitHub-compatible headers (X-GitHub-Event,
// X-Hub-Signature-256, X-Gogs-*), so detection must not misclassify them.
const p = detectProvider({
"x-gitea-event": "push",
"x-github-event": "push",
"x-gitea-signature": "abc",
"x-hub-signature-256": "sha256=abc",
});
expect(p?.id).toBe("gitea");
});
it("returns null for unknown providers", () => {
expect(detectProvider({})).toBeNull();
expect(detectProvider({ "x-gitlab-event": "Push Hook" })).toBeNull();
});
});
describe("gitea signature", () => {
const secret = "gitea-secret";
it("accepts a valid hex HMAC-SHA256 signature (no sha256= prefix)", async () => {
const body = '{"ref":"refs/heads/main"}';
const sig = giteaSign(body, secret);
expect(await verifyGiteaSignature(body, sig, secret)).toBe(true);
});
it("rejects an invalid signature", async () => {
expect(await verifyGiteaSignature("body", "deadbeef", secret)).toBe(false);
});
it("rejects when signature or secret is missing", async () => {
expect(await verifyGiteaSignature("body", undefined, secret)).toBe(false);
expect(await verifyGiteaSignature("body", "abc", undefined)).toBe(false);
});
});
describe("gitea event parsing", () => {
it("parses a push event and sets the provider", () => {
const event = parseGiteaEvent(
{ "x-gitea-event": "push", "x-gitea-delivery": "d-1" },
JSON.stringify({
ref: "refs/heads/main",
compare_url: "https://git.example.com/org/repo/compare/abc...def",
pusher: { login: "octo", html_url: "https://git.example.com/octo" },
repository: { full_name: "org/repo", html_url: "https://git.example.com/org/repo" },
}),
);
expect(event).not.toBeNull();
expect(event!.provider).toBe("gitea");
expect(event!.event).toBe("push");
expect(event!.deliveryId).toBe("d-1");
expect(event!.payload.compare).toBe("https://git.example.com/org/repo/compare/abc...def");
expect((event!.payload.sender as { login?: string }).login).toBe("octo");
});
it("maps pull_request_comment to pull_request_review_comment and pulls the PR", () => {
const event = parseGiteaEvent(
{ "x-gitea-event": "pull_request_comment" },
JSON.stringify({
action: "created",
issue: { number: 7, title: "Add feature", html_url: "https://git.example.com/org/repo/pulls/7" },
comment: { body: "looks good", line: 12, html_url: "https://git.example.com/org/repo/pulls/7#issuecomment-1" },
repository: { full_name: "org/repo" },
sender: { login: "octo" },
}),
);
expect(event!.event).toBe("pull_request_review_comment");
const pr = event!.payload.pull_request as { number?: number; title?: string };
expect(pr.number).toBe(7);
const comment = event!.payload.comment as { position?: number };
expect(comment.position).toBe(12);
});
it("copies top-level commit_id onto the comment for commit_comment events", () => {
const event = parseGiteaEvent(
{ "x-gitea-event": "commit_comment" },
JSON.stringify({
action: "created",
commit_id: "abcd1234ef",
comment: { body: "why?", html_url: "https://git.example.com/org/repo/commit/abcd1234ef#commitcomment-1" },
repository: { full_name: "org/repo" },
sender: { login: "octo" },
}),
);
expect(event!.event).toBe("commit_comment");
expect((event!.payload.comment as { commit_id?: string }).commit_id).toBe("abcd1234ef");
});
it("returns null for missing event header or invalid JSON", () => {
expect(parseGiteaEvent({}, "{}")).toBeNull();
expect(parseGiteaEvent({ "x-gitea-event": "push" }, "not json")).toBeNull();
});
it("formats a normalized gitea push with gitea commit links", () => {
const event = parseGiteaEvent(
{ "x-gitea-event": "push" },
JSON.stringify({
ref: "refs/heads/main",
compare_url: "https://git.example.com/org/repo/compare/abc...def",
pusher: { login: "octo", html_url: "https://git.example.com/octo" },
commits: [{ id: "abcd1234ef", message: "fix stuff", added: [], removed: [], modified: [] }],
repository: { full_name: "org/repo", html_url: "https://git.example.com/org/repo" },
}),
);
const route: Route = {
id: "test",
name: "Test",
enabled: true,
filters: [],
targets: [{ channelId: "111" }],
};
const msg = formatEvent(route, event!);
expect(msg.title).toContain("org/repo");
expect(msg.fields![0].value).toBe(
"[`abcd123`](https://git.example.com/org/repo/commit/abcd1234ef) fix stuff",
);
});
});

View file

@ -1,7 +1,7 @@
import { describe, it, expect } from "bun:test";
import { createHmac } from "crypto";
import { verifySignature } from "../events/verify";
import { parseEvent } from "../events/parse";
import { verifySignature } from "../providers/github/verify";
import { parseEvent } from "../providers/github/parse";
import { matchRoute } from "../events/match";
import type { Route, WebhookEvent } from "../types";

View file

@ -4,7 +4,7 @@ import { matchRoute, eventOwners } from "../events/match";
import { log } from "../lib/log";
import { loadTranslations, type Translations } from "../lib/i18n";
import { recordSend } from "../lib/send-log";
import { loadGroups, groupAcceptsOwners } from "../web/groups";
import { loadGroups, groupAcceptsOwners, groupAcceptsProvider } from "../web/groups";
import { getDriver } from "../drivers";
import type { SendResult } from "../drivers/types";
@ -24,7 +24,9 @@ export async function dispatchEvent(config: Config, event: WebhookEvent, env: En
const accepted = (route: Route): boolean => {
if (!route.groupId) return true;
const group = groupById.get(route.groupId);
return !group || groupAcceptsOwners(group, owners);
if (!group) return true;
if (!groupAcceptsOwners(group, owners)) return false;
return groupAcceptsProvider(group, event.provider);
};
const matched = config.routes.filter(
(route) => !route.fallback && matchRoute(route, event) && accepted(route),

View file

@ -1,43 +0,0 @@
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 verifySignature(
payload: string,
signature: string | undefined,
secret: string,
): Promise<boolean> {
if (!signature) return false;
const encoder = new TextEncoder();
const key = await getHmacKey(secret);
const sig = await crypto.subtle.sign("HMAC", key, encoder.encode(payload));
const expected = `sha256=${Array.from(new Uint8Array(sig))
.map((b) => b.toString(16).padStart(2, "0"))
.join("")}`;
try {
const a = encoder.encode(signature);
const b = encoder.encode(expected);
if (a.byteLength !== b.byteLength) return false;
let diff = 0;
for (let i = 0; i < a.byteLength; i++) {
diff |= a[i]! ^ b[i]!;
}
return diff === 0;
} catch {
return false;
}
}

View file

@ -1,6 +1,6 @@
import type { NeutralMessage, NeutralAuthor } from "../types";
import { GITHUB_COLORS, WORKFLOW_CONCLUSION_EMOJI } from "./colors";
import { emojiPrefix, type T, buildMessage } from "./helpers";
import { emojiPrefix, type T, buildMessage, repoBaseUrl } from "./helpers";
export function formatCheckSuite(
payload: Record<string, unknown>,
@ -24,6 +24,7 @@ export function formatCheckSuite(
: 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 =
@ -61,8 +62,8 @@ export function formatCheckSuite(
fields.push({
name: t("fields.commit"),
value:
repo && suite.head_sha
? `[${suite.head_sha.slice(0, 7)}](https://github.com/${repo}/commit/${suite.head_sha})`
baseUrl && suite.head_sha
? `[${suite.head_sha.slice(0, 7)}](${baseUrl}/commit/${suite.head_sha})`
: `\`${suite.head_sha.slice(0, 7)}\``,
inline: true,
});
@ -77,9 +78,7 @@ export function formatCheckSuite(
}),
url:
suite.html_url ??
(repo && suite.head_sha
? `https://github.com/${repo}/commit/${suite.head_sha}/checks`
: undefined),
(baseUrl && suite.head_sha ? `${baseUrl}/commit/${suite.head_sha}/checks` : undefined),
color: GITHUB_COLORS[colorKey],
fields,
},

View file

@ -23,3 +23,14 @@ export function buildMessage(
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;
}

View file

@ -32,6 +32,7 @@ export function formatEvent(
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);
@ -39,7 +40,7 @@ export function formatEvent(
const author: NeutralAuthor = {
name: sender ?? t("common.unknown"),
iconUrl: senderAvatar,
url: sender ? `https://github.com/${sender}` : undefined,
url: senderUrl ?? (sender ? `https://github.com/${sender}` : undefined),
};
switch (eventType) {

View file

@ -1,6 +1,6 @@
import type { NeutralMessage, NeutralAuthor } from "../types";
import { GITHUB_COLORS } from "./colors";
import { emojiPrefix, type T, buildMessage } from "./helpers";
import { emojiPrefix, type T, buildMessage, repoBaseUrl } from "./helpers";
export function formatPush(
payload: Record<string, unknown>,
@ -20,6 +20,7 @@ export function formatPush(
}>;
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);
@ -46,7 +47,7 @@ export function formatPush(
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 = repo && c.id ? `https://github.com/${repo}/commit/${c.id}` : null;
const url = baseUrl && c.id ? `${baseUrl}/commit/${c.id}` : null;
const hash = url ? `[\`${shortId}\`](${url})` : `\`${shortId}\``;
return { name: `\u200b`, value: `${hash} ${msg}` };
};

View file

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

View file

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

View file

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

View file

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

View file

@ -1,4 +1,4 @@
import type { WebhookEvent } from "../types";
import type { WebhookEvent } from "../../types";
export function parseEvent(headers: Record<string, string>, body: string): WebhookEvent | null {
const event = headers["x-github-event"];
@ -9,7 +9,7 @@ export function parseEvent(headers: Record<string, string>, body: string): Webho
try {
const payload = JSON.parse(body);
return { event, payload, signature, deliveryId };
return { provider: "github", event, payload, signature, deliveryId };
} catch {
return null;
}

View file

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

36
src/providers/hmac.ts Normal file
View file

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

22
src/providers/index.ts Normal file
View file

@ -0,0 +1,22 @@
import type { Provider } from "./types";
import { githubProvider } from "./github";
import { giteaProvider } from "./gitea";
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`.
*/
const providers: Provider[] = [giteaProvider, githubProvider];
/**
* 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
src/providers/types.ts Normal file
View file

@ -0,0 +1,23 @@
import type { Env, WebhookEvent } from "../types";
/**
* A forge webhook provider (GitHub, Gitea, GitLab, ...). Each provider owns
* signature verification and payload parsing/normalization. The rest of the
* pipeline (route matching, formatting, dispatch) only sees the normalized
* {@link WebhookEvent} and never knows which forge produced it.
*/
export interface Provider {
readonly id: "github" | "gitea" | "gitlab";
/**
* 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;
}

View file

@ -1,7 +1,6 @@
import { Hono } from "hono";
import type { Env } from "./types";
import { verifySignature } from "./events/verify";
import { parseEvent } from "./events/parse";
import { detectProvider } from "./providers";
import { dispatchEvent } from "./core/dispatch";
import { handleInteractionRequest } from "./drivers/discord/interactions";
import { handleTelegramWebhookRequest } from "./drivers/telegram/updates";
@ -44,24 +43,26 @@ export function createServer(): Hono<{ Bindings: Env }> {
headers[key] = value;
});
if (
!(await verifySignature(body, headers["x-hub-signature-256"], c.env.GITHUB_WEBHOOK_SECRET))
) {
const provider = detectProvider(headers);
if (!provider) {
return c.json({ error: "Unknown webhook provider" }, 400);
}
if (!(await provider.verify(body, headers, c.env))) {
return c.json({ error: "Invalid signature" }, 401);
}
const event = parseEvent(headers, body);
const event = provider.parse(body, headers);
if (!event) {
return c.json({ error: "Invalid event" }, 400);
}
const delivery = headers["x-github-delivery"];
if (delivery) {
const seen = await c.env.KV.get(`delivery:${delivery}`);
if (event.deliveryId) {
const seen = await c.env.KV.get(`delivery:${event.deliveryId}`);
if (seen) {
return c.json({ ok: true, duplicate: true });
}
await c.env.KV.put(`delivery:${delivery}`, "1", { expirationTtl: 300 });
await c.env.KV.put(`delivery:${event.deliveryId}`, "1", { expirationTtl: 300 });
}
const config = await loadConfig(c.env);

View file

@ -1,5 +1,6 @@
export interface Env {
GITHUB_WEBHOOK_SECRET: string;
GITEA_WEBHOOK_SECRET?: string;
GITHUB_APP_ID?: string;
GITHUB_PRIVATE_KEY?: string;
GITHUB_CLIENT_ID?: string;
@ -85,6 +86,11 @@ export interface Group {
* 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[];
/**
* Whether to include emoji in messages sent through this group's routes.
* Defaults to true when omitted.
@ -98,11 +104,14 @@ export interface Filter {
exclude?: boolean;
}
export type WebhookProvider = "github" | "gitea" | "gitlab";
export interface WebhookEvent {
event: string;
payload: Record<string, unknown>;
signature?: string;
deliveryId?: string;
provider?: WebhookProvider;
}
export interface NeutralAuthor {

View file

@ -209,6 +209,18 @@ function validateGroups(
) {
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.emoji !== undefined && typeof g.emoji !== "boolean") {
return { ok: false, error: `group "${g.id}".emoji must be a boolean` };
}

View file

@ -40,6 +40,17 @@ export function groupAcceptsOwners(group: Group, owners: string[]): 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");
}
export interface AccessScope {
isSuper: boolean;
/** Groups the user may view/edit. When isSuper, this is every group. */