mirror of
https://github.com/ReCloudStudio/WebHooker.git
synced 2026-09-22 16:11:29 +00:00
merge: resolve conflicts with origin/main (auto-fix formatting)
This commit is contained in:
commit
6f1a334150
22 changed files with 921 additions and 910 deletions
366
README.md
366
README.md
|
|
@ -1,187 +1,187 @@
|
|||
# WebHooker
|
||||
|
||||
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 `server/lib/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, + `custom` webhooks)
|
||||
- **Multi-provider webhooks** — GitHub (`X-Hub-Signature-256`) and Gitea (`X-Gitea-Signature`) share one `/webhook` endpoint; the provider is auto-detected from headers
|
||||
- **Per-group webhook ingress** — every group can get its own `POST /webhook/{groupId}` URL + secret (Gitea, classic GitHub webhooks, and arbitrary custom JSON posts signed with `X-WebHooker-Signature`)
|
||||
- **GitHub App tenant isolation** — bind a group to a GitHub App installation id so only that org/user's events enter it
|
||||
- HMAC-SHA256 signature verification (Web Crypto API)
|
||||
- Filter by event type, repo, actor, action, branch, keyword (supports `*`/`?` globs and `/regex/`)
|
||||
- Rich messages with color coding, author avatars, fields, and timestamps — rendered as Discord embeds and Telegram HTML
|
||||
- Route to Discord channels/threads and Telegram chats/topics (multi-target routes)
|
||||
- `workflow_run` / `check_run` progress is edited **in place** (single message updated as the run advances) on both platforms
|
||||
- **Per-group webhook log channel** — point a group at a Discord channel/thread or Telegram chat/topic and every webhook the group's routes dispatch is summarized there (✅/❌ per route × target)
|
||||
- GitHub OAuth for user actions (comment, edit comment, delete comment, merge, close, react)
|
||||
- **Web UI config console** (`/admin`) — manage routes and groups with GitHub OAuth + admin whitelist, view send logs
|
||||
- **Discord Interactions Endpoint** (Ed25519-verified) for `/gh` slash commands, message context-menu commands, PR merge/close buttons, and comment modals
|
||||
- **Telegram `/gh` commands** (login/logout/comment/merge/close) via the Telegram webhook, with avatar link-preview cards
|
||||
- Cloudflare KV for token/state/config/session storage + D1 for send logs and platform account links
|
||||
- Graceful degradation (webhook-only mode if Discord unavailable)
|
||||
|
||||
## Architecture
|
||||
|
||||
```text
|
||||
GitHub Webhook → Cloudflare Worker (Nuxt 4 / Nitro)
|
||||
├── POST /webhook → verify → dedup → filter → format → Discord (REST) / Telegram (Bot API)
|
||||
├── POST /discord/interactions → verify (Ed25519) → handle command/button/modal
|
||||
├── POST /telegram/webhook → verify (secret token) → handle /gh commands
|
||||
├── GET /auth/github → OAuth flow
|
||||
├── GET /api/richheader → Telegram avatar link-preview card
|
||||
├── POST /api/* → user actions (Bearer token auth)
|
||||
├── /admin → routes, groups & send logs Web UI
|
||||
└── GET /health → status check
|
||||
```
|
||||
|
||||
- **Cloudflare Worker** — HTTP ingress, signature verification, routing, platform dispatch
|
||||
- **Interactions Endpoint** — HTTPS callback (no Discord Gateway connection, no Durable Object); the bot stays offline and commands are registered via the API
|
||||
- **KV** — token storage (`token:{userId}`), OAuth state (`state:{hex}`), route config (`config:routes`), group config (`config:groups`), admin sessions (`session:{id}`), delivery dedup (`delivery:{id}`), message-update tracking (`msg:*`)
|
||||
- **D1** — send logs (`send_logs`), Discord↔GitHub links (`discord_links`), Telegram↔GitHub links (`telegram_links`)
|
||||
|
||||
## Quick Start
|
||||
|
||||
```bash
|
||||
bun install # package manager is bun (lockfile: bun.lock)
|
||||
cp .env.example .dev.vars # Fill in secrets for local dev
|
||||
bun run build # Production build first (wrangler dev serves the built worker)
|
||||
bunx wrangler dev # Start local dev server
|
||||
```
|
||||
|
||||
## Configuration
|
||||
|
||||
### Secrets (`.dev.vars` for local, Worker Secrets for production)
|
||||
|
||||
| Variable | Description |
|
||||
|-----------------------------|------------------------------------------------------------------------------------------------|
|
||||
| `GITHUB_WEBHOOK_SECRET` | Webhook secret from GitHub |
|
||||
| `GITEA_WEBHOOK_SECRET` | Webhook secret from Gitea (required only to receive Gitea webhooks) |
|
||||
# WebHooker
|
||||
|
||||
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 `server/lib/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, + `custom` webhooks)
|
||||
- **Multi-provider webhooks** — GitHub (`X-Hub-Signature-256`) and Gitea (`X-Gitea-Signature`) share one `/webhook` endpoint; the provider is auto-detected from headers
|
||||
- **Per-group webhook ingress** — every group can get its own `POST /webhook/{groupId}` URL + secret (Gitea, classic GitHub webhooks, and arbitrary custom JSON posts signed with `X-WebHooker-Signature`)
|
||||
- **GitHub App tenant isolation** — bind a group to a GitHub App installation id so only that org/user's events enter it
|
||||
- HMAC-SHA256 signature verification (Web Crypto API)
|
||||
- Filter by event type, repo, actor, action, branch, keyword (supports `*`/`?` globs and `/regex/`)
|
||||
- Rich messages with color coding, author avatars, fields, and timestamps — rendered as Discord embeds and Telegram HTML
|
||||
- Route to Discord channels/threads and Telegram chats/topics (multi-target routes)
|
||||
- `workflow_run` / `check_run` progress is edited **in place** (single message updated as the run advances) on both platforms
|
||||
- **Per-group webhook log channel** — point a group at a Discord channel/thread or Telegram chat/topic and every webhook the group's routes dispatch is summarized there (✅/❌ per route × target)
|
||||
- GitHub OAuth for user actions (comment, edit comment, delete comment, merge, close, react)
|
||||
- **Web UI config console** (`/admin`) — manage routes and groups with GitHub OAuth + admin whitelist, view send logs
|
||||
- **Discord Interactions Endpoint** (Ed25519-verified) for `/gh` slash commands, message context-menu commands, PR merge/close buttons, and comment modals
|
||||
- **Telegram `/gh` commands** (login/logout/comment/merge/close) via the Telegram webhook, with avatar link-preview cards
|
||||
- Cloudflare KV for token/state/config/session storage + D1 for send logs and platform account links
|
||||
- Graceful degradation (webhook-only mode if Discord unavailable)
|
||||
|
||||
## Architecture
|
||||
|
||||
```text
|
||||
GitHub Webhook → Cloudflare Worker (Nuxt 4 / Nitro)
|
||||
├── POST /webhook → verify → dedup → filter → format → Discord (REST) / Telegram (Bot API)
|
||||
├── POST /discord/interactions → verify (Ed25519) → handle command/button/modal
|
||||
├── POST /telegram/webhook → verify (secret token) → handle /gh commands
|
||||
├── GET /auth/github → OAuth flow
|
||||
├── GET /api/richheader → Telegram avatar link-preview card
|
||||
├── POST /api/* → user actions (Bearer token auth)
|
||||
├── /admin → routes, groups & send logs Web UI
|
||||
└── GET /health → status check
|
||||
```
|
||||
|
||||
- **Cloudflare Worker** — HTTP ingress, signature verification, routing, platform dispatch
|
||||
- **Interactions Endpoint** — HTTPS callback (no Discord Gateway connection, no Durable Object); the bot stays offline and commands are registered via the API
|
||||
- **KV** — token storage (`token:{userId}`), OAuth state (`state:{hex}`), route config (`config:routes`), group config (`config:groups`), admin sessions (`session:{id}`), delivery dedup (`delivery:{id}`), message-update tracking (`msg:*`)
|
||||
- **D1** — send logs (`send_logs`), Discord↔GitHub links (`discord_links`), Telegram↔GitHub links (`telegram_links`)
|
||||
|
||||
## Quick Start
|
||||
|
||||
```bash
|
||||
bun install # package manager is bun (lockfile: bun.lock)
|
||||
cp .env.example .dev.vars # Fill in secrets for local dev
|
||||
bun run build # Production build first (wrangler dev serves the built worker)
|
||||
bunx wrangler dev # Start local dev server
|
||||
```
|
||||
|
||||
## Configuration
|
||||
|
||||
### Secrets (`.dev.vars` for local, Worker Secrets for production)
|
||||
|
||||
| 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 (used by the App install flow to resolve the installing account) |
|
||||
| `GITHUB_PRIVATE_KEY` | App private key (PKCS#8 PEM; used by the App install flow; optional) |
|
||||
| `GITHUB_CLIENT_ID` | OAuth client ID |
|
||||
| `GITHUB_CLIENT_SECRET` | OAuth client secret |
|
||||
| `DISCORD_TOKEN` | Bot token |
|
||||
| `DISCORD_PUBLIC_KEY` | Discord application public key (from the Developer Portal) — required for interactions |
|
||||
| `DISCORD_APPLICATION_ID` | Discord application id (optional; auto-resolved via `GET /oauth2/applications/@me` if omitted) |
|
||||
| `TELEGRAM_TOKEN` | Telegram bot token (from BotFather) — required for Telegram routes |
|
||||
| `TELEGRAM_WEBHOOK_SECRET` | Optional secret token for `POST /telegram/webhook` verification |
|
||||
| `TELEGRAM_RICH_HEADER_HOST` | Optional base URL overriding the built-in `GET /api/richheader` for Telegram avatar cards |
|
||||
| `BASE_URL` | Public URL for OAuth callbacks and the Telegram webhook sync |
|
||||
| `ADMIN_USER_IDS` | Comma-separated GitHub user IDs (or logins) allowed to access `/admin` |
|
||||
| `ALLOW_SELF_SIGNUP` | `1` to give access-less GitHub users a personal group on first login (default off) |
|
||||
| `AUDIT_RETENTION_DAYS` | Audit-log retention in days for the scheduled cleanup (default 90) |
|
||||
| `NUXT_PUBLIC_DOCS_URL` | Optional docs site URL used by the landing page |
|
||||
| `NUXT_PUBLIC_REPO_URL` | Optional GitHub repo URL used by the landing page |
|
||||
| `NUXT_PUBLIC_LEGAL_CONTACT` | Optional contact shown on `/terms` and `/privacy` |
|
||||
|
||||
### Routes
|
||||
|
||||
Routes are stored in KV (`config:routes` as JSON). There are **no default routes** — every route (including its target) must be defined explicitly, either via the Web UI (`/admin`) or by storing a JSON array in KV. A route may carry multiple `targets`, so one rule can forward to several channels at once:
|
||||
|
||||
```json
|
||||
[
|
||||
{
|
||||
"id": "all-push",
|
||||
"name": "Push Events",
|
||||
"enabled": true,
|
||||
"groupId": "default",
|
||||
"filters": [{ "type": "event", "match": "push" }],
|
||||
"stop": true,
|
||||
"targets": [
|
||||
{ "platform": "discord", "channelId": "CHANNEL_ID" },
|
||||
{ "platform": "telegram", "chatId": "-1001234567890" }
|
||||
]
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
`target.platform` selects the push target: `discord` (default) or `telegram`. Discord targets require `target.channelId` (optional `threadId` for a thread); Telegram targets require `target.chatId` (optional `topicId` for a topic). Routes belong to **groups** (KV `config:groups`) that scope admin access and can restrict which org/user events flow in. See the [Routes & Targets](https://webhooker.docs.worldexecute.me/guide/routes) and [Groups & Access Control](https://webhooker.docs.worldexecute.me/guide/groups) guides for the full schema.
|
||||
|
||||
### Web UI (`/admin`)
|
||||
|
||||
The built-in config console lets you manage routes and groups in the browser (add / edit / delete / toggle / reorder), inspect send logs, manage group members and invite links, and read the audit log — no KV access needed:
|
||||
|
||||
1. Set `ADMIN_USER_IDS` to the GitHub user IDs (or logins) allowed to manage the console, e.g. `ADMIN_USER_IDS=12345,RhenCloud`.
|
||||
2. Visit `/admin` and sign in with GitHub. Users with no access get `403` — unless `ALLOW_SELF_SIGNUP=1` (they receive a personal group) or they follow a group invite link.
|
||||
3. Changes are written to KV immediately and picked up by the webhook pipeline.
|
||||
|
||||
Sign out at `/admin/logout`. Every group has `members` with a role (`owner` / `admin` / `viewer`); all admin operations are recorded in the D1 `audit_logs` table.
|
||||
|
||||
### Filter Types
|
||||
|
||||
| `GITHUB_CLIENT_ID` | OAuth client ID |
|
||||
| `GITHUB_CLIENT_SECRET` | OAuth client secret |
|
||||
| `DISCORD_TOKEN` | Bot token |
|
||||
| `DISCORD_PUBLIC_KEY` | Discord application public key (from the Developer Portal) — required for interactions |
|
||||
| `DISCORD_APPLICATION_ID` | Discord application id (optional; auto-resolved via `GET /oauth2/applications/@me` if omitted) |
|
||||
| `TELEGRAM_TOKEN` | Telegram bot token (from BotFather) — required for Telegram routes |
|
||||
| `TELEGRAM_WEBHOOK_SECRET` | Optional secret token for `POST /telegram/webhook` verification |
|
||||
| `TELEGRAM_RICH_HEADER_HOST` | Optional base URL overriding the built-in `GET /api/richheader` for Telegram avatar cards |
|
||||
| `BASE_URL` | Public URL for OAuth callbacks and the Telegram webhook sync |
|
||||
| `ADMIN_USER_IDS` | Comma-separated GitHub user IDs (or logins) allowed to access `/admin` |
|
||||
| `ALLOW_SELF_SIGNUP` | `1` to give access-less GitHub users a personal group on first login (default off) |
|
||||
| `AUDIT_RETENTION_DAYS` | Audit-log retention in days for the scheduled cleanup (default 90) |
|
||||
| `NUXT_PUBLIC_DOCS_URL` | Optional docs site URL used by the landing page |
|
||||
| `NUXT_PUBLIC_REPO_URL` | Optional GitHub repo URL used by the landing page |
|
||||
| `NUXT_PUBLIC_LEGAL_CONTACT` | Optional contact shown on `/terms` and `/privacy` |
|
||||
|
||||
### Routes
|
||||
|
||||
Routes are stored in KV (`config:routes` as JSON). There are **no default routes** — every route (including its target) must be defined explicitly, either via the Web UI (`/admin`) or by storing a JSON array in KV. A route may carry multiple `targets`, so one rule can forward to several channels at once:
|
||||
|
||||
```json
|
||||
[
|
||||
{
|
||||
"id": "all-push",
|
||||
"name": "Push Events",
|
||||
"enabled": true,
|
||||
"groupId": "default",
|
||||
"filters": [{ "type": "event", "match": "push" }],
|
||||
"stop": true,
|
||||
"targets": [
|
||||
{ "platform": "discord", "channelId": "CHANNEL_ID" },
|
||||
{ "platform": "telegram", "chatId": "-1001234567890" }
|
||||
]
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
`target.platform` selects the push target: `discord` (default) or `telegram`. Discord targets require `target.channelId` (optional `threadId` for a thread); Telegram targets require `target.chatId` (optional `topicId` for a topic). Routes belong to **groups** (KV `config:groups`) that scope admin access and can restrict which org/user events flow in. See the [Routes & Targets](https://webhooker.docs.worldexecute.me/guide/routes) and [Groups & Access Control](https://webhooker.docs.worldexecute.me/guide/groups) guides for the full schema.
|
||||
|
||||
### Web UI (`/admin`)
|
||||
|
||||
The built-in config console lets you manage routes and groups in the browser (add / edit / delete / toggle / reorder), inspect send logs, manage group members and invite links, and read the audit log — no KV access needed:
|
||||
|
||||
1. Set `ADMIN_USER_IDS` to the GitHub user IDs (or logins) allowed to manage the console, e.g. `ADMIN_USER_IDS=12345,RhenCloud`.
|
||||
2. Visit `/admin` and sign in with GitHub. Users with no access get `403` — unless `ALLOW_SELF_SIGNUP=1` (they receive a personal group) or they follow a group invite link.
|
||||
3. Changes are written to KV immediately and picked up by the webhook pipeline.
|
||||
|
||||
Sign out at `/admin/logout`. Every group has `members` with a role (`owner` / `admin` / `viewer`); all admin operations are recorded in the D1 `audit_logs` table.
|
||||
|
||||
### Filter Types
|
||||
|
||||
Every filter supports plain text, `*`/`?` globs, and `/regex/` patterns (case-insensitive); set `exclude: true` to invert. See the [Filter Tutorial](https://webhooker.docs.worldexecute.me/guide/filters) for the pattern syntax and the full filter reference.
|
||||
|
||||
## API
|
||||
|
||||
### Health
|
||||
|
||||
- `GET /health` — Returns `{"status": "ok"}`
|
||||
|
||||
### OAuth
|
||||
|
||||
- `GET /auth/github` — Start GitHub OAuth flow (redirects to GitHub)
|
||||
- `GET /auth/github/callback` — OAuth callback (exchanges code for token; admin session / Discord link / Telegram link)
|
||||
- `DELETE /auth/token/:userId` — Revoke user token
|
||||
|
||||
### Actions (require `Authorization: Bearer <token>` header)
|
||||
|
||||
- `POST /api/comment` — Create issue comment
|
||||
- `POST /api/merge` — Merge pull request
|
||||
- `POST /api/close` — Close pull request
|
||||
- `POST /api/react` — Add reaction to issue
|
||||
|
||||
### Admin (require admin OAuth session)
|
||||
|
||||
- `GET /admin` — Config console UI
|
||||
- `GET /admin/login` — Start admin sign-in (GitHub OAuth)
|
||||
- `GET /admin/logout` — Sign out
|
||||
- `GET /admin/invite?token=…` — Accept a group invite (browser page)
|
||||
- `GET /admin/api/routes` — List routes
|
||||
- `PUT /admin/api/routes` — Replace routes (owner/admin per group)
|
||||
- `GET /admin/api/groups` — List groups + your role in each
|
||||
- `PUT /admin/api/groups` — Replace groups (super: all; owner: own groups only)
|
||||
- `GET /admin/api/groups/:groupId/routes` — List a group's routes
|
||||
- `PUT /admin/api/groups/:groupId/routes` — Replace a group's routes
|
||||
- `POST /admin/api/groups/:groupId/invites` — Create invite link (owner)
|
||||
- `GET /admin/api/groups/:groupId/invites` — List pending invites (owner)
|
||||
- `DELETE /admin/api/invites/:token` — Revoke an invite (owner)
|
||||
- `GET /admin/api/audit` — Audit log (scoped)
|
||||
- `GET /admin/api/me` — Current session / scope / roles
|
||||
- `GET /admin/api/logs` — Send logs (scoped)
|
||||
- `GET /admin/api/logs/:id` — Single send-log entry
|
||||
|
||||
## Setup Guides
|
||||
|
||||
- **GitHub App** — create the app, subscribe to events, configure OAuth, and set the _Setup URL_ for tenant isolation: see [GitHub App Setup](https://webhooker.docs.worldexecute.me/guide/deployment#github-app-setup)
|
||||
- **Discord bot** — create the bot, invite it with `applications.commands` (combined permission integer `274877910016`), and configure the Interactions Endpoint: see [Discord Bot Setup](https://webhooker.docs.worldexecute.me/guide/deployment#discord-bot-setup). The bot never connects to the Discord Gateway, so it shows as **offline** — messaging is unaffected (always REST).
|
||||
- **Telegram bot** — create the bot with [@BotFather](https://t.me/BotFather), set `TELEGRAM_TOKEN` (optional `TELEGRAM_WEBHOOK_SECRET`); the webhook is synced automatically by the scheduled trigger: see [Telegram Bot Setup](https://webhooker.docs.worldexecute.me/guide/deployment#telegram-bot-setup)
|
||||
- **Deployment** — KV namespace, D1 database + migrations, secrets, and deploy: see the [Deployment guide](https://webhooker.docs.worldexecute.me/guide/deployment)
|
||||
|
||||
### Bot Commands (comment on GitHub as yourself)
|
||||
|
||||
The bot registers native **slash** and **message context-menu** commands, synced by the scheduled trigger (every 5 minutes): per-guild for instant availability, and globally (24h dedup, ~1h propagation). After `/gh login` you can comment on issues/PRs as yourself, edit/delete your comments, and merge/close PRs via buttons — all replies are ephemeral and GitHub enforces permission.
|
||||
|
||||
```
|
||||
/gh login /gh logout
|
||||
/gh comment add|edit|del link:<url> (or right-click a notification → Apps → GitHub: 添加/编辑/删除评论)
|
||||
```
|
||||
|
||||
See the full reference in the [Bot Commands guide](https://webhooker.docs.worldexecute.me/guide/commands).
|
||||
|
||||
## Development
|
||||
|
||||
```bash
|
||||
bun run dev # Nuxt dev server (HMR)
|
||||
bun run typecheck # Type checking
|
||||
bun run lint # ESLint
|
||||
bun test # Unit tests
|
||||
```
|
||||
|
||||
## Supported Events
|
||||
|
||||
28 event formatters (push, pull_request, issues, workflow_run, release, ...) plus `custom` webhooks; unsupported events fall back to a generic formatter. See the full table with embed highlights in [Supported Events](https://webhooker.docs.worldexecute.me/events/supported).
|
||||
|
||||
## License
|
||||
|
||||
|
||||
## API
|
||||
|
||||
### Health
|
||||
|
||||
- `GET /health` — Returns `{"status": "ok"}`
|
||||
|
||||
### OAuth
|
||||
|
||||
- `GET /auth/github` — Start GitHub OAuth flow (redirects to GitHub)
|
||||
- `GET /auth/github/callback` — OAuth callback (exchanges code for token; admin session / Discord link / Telegram link)
|
||||
- `DELETE /auth/token/:userId` — Revoke user token
|
||||
|
||||
### Actions (require `Authorization: Bearer <token>` header)
|
||||
|
||||
- `POST /api/comment` — Create issue comment
|
||||
- `POST /api/merge` — Merge pull request
|
||||
- `POST /api/close` — Close pull request
|
||||
- `POST /api/react` — Add reaction to issue
|
||||
|
||||
### Admin (require admin OAuth session)
|
||||
|
||||
- `GET /admin` — Config console UI
|
||||
- `GET /admin/login` — Start admin sign-in (GitHub OAuth)
|
||||
- `GET /admin/logout` — Sign out
|
||||
- `GET /admin/invite?token=…` — Accept a group invite (browser page)
|
||||
- `GET /admin/api/routes` — List routes
|
||||
- `PUT /admin/api/routes` — Replace routes (owner/admin per group)
|
||||
- `GET /admin/api/groups` — List groups + your role in each
|
||||
- `PUT /admin/api/groups` — Replace groups (super: all; owner: own groups only)
|
||||
- `GET /admin/api/groups/:groupId/routes` — List a group's routes
|
||||
- `PUT /admin/api/groups/:groupId/routes` — Replace a group's routes
|
||||
- `POST /admin/api/groups/:groupId/invites` — Create invite link (owner)
|
||||
- `GET /admin/api/groups/:groupId/invites` — List pending invites (owner)
|
||||
- `DELETE /admin/api/invites/:token` — Revoke an invite (owner)
|
||||
- `GET /admin/api/audit` — Audit log (scoped)
|
||||
- `GET /admin/api/me` — Current session / scope / roles
|
||||
- `GET /admin/api/logs` — Send logs (scoped)
|
||||
- `GET /admin/api/logs/:id` — Single send-log entry
|
||||
|
||||
## Setup Guides
|
||||
|
||||
- **GitHub App** — create the app, subscribe to events, configure OAuth, and set the _Setup URL_ for tenant isolation: see [GitHub App Setup](https://webhooker.docs.worldexecute.me/guide/deployment#github-app-setup)
|
||||
- **Discord bot** — create the bot, invite it with `applications.commands` (combined permission integer `274877910016`), and configure the Interactions Endpoint: see [Discord Bot Setup](https://webhooker.docs.worldexecute.me/guide/deployment#discord-bot-setup). The bot never connects to the Discord Gateway, so it shows as **offline** — messaging is unaffected (always REST).
|
||||
- **Telegram bot** — create the bot with [@BotFather](https://t.me/BotFather), set `TELEGRAM_TOKEN` (optional `TELEGRAM_WEBHOOK_SECRET`); the webhook is synced automatically by the scheduled trigger: see [Telegram Bot Setup](https://webhooker.docs.worldexecute.me/guide/deployment#telegram-bot-setup)
|
||||
- **Deployment** — KV namespace, D1 database + migrations, secrets, and deploy: see the [Deployment guide](https://webhooker.docs.worldexecute.me/guide/deployment)
|
||||
|
||||
### Bot Commands (comment on GitHub as yourself)
|
||||
|
||||
The bot registers native **slash** and **message context-menu** commands, synced by the scheduled trigger (every 5 minutes): per-guild for instant availability, and globally (24h dedup, ~1h propagation). After `/gh login` you can comment on issues/PRs as yourself, edit/delete your comments, and merge/close PRs via buttons — all replies are ephemeral and GitHub enforces permission.
|
||||
|
||||
```
|
||||
/gh login /gh logout
|
||||
/gh comment add|edit|del link:<url> (or right-click a notification → Apps → GitHub: 添加/编辑/删除评论)
|
||||
```
|
||||
|
||||
See the full reference in the [Bot Commands guide](https://webhooker.docs.worldexecute.me/guide/commands).
|
||||
|
||||
## Development
|
||||
|
||||
```bash
|
||||
bun run dev # Nuxt dev server (HMR)
|
||||
bun run typecheck # Type checking
|
||||
bun run lint # ESLint
|
||||
bun test # Unit tests
|
||||
```
|
||||
|
||||
## Supported Events
|
||||
|
||||
28 event formatters (push, pull_request, issues, workflow_run, release, ...) plus `custom` webhooks; unsupported events fall back to a generic formatter. See the full table with embed highlights in [Supported Events](https://webhooker.docs.worldexecute.me/events/supported).
|
||||
|
||||
## License
|
||||
|
||||
MIT
|
||||
|
|
|
|||
|
|
@ -258,12 +258,7 @@ if (view.value === null) {
|
|||
throw createError({ statusCode: 404, statusMessage: "Page not found", fatal: false });
|
||||
}
|
||||
|
||||
const {
|
||||
logs,
|
||||
loading: logsLoading,
|
||||
error: logsError,
|
||||
load: loadLogs,
|
||||
} = useSendLogs();
|
||||
const { logs, loading: logsLoading, error: logsError, load: loadLogs } = useSendLogs();
|
||||
const {
|
||||
entries: auditEntries,
|
||||
loading: auditLoading,
|
||||
|
|
|
|||
|
|
@ -13,22 +13,22 @@
|
|||
<div class="row2">
|
||||
<div class="field">
|
||||
<label>{{ t("groupEditor.name") }}</label>
|
||||
<input
|
||||
v-model="form.name"
|
||||
type="text"
|
||||
class="input"
|
||||
:placeholder="t('groupEditor.namePlaceholder')"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>{{ t("groupEditor.id") }}</label>
|
||||
<input
|
||||
v-model="form.id"
|
||||
type="text"
|
||||
class="input"
|
||||
:placeholder="t('groupEditor.idPlaceholder')"
|
||||
required
|
||||
<input
|
||||
v-model="form.name"
|
||||
type="text"
|
||||
class="input"
|
||||
:placeholder="t('groupEditor.namePlaceholder')"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>{{ t("groupEditor.id") }}</label>
|
||||
<input
|
||||
v-model="form.id"
|
||||
type="text"
|
||||
class="input"
|
||||
:placeholder="t('groupEditor.idPlaceholder')"
|
||||
required
|
||||
/>
|
||||
<div class="hint">
|
||||
{{ isEdit ? t("groupEditor.renameHint") : t("groupEditor.idHint") }}
|
||||
|
|
@ -38,11 +38,11 @@
|
|||
<div class="row2">
|
||||
<div class="field">
|
||||
<label>{{ t("groupEditor.language") }}</label>
|
||||
<input
|
||||
v-model="form.lang"
|
||||
type="text"
|
||||
class="input"
|
||||
:placeholder="t('groupEditor.langPlaceholder')"
|
||||
<input
|
||||
v-model="form.lang"
|
||||
type="text"
|
||||
class="input"
|
||||
:placeholder="t('groupEditor.langPlaceholder')"
|
||||
/>
|
||||
<div class="hint">{{ t("groupEditor.langHint") }}</div>
|
||||
</div>
|
||||
|
|
@ -71,20 +71,20 @@
|
|||
>{{ t("groupEditor.owners") }}
|
||||
<span class="lbl-note">{{ t("groupEditor.ownersNote") }}</span></label
|
||||
>
|
||||
<input
|
||||
v-model="form.owners"
|
||||
type="text"
|
||||
class="input"
|
||||
:placeholder="t('groupEditor.ownersPlaceholder')"
|
||||
/>
|
||||
<div class="hint">{{ t("groupEditor.ownersHint") }}</div>
|
||||
</div>
|
||||
<div v-else class="field">
|
||||
<label
|
||||
>{{ t("groupEditor.owners") }}
|
||||
<span class="lbl-note">{{ t("groupEditor.ownersSuperOnly") }}</span></label
|
||||
>
|
||||
<input v-model="ownersReadonly" type="text" class="input opacity-60" disabled />
|
||||
<input
|
||||
v-model="form.owners"
|
||||
type="text"
|
||||
class="input"
|
||||
:placeholder="t('groupEditor.ownersPlaceholder')"
|
||||
/>
|
||||
<div class="hint">{{ t("groupEditor.ownersHint") }}</div>
|
||||
</div>
|
||||
<div v-else class="field">
|
||||
<label
|
||||
>{{ t("groupEditor.owners") }}
|
||||
<span class="lbl-note">{{ t("groupEditor.ownersSuperOnly") }}</span></label
|
||||
>
|
||||
<input v-model="ownersReadonly" type="text" class="input opacity-60" disabled />
|
||||
</div>
|
||||
<div class="field">
|
||||
<label
|
||||
|
|
@ -116,12 +116,12 @@
|
|||
>{{ t("groupEditor.installationId") }}
|
||||
<span class="lbl-note">{{ t("groupEditor.installationIdNote") }}</span></label
|
||||
>
|
||||
<input
|
||||
v-model="form.installationId"
|
||||
type="text"
|
||||
class="input"
|
||||
inputmode="numeric"
|
||||
:placeholder="t('groupEditor.installationIdPlaceholder')"
|
||||
<input
|
||||
v-model="form.installationId"
|
||||
type="text"
|
||||
class="input"
|
||||
inputmode="numeric"
|
||||
:placeholder="t('groupEditor.installationIdPlaceholder')"
|
||||
/>
|
||||
<div class="hint">{{ t("groupEditor.installationIdHint") }}</div>
|
||||
</div>
|
||||
|
|
@ -130,38 +130,38 @@
|
|||
>{{ t("groupEditor.logTarget") }}
|
||||
<span class="lbl-note">{{ t("groupEditor.logTargetNote") }}</span></label
|
||||
>
|
||||
<select v-model="form.logPlatform" class="select">
|
||||
<option value="">{{ t("groupEditor.logDisabled") }}</option>
|
||||
<option value="discord">Discord</option>
|
||||
<option value="telegram">Telegram</option>
|
||||
</select>
|
||||
<template v-if="form.logPlatform === 'discord'">
|
||||
<input
|
||||
v-model="form.logChannelId"
|
||||
type="text"
|
||||
class="input mt-2"
|
||||
:placeholder="t('routeEditor.channelPlaceholder')"
|
||||
/>
|
||||
<input
|
||||
v-model="form.logThreadId"
|
||||
type="text"
|
||||
class="input mt-2"
|
||||
:placeholder="t('routeEditor.threadPlaceholder')"
|
||||
/>
|
||||
</template>
|
||||
<template v-else-if="form.logPlatform === 'telegram'">
|
||||
<input
|
||||
v-model="form.logChatId"
|
||||
type="text"
|
||||
class="input mt-2"
|
||||
:placeholder="t('routeEditor.chatPlaceholder')"
|
||||
/>
|
||||
<input
|
||||
v-model="form.logTopicId"
|
||||
type="text"
|
||||
class="input mt-2"
|
||||
:placeholder="t('routeEditor.topicPlaceholder')"
|
||||
/>
|
||||
<select v-model="form.logPlatform" class="select">
|
||||
<option value="">{{ t("groupEditor.logDisabled") }}</option>
|
||||
<option value="discord">Discord</option>
|
||||
<option value="telegram">Telegram</option>
|
||||
</select>
|
||||
<template v-if="form.logPlatform === 'discord'">
|
||||
<input
|
||||
v-model="form.logChannelId"
|
||||
type="text"
|
||||
class="input mt-2"
|
||||
:placeholder="t('routeEditor.channelPlaceholder')"
|
||||
/>
|
||||
<input
|
||||
v-model="form.logThreadId"
|
||||
type="text"
|
||||
class="input mt-2"
|
||||
:placeholder="t('routeEditor.threadPlaceholder')"
|
||||
/>
|
||||
</template>
|
||||
<template v-else-if="form.logPlatform === 'telegram'">
|
||||
<input
|
||||
v-model="form.logChatId"
|
||||
type="text"
|
||||
class="input mt-2"
|
||||
:placeholder="t('routeEditor.chatPlaceholder')"
|
||||
/>
|
||||
<input
|
||||
v-model="form.logTopicId"
|
||||
type="text"
|
||||
class="input mt-2"
|
||||
:placeholder="t('routeEditor.topicPlaceholder')"
|
||||
/>
|
||||
</template>
|
||||
<div class="hint">{{ t("groupEditor.logTargetHint") }}</div>
|
||||
</div>
|
||||
|
|
@ -322,4 +322,3 @@ function save(): void {
|
|||
});
|
||||
}
|
||||
</script>
|
||||
|
||||
|
|
|
|||
|
|
@ -57,7 +57,10 @@ const updated = computed(() => "2026-08-01");
|
|||
const t = (zh: string, en: string): string => (lang.value === "zh" ? zh : en);
|
||||
const q = (p: string): string => `${p}?lang=${lang.value}`;
|
||||
const altLink = computed(() =>
|
||||
q(props.active === "terms" ? "/terms" : "/privacy").replace(`lang=${lang.value}`, `lang=${altLang.value}`),
|
||||
q(props.active === "terms" ? "/terms" : "/privacy").replace(
|
||||
`lang=${lang.value}`,
|
||||
`lang=${altLang.value}`,
|
||||
),
|
||||
);
|
||||
|
||||
useHead({ title: `${props.title} · WebHooker` });
|
||||
|
|
|
|||
|
|
@ -22,7 +22,11 @@
|
|||
v-for="it in items"
|
||||
:key="it.label"
|
||||
class="flex items-center gap-4 rounded-[14px] border border-border bg-surface px-5 py-[18px] text-text no-underline shadow-card transition-all duration-150 hover:-translate-y-0.5 hover:border-border-strong hover:shadow-card-hover"
|
||||
:class="it.primary ? 'border-accent bg-accent shadow-none hover:border-accent hover:shadow-accent-lg' : ''"
|
||||
:class="
|
||||
it.primary
|
||||
? 'border-accent bg-accent shadow-none hover:border-accent hover:shadow-accent-lg'
|
||||
: ''
|
||||
"
|
||||
:href="it.href"
|
||||
:target="it.external ? '_blank' : undefined"
|
||||
:rel="it.external ? 'noopener noreferrer' : undefined"
|
||||
|
|
@ -68,7 +72,9 @@ const config = useRuntimeConfig();
|
|||
const lang = computed(() => (route.query.lang === "en" ? "en" : "zh"));
|
||||
const altLang = computed(() => (lang.value === "zh" ? "en" : "zh"));
|
||||
const repo = computed(() => (config.public.repoUrl as string) || DEFAULT_REPO);
|
||||
const docsBase = computed(() => ((config.public.docsUrl as string) || DEFAULT_DOCS).replace(/\/+$/, ""));
|
||||
const docsBase = computed(() =>
|
||||
((config.public.docsUrl as string) || DEFAULT_DOCS).replace(/\/+$/, ""),
|
||||
);
|
||||
|
||||
const t = (zh: string, en: string): string => (lang.value === "zh" ? zh : en);
|
||||
|
||||
|
|
@ -119,7 +125,8 @@ const ICON_PATHS: Record<string, string> = {
|
|||
docs: '<path d="M4 4a2 2 0 0 1 2-2h7l5 5v13a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V4z"/><path d="M13 2v5h5"/><path d="M8 12h8M8 16h6"/>',
|
||||
github:
|
||||
'<path d="M12 2a10 10 0 0 0-3.16 19.49c.5.09.68-.22.68-.48v-1.7c-2.78.6-3.37-1.34-3.37-1.34-.45-1.16-1.11-1.47-1.11-1.47-.91-.62.07-.6.07-.6 1 .07 1.53 1.03 1.53 1.03.9 1.53 2.36 1.09 2.94.83.09-.65.35-1.09.63-1.34-2.22-.25-4.55-1.11-4.55-4.94 0-1.09.39-1.98 1.03-2.68-.1-.25-.45-1.27.1-2.65 0 0 .84-.27 2.75 1.02a9.5 9.5 0 0 1 5 0c1.91-1.29 2.75-1.02 2.75-1.02.55 1.38.2 2.4.1 2.65.64.7 1.03 1.59 1.03 2.68 0 3.84-2.34 4.68-4.57 4.93.36.31.68.92.68 1.85v2.74c0 .27.18.58.69.48A10 10 0 0 0 12 2z"/>',
|
||||
terms: '<path d="M9 12h6M9 16h6M9 8h2"/><path d="M6 2h9l5 5v13a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2z"/>',
|
||||
terms:
|
||||
'<path d="M9 12h6M9 16h6M9 8h2"/><path d="M6 2h9l5 5v13a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2z"/>',
|
||||
privacy:
|
||||
'<path d="M12 2l7 3v6c0 5-3.5 8.5-7 10-3.5-1.5-7-5-7-10V5l7-3z"/><path d="M9 12l2 2 4-4"/>',
|
||||
login:
|
||||
|
|
@ -143,4 +150,3 @@ useHead({
|
|||
],
|
||||
});
|
||||
</script>
|
||||
|
||||
|
|
|
|||
|
|
@ -2,13 +2,13 @@
|
|||
|
||||
This page is the reference for secrets and the Web UI. Core concepts live in dedicated pages:
|
||||
|
||||
| Topic | Page |
|
||||
|---------------------------------------------------|----------------------------------------------------------------------|
|
||||
| Routes, targets, `fallback` / `stop`, role pings | [Routes & Targets](./routes) |
|
||||
| Groups, roles, invites, self sign-up, log channel | [Groups & Access Control](./groups) |
|
||||
| Webhook providers, per-group ingress, custom | [Webhook Ingress & Tenancy](./ingress) |
|
||||
| KV / D1 key layout | [Storage Layout](./storage) |
|
||||
| Filters (pattern syntax reference) | [Filter Types](#filter-types) below / [Filter Tutorial](./filters) |
|
||||
| Topic | Page |
|
||||
| ------------------------------------------------- | ------------------------------------------------------------------ |
|
||||
| Routes, targets, `fallback` / `stop`, role pings | [Routes & Targets](./routes) |
|
||||
| Groups, roles, invites, self sign-up, log channel | [Groups & Access Control](./groups) |
|
||||
| Webhook providers, per-group ingress, custom | [Webhook Ingress & Tenancy](./ingress) |
|
||||
| KV / D1 key layout | [Storage Layout](./storage) |
|
||||
| Filters (pattern syntax reference) | [Filter Types](#filter-types) below / [Filter Tutorial](./filters) |
|
||||
|
||||
## Secrets
|
||||
|
||||
|
|
@ -17,7 +17,7 @@ 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 |
|
||||
| `GITEA_WEBHOOK_SECRET` | Webhook secret from your Gitea instance (only to receive Gitea webhooks) |
|
||||
| `GITHUB_CLIENT_ID` | OAuth client ID from App settings |
|
||||
|
|
@ -35,7 +35,7 @@ WebHooker requires several secrets to function. For local development, store the
|
|||
### Optional Secrets
|
||||
|
||||
| Variable | Description | Default |
|
||||
|-----------------------------|-----------------------------------------------------------------------------------------------------------------------------|-----------------------------------|
|
||||
| --------------------------- | --------------------------------------------------------------------------------------------------------------------------- | --------------------------------- |
|
||||
| `DISCORD_PUBLIC_KEY` | Discord application public key (Developer Portal) — required for interactions | Unset → interactions return `401` |
|
||||
| `DISCORD_APPLICATION_ID` | Discord application id; auto-resolved when omitted | Auto-resolved |
|
||||
| `TELEGRAM_WEBHOOK_SECRET` | Secret token for `POST /telegram/webhook` verification (X-Telegram-Bot-Api-Secret-Token) | Disabled (no verification) |
|
||||
|
|
@ -67,7 +67,7 @@ All management endpoints (`/admin/api/*`) are documented in the [Admin API](../a
|
|||
See the [Filter Tutorial](./filters) for a hands-on guide with worked examples.
|
||||
|
||||
| Type | Matches | Example |
|
||||
|-----------|----------------------|------------------------------------|
|
||||
| --------- | -------------------- | ---------------------------------- |
|
||||
| `event` | GitHub event name | `push`, `pull_*`, `pull_request` |
|
||||
| `repo` | Repository full name | `org/repo`, `org/*` |
|
||||
| `actor` | Sender login | `username`, `[bot]`, `*[bot]` |
|
||||
|
|
|
|||
|
|
@ -1,263 +1,263 @@
|
|||
# Filter Tutorial
|
||||
|
||||
Filters decide which webhook events a [route](./routes) forwards. A route fires only when **every** filter in its `filters` array matches (AND logic). This page is a hands-on tutorial: it explains how each filter type behaves and how to combine them into real-world routing rules.
|
||||
|
||||
See [Filter Types](./configuration#filter-types) in the configuration guide for the reference table, and [Supported Events](../events/supported) for the full event list.
|
||||
|
||||
## How Matching Works
|
||||
|
||||
- All filters in a route must match, otherwise the route is skipped.
|
||||
- Each filter matches the event against one field of the webhook payload.
|
||||
- Matching is **case-insensitive** for every filter type.
|
||||
- The `match` value accepts either a single string or an array of strings. An array behaves as OR — the filter matches if any of its values match.
|
||||
- Setting `"exclude": true` inverts the result (NOT logic): the filter matches when the value does **not** match.
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "event",
|
||||
"match": ["push", "pull_request"],
|
||||
"exclude": false
|
||||
}
|
||||
```
|
||||
|
||||
The route above matches both `push` and `pull_request` events.
|
||||
|
||||
## Pattern Syntax
|
||||
|
||||
Every filter type shares the same three pattern forms:
|
||||
|
||||
| Pattern | Meaning |
|
||||
| ---------------------- | -------------------------------------------------------------- |
|
||||
| `plain text` | Field filters: **exact** match. `keyword`: search anywhere. |
|
||||
| `*` / `?` | **Glob wildcards** — `*` any run, `?` one char. |
|
||||
| `/regular expression/` | Compiled as a **regular expression** (case-insensitive flag). |
|
||||
|
||||
- On field filters (`event`/`repo`/`actor`/`action`/`branch`), plain text and globs match the whole value; on `keyword` they search anywhere in the payload.
|
||||
- Regexes always search: `/^feat/` matches values *starting* with `feat`, `/feat/` matches anywhere.
|
||||
|
||||
Examples:
|
||||
|
||||
```json
|
||||
{ "type": "event", "match": "pull_*" }
|
||||
```
|
||||
|
||||
Matches `pull_request`, `pull_request_review`, `pull_request_review_comment`, ...
|
||||
|
||||
```json
|
||||
{ "type": "repo", "match": "myorg/*" }
|
||||
```
|
||||
|
||||
```json
|
||||
{ "type": "branch", "match": "feature-?" }
|
||||
```
|
||||
|
||||
Matches `feature-x`, `feature-1`, but not `feature-xy`.
|
||||
|
||||
```json
|
||||
{ "type": "branch", "match": "/^feat/" }
|
||||
```
|
||||
|
||||
Matches any branch whose name starts with `feat`.
|
||||
|
||||
> [!TIP]
|
||||
> Globs and regular expressions are case-insensitive too, and `*` matches across `/` in repo names (`myorg/*` also matches `myorg/sub/backend`).
|
||||
|
||||
## Filter Types in Depth
|
||||
|
||||
### `event` — Event type
|
||||
|
||||
Matches the GitHub event name, e.g. `push`, `pull_request`, `issues`, `release`. Use this as the backbone of every route.
|
||||
|
||||
```json
|
||||
{ "type": "event", "match": "release" }
|
||||
```
|
||||
|
||||
Match several events with an array:
|
||||
|
||||
```json
|
||||
{ "type": "event", "match": ["create", "delete"] }
|
||||
```
|
||||
|
||||
### `repo` — Repository
|
||||
|
||||
Matches the repository **full name** (`owner/name`). Case-insensitive.
|
||||
|
||||
```json
|
||||
{ "type": "repo", "match": "myorg/backend" }
|
||||
```
|
||||
|
||||
Route multiple repositories to one channel:
|
||||
|
||||
```json
|
||||
{ "type": "repo", "match": ["myorg/backend", "myorg/frontend"] }
|
||||
```
|
||||
|
||||
### `actor` — Sender
|
||||
|
||||
Matches the **sender's GitHub login** that triggered the event (`sender.login` in the payload). Useful for ignoring bots.
|
||||
|
||||
```json
|
||||
{ "type": "actor", "match": "dependabot[bot]", "exclude": true }
|
||||
```
|
||||
|
||||
The route above fires for every event **except** those triggered by Dependabot.
|
||||
|
||||
### `action` — Event action
|
||||
|
||||
Matches the `action` field of the payload, e.g. `opened`, `closed`, `published`, `completed`. Not all events carry an action — see [Filter Compatibility](../events/supported#filter-compatibility). Combine it with `event` to narrow down a specific lifecycle step:
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "event",
|
||||
"match": "pull_request",
|
||||
"exclude": false
|
||||
},
|
||||
{
|
||||
"type": "action",
|
||||
"match": ["opened", "reopened"]
|
||||
}
|
||||
```
|
||||
|
||||
This fires when a pull request is opened or reopened (and not on merge/close/edit).
|
||||
|
||||
### `branch` — Branch
|
||||
|
||||
Matches the branch involved in the event. What counts as "the branch" depends on the event type:
|
||||
|
||||
| Event | Branch extracted |
|
||||
| --------------------------- | ------------------------------------------- |
|
||||
| `push` | The branch that was pushed to |
|
||||
| `pull_request` (and review) | The pull request's **head** (source) branch |
|
||||
| `create` / `delete` | The created/deleted branch or tag |
|
||||
| `workflow_run` | The `head_branch` the workflow ran on |
|
||||
| `workflow_job` | The `head_branch` the job ran on |
|
||||
| `check_suite` | The `head_branch` of the check suite |
|
||||
| `deployment` | The deployment ref (strips `refs/heads/`) |
|
||||
| `code_scanning_alert` | The branch the alert belongs to |
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "event",
|
||||
"match": "push"
|
||||
},
|
||||
{
|
||||
"type": "branch",
|
||||
"match": "main"
|
||||
}
|
||||
```
|
||||
|
||||
Fires for pushes to `main` only. To watch several long-lived branches:
|
||||
|
||||
```json
|
||||
{ "type": "branch", "match": ["main", "develop"] }
|
||||
```
|
||||
|
||||
> [!NOTE]
|
||||
> `branch` matching is case-insensitive. Use globs (`feature/*`) or a `//`-wrapped regex (`/^release-/`) for prefix or wildcard-style matching.
|
||||
|
||||
### `keyword` — Text in the payload
|
||||
|
||||
Matches against the **full JSON payload**, lowercased. It is the most flexible filter: plain text searches anywhere, `*`/`?` globs search with wildcards, and `//`-wrapped patterns are compiled as regular expressions (with the `i` flag).
|
||||
|
||||
```json
|
||||
{ "type": "keyword", "match": "deploy" }
|
||||
```
|
||||
|
||||
Fires when the payload contains `deploy` anywhere. Because the payload is lowercased, this matches `Deploy`, `DEPLOY`, etc.
|
||||
|
||||
```json
|
||||
{ "type": "keyword", "match": "*release-*" }
|
||||
```
|
||||
|
||||
```json
|
||||
{ "type": "keyword", "match": "/^(fix|hotfix)/" }
|
||||
```
|
||||
|
||||
```json
|
||||
{ "type": "keyword", "match": "/release-[0-9]+/" }
|
||||
```
|
||||
|
||||
Behavior details:
|
||||
|
||||
- Patterns longer than 200 characters are **not** compiled as glob/regex and fall back to plain matching.
|
||||
- A `//`-wrapped pattern that is not a valid regex matches **nothing** (the filter stays false) rather than erroring.
|
||||
- To search for text that is a glob or regex special character (e.g. `v1.2.3`), rely on the plain-text form — a pattern without `*`, `?`, or `//` wrapping matches literally.
|
||||
- The search covers the **entire** payload: commit messages, PR titles and bodies, labels, refs, even repository and sender names.
|
||||
|
||||
### Combining `exclude` with `keyword`
|
||||
|
||||
Just like the other filters, `exclude` inverts the keyword match:
|
||||
|
||||
```json
|
||||
{ "type": "keyword", "match": "/wip|draft/", "exclude": true }
|
||||
```
|
||||
|
||||
Skips events whose payload mentions `wip` or `draft`.
|
||||
|
||||
## Worked Example 1: PR alerts that skip bots and drafts
|
||||
|
||||
Forward pull request activity, but ignore bot authors and draft PRs, to a `#prs` channel:
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "pr-notices",
|
||||
"name": "PR Notices",
|
||||
"enabled": true,
|
||||
"groupId": "eng",
|
||||
"filters": [
|
||||
{ "type": "event", "match": "pull_request" },
|
||||
{ "type": "actor", "match": "dependabot[bot]", "exclude": true },
|
||||
{ "type": "keyword", "match": "\"draft\": true", "exclude": true }
|
||||
],
|
||||
"target": { "channelId": "111111111111111111" }
|
||||
}
|
||||
```
|
||||
|
||||
The `"draft": true` pattern matches the `draft` field that GitHub includes in pull request payloads; combined with `exclude: true` it filters out draft PRs.
|
||||
|
||||
## Worked Example 2: Release-only channel
|
||||
|
||||
Forward only published releases from a specific repo:
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "release-alerts",
|
||||
"name": "Release Alerts",
|
||||
"enabled": true,
|
||||
"groupId": "eng",
|
||||
"filters": [
|
||||
{ "type": "event", "match": "release" },
|
||||
{ "type": "action", "match": "published" },
|
||||
{ "type": "repo", "match": "myorg/backend" }
|
||||
],
|
||||
"target": { "channelId": "222222222222222222" }
|
||||
}
|
||||
```
|
||||
|
||||
## Worked Example 3: CI failures
|
||||
|
||||
Forward workflow runs that ended in failure on any branch, to a `#ci` channel:
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "ci-failures",
|
||||
"name": "CI Failures",
|
||||
"enabled": true,
|
||||
"groupId": "eng",
|
||||
"filters": [
|
||||
{ "type": "event", "match": "workflow_run" },
|
||||
{ "type": "action", "match": "completed" },
|
||||
{ "type": "keyword", "match": "\"conclusion\":\"failure\"" }
|
||||
],
|
||||
"target": { "channelId": "333333333333333333" }
|
||||
}
|
||||
```
|
||||
|
||||
## Common Pitfalls
|
||||
|
||||
- **Wildcards are globs, not regex.** `repo: "myorg/*"` matches any repo under `myorg` (and `myorg/sub/backend`), but `repo: "myorg/.*"` matches literally. Use `//` wrapping for regex: `"/myorg\/.*/"`.
|
||||
- **A `//`-wrapped invalid regex never matches.** Unlike plain text, an unwrapped invalid pattern is matched literally — wrap patterns only when they are real regular expressions.
|
||||
- **An `action` filter on an action-less event never matches.** Check the event has an `action` field first (see [Filter Compatibility](../events/supported#filter-compatibility)).
|
||||
- **`branch` on an event without a branch never matches.** A `branch` filter on an `issues` event will always be false. Use `keyword` if you need branch-like matching there.
|
||||
- **`keyword` searches everything.** Because it scans the whole payload, a pattern like `"fix"` can match commit messages, issue titles, *and* repository names. Be as specific as possible.
|
||||
- **Forgetting `exclude` semantics.** `exclude: true` negates the whole filter — one non-matching value in an array does not "block" the route; the negated filter matches only when *none* of the values match.
|
||||
# Filter Tutorial
|
||||
|
||||
Filters decide which webhook events a [route](./routes) forwards. A route fires only when **every** filter in its `filters` array matches (AND logic). This page is a hands-on tutorial: it explains how each filter type behaves and how to combine them into real-world routing rules.
|
||||
|
||||
See [Filter Types](./configuration#filter-types) in the configuration guide for the reference table, and [Supported Events](../events/supported) for the full event list.
|
||||
|
||||
## How Matching Works
|
||||
|
||||
- All filters in a route must match, otherwise the route is skipped.
|
||||
- Each filter matches the event against one field of the webhook payload.
|
||||
- Matching is **case-insensitive** for every filter type.
|
||||
- The `match` value accepts either a single string or an array of strings. An array behaves as OR — the filter matches if any of its values match.
|
||||
- Setting `"exclude": true` inverts the result (NOT logic): the filter matches when the value does **not** match.
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "event",
|
||||
"match": ["push", "pull_request"],
|
||||
"exclude": false
|
||||
}
|
||||
```
|
||||
|
||||
The route above matches both `push` and `pull_request` events.
|
||||
|
||||
## Pattern Syntax
|
||||
|
||||
Every filter type shares the same three pattern forms:
|
||||
|
||||
| Pattern | Meaning |
|
||||
| ---------------------- | ------------------------------------------------------------- |
|
||||
| `plain text` | Field filters: **exact** match. `keyword`: search anywhere. |
|
||||
| `*` / `?` | **Glob wildcards** — `*` any run, `?` one char. |
|
||||
| `/regular expression/` | Compiled as a **regular expression** (case-insensitive flag). |
|
||||
|
||||
- On field filters (`event`/`repo`/`actor`/`action`/`branch`), plain text and globs match the whole value; on `keyword` they search anywhere in the payload.
|
||||
- Regexes always search: `/^feat/` matches values _starting_ with `feat`, `/feat/` matches anywhere.
|
||||
|
||||
Examples:
|
||||
|
||||
```json
|
||||
{ "type": "event", "match": "pull_*" }
|
||||
```
|
||||
|
||||
Matches `pull_request`, `pull_request_review`, `pull_request_review_comment`, ...
|
||||
|
||||
```json
|
||||
{ "type": "repo", "match": "myorg/*" }
|
||||
```
|
||||
|
||||
```json
|
||||
{ "type": "branch", "match": "feature-?" }
|
||||
```
|
||||
|
||||
Matches `feature-x`, `feature-1`, but not `feature-xy`.
|
||||
|
||||
```json
|
||||
{ "type": "branch", "match": "/^feat/" }
|
||||
```
|
||||
|
||||
Matches any branch whose name starts with `feat`.
|
||||
|
||||
> [!TIP]
|
||||
> Globs and regular expressions are case-insensitive too, and `*` matches across `/` in repo names (`myorg/*` also matches `myorg/sub/backend`).
|
||||
|
||||
## Filter Types in Depth
|
||||
|
||||
### `event` — Event type
|
||||
|
||||
Matches the GitHub event name, e.g. `push`, `pull_request`, `issues`, `release`. Use this as the backbone of every route.
|
||||
|
||||
```json
|
||||
{ "type": "event", "match": "release" }
|
||||
```
|
||||
|
||||
Match several events with an array:
|
||||
|
||||
```json
|
||||
{ "type": "event", "match": ["create", "delete"] }
|
||||
```
|
||||
|
||||
### `repo` — Repository
|
||||
|
||||
Matches the repository **full name** (`owner/name`). Case-insensitive.
|
||||
|
||||
```json
|
||||
{ "type": "repo", "match": "myorg/backend" }
|
||||
```
|
||||
|
||||
Route multiple repositories to one channel:
|
||||
|
||||
```json
|
||||
{ "type": "repo", "match": ["myorg/backend", "myorg/frontend"] }
|
||||
```
|
||||
|
||||
### `actor` — Sender
|
||||
|
||||
Matches the **sender's GitHub login** that triggered the event (`sender.login` in the payload). Useful for ignoring bots.
|
||||
|
||||
```json
|
||||
{ "type": "actor", "match": "dependabot[bot]", "exclude": true }
|
||||
```
|
||||
|
||||
The route above fires for every event **except** those triggered by Dependabot.
|
||||
|
||||
### `action` — Event action
|
||||
|
||||
Matches the `action` field of the payload, e.g. `opened`, `closed`, `published`, `completed`. Not all events carry an action — see [Filter Compatibility](../events/supported#filter-compatibility). Combine it with `event` to narrow down a specific lifecycle step:
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "event",
|
||||
"match": "pull_request",
|
||||
"exclude": false
|
||||
},
|
||||
{
|
||||
"type": "action",
|
||||
"match": ["opened", "reopened"]
|
||||
}
|
||||
```
|
||||
|
||||
This fires when a pull request is opened or reopened (and not on merge/close/edit).
|
||||
|
||||
### `branch` — Branch
|
||||
|
||||
Matches the branch involved in the event. What counts as "the branch" depends on the event type:
|
||||
|
||||
| Event | Branch extracted |
|
||||
| --------------------------- | ------------------------------------------- |
|
||||
| `push` | The branch that was pushed to |
|
||||
| `pull_request` (and review) | The pull request's **head** (source) branch |
|
||||
| `create` / `delete` | The created/deleted branch or tag |
|
||||
| `workflow_run` | The `head_branch` the workflow ran on |
|
||||
| `workflow_job` | The `head_branch` the job ran on |
|
||||
| `check_suite` | The `head_branch` of the check suite |
|
||||
| `deployment` | The deployment ref (strips `refs/heads/`) |
|
||||
| `code_scanning_alert` | The branch the alert belongs to |
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "event",
|
||||
"match": "push"
|
||||
},
|
||||
{
|
||||
"type": "branch",
|
||||
"match": "main"
|
||||
}
|
||||
```
|
||||
|
||||
Fires for pushes to `main` only. To watch several long-lived branches:
|
||||
|
||||
```json
|
||||
{ "type": "branch", "match": ["main", "develop"] }
|
||||
```
|
||||
|
||||
> [!NOTE]
|
||||
> `branch` matching is case-insensitive. Use globs (`feature/*`) or a `//`-wrapped regex (`/^release-/`) for prefix or wildcard-style matching.
|
||||
|
||||
### `keyword` — Text in the payload
|
||||
|
||||
Matches against the **full JSON payload**, lowercased. It is the most flexible filter: plain text searches anywhere, `*`/`?` globs search with wildcards, and `//`-wrapped patterns are compiled as regular expressions (with the `i` flag).
|
||||
|
||||
```json
|
||||
{ "type": "keyword", "match": "deploy" }
|
||||
```
|
||||
|
||||
Fires when the payload contains `deploy` anywhere. Because the payload is lowercased, this matches `Deploy`, `DEPLOY`, etc.
|
||||
|
||||
```json
|
||||
{ "type": "keyword", "match": "*release-*" }
|
||||
```
|
||||
|
||||
```json
|
||||
{ "type": "keyword", "match": "/^(fix|hotfix)/" }
|
||||
```
|
||||
|
||||
```json
|
||||
{ "type": "keyword", "match": "/release-[0-9]+/" }
|
||||
```
|
||||
|
||||
Behavior details:
|
||||
|
||||
- Patterns longer than 200 characters are **not** compiled as glob/regex and fall back to plain matching.
|
||||
- A `//`-wrapped pattern that is not a valid regex matches **nothing** (the filter stays false) rather than erroring.
|
||||
- To search for text that is a glob or regex special character (e.g. `v1.2.3`), rely on the plain-text form — a pattern without `*`, `?`, or `//` wrapping matches literally.
|
||||
- The search covers the **entire** payload: commit messages, PR titles and bodies, labels, refs, even repository and sender names.
|
||||
|
||||
### Combining `exclude` with `keyword`
|
||||
|
||||
Just like the other filters, `exclude` inverts the keyword match:
|
||||
|
||||
```json
|
||||
{ "type": "keyword", "match": "/wip|draft/", "exclude": true }
|
||||
```
|
||||
|
||||
Skips events whose payload mentions `wip` or `draft`.
|
||||
|
||||
## Worked Example 1: PR alerts that skip bots and drafts
|
||||
|
||||
Forward pull request activity, but ignore bot authors and draft PRs, to a `#prs` channel:
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "pr-notices",
|
||||
"name": "PR Notices",
|
||||
"enabled": true,
|
||||
"groupId": "eng",
|
||||
"filters": [
|
||||
{ "type": "event", "match": "pull_request" },
|
||||
{ "type": "actor", "match": "dependabot[bot]", "exclude": true },
|
||||
{ "type": "keyword", "match": "\"draft\": true", "exclude": true }
|
||||
],
|
||||
"target": { "channelId": "111111111111111111" }
|
||||
}
|
||||
```
|
||||
|
||||
The `"draft": true` pattern matches the `draft` field that GitHub includes in pull request payloads; combined with `exclude: true` it filters out draft PRs.
|
||||
|
||||
## Worked Example 2: Release-only channel
|
||||
|
||||
Forward only published releases from a specific repo:
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "release-alerts",
|
||||
"name": "Release Alerts",
|
||||
"enabled": true,
|
||||
"groupId": "eng",
|
||||
"filters": [
|
||||
{ "type": "event", "match": "release" },
|
||||
{ "type": "action", "match": "published" },
|
||||
{ "type": "repo", "match": "myorg/backend" }
|
||||
],
|
||||
"target": { "channelId": "222222222222222222" }
|
||||
}
|
||||
```
|
||||
|
||||
## Worked Example 3: CI failures
|
||||
|
||||
Forward workflow runs that ended in failure on any branch, to a `#ci` channel:
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "ci-failures",
|
||||
"name": "CI Failures",
|
||||
"enabled": true,
|
||||
"groupId": "eng",
|
||||
"filters": [
|
||||
{ "type": "event", "match": "workflow_run" },
|
||||
{ "type": "action", "match": "completed" },
|
||||
{ "type": "keyword", "match": "\"conclusion\":\"failure\"" }
|
||||
],
|
||||
"target": { "channelId": "333333333333333333" }
|
||||
}
|
||||
```
|
||||
|
||||
## Common Pitfalls
|
||||
|
||||
- **Wildcards are globs, not regex.** `repo: "myorg/*"` matches any repo under `myorg` (and `myorg/sub/backend`), but `repo: "myorg/.*"` matches literally. Use `//` wrapping for regex: `"/myorg\/.*/"`.
|
||||
- **A `//`-wrapped invalid regex never matches.** Unlike plain text, an unwrapped invalid pattern is matched literally — wrap patterns only when they are real regular expressions.
|
||||
- **An `action` filter on an action-less event never matches.** Check the event has an `action` field first (see [Filter Compatibility](../events/supported#filter-compatibility)).
|
||||
- **`branch` on an event without a branch never matches.** A `branch` filter on an `issues` event will always be false. Use `keyword` if you need branch-like matching there.
|
||||
- **`keyword` searches everything.** Because it scans the whole payload, a pattern like `"fix"` can match commit messages, issue titles, _and_ repository names. Be as specific as possible.
|
||||
- **Forgetting `exclude` semantics.** `exclude: true` negates the whole filter — one non-matching value in an array does not "block" the route; the negated filter matches only when _none_ of the values match.
|
||||
|
|
|
|||
|
|
@ -63,7 +63,7 @@ curl http://localhost:8787/health
|
|||
## Available Scripts
|
||||
|
||||
| Script | Description |
|
||||
|------------------------|---------------------------------------------|
|
||||
| ---------------------- | ------------------------------------------- |
|
||||
| `bun run dev` | Start Nuxt dev server (HMR) |
|
||||
| `bun run build` | Production build (cloudflare_module preset) |
|
||||
| `bun run deploy` | Deploy to Cloudflare |
|
||||
|
|
|
|||
|
|
@ -2,13 +2,13 @@
|
|||
|
||||
本页是密钥与 Web 控制台的参考。核心概念在独立页面中说明:
|
||||
|
||||
| 主题 | 页面 |
|
||||
|--------------------------------------------------|----------------------------------------------------------------------|
|
||||
| 路由、目标、`fallback` / `stop`、身份组提醒 | [路由与目标](./routes) |
|
||||
| 分组、角色、邀请、自助注册、日志频道 | [分组与访问控制](./groups) |
|
||||
| Webhook 提供方、分组入口、自定义 webhook | [Webhook 接入与租户隔离](./ingress) |
|
||||
| KV / D1 键布局 | [存储布局](./storage) |
|
||||
| 过滤器(模式语法参考) | 下方[过滤器类型](#过滤器类型) / [过滤器教程](./filters) |
|
||||
| 主题 | 页面 |
|
||||
| ------------------------------------------- | ------------------------------------------------------- |
|
||||
| 路由、目标、`fallback` / `stop`、身份组提醒 | [路由与目标](./routes) |
|
||||
| 分组、角色、邀请、自助注册、日志频道 | [分组与访问控制](./groups) |
|
||||
| Webhook 提供方、分组入口、自定义 webhook | [Webhook 接入与租户隔离](./ingress) |
|
||||
| KV / D1 键布局 | [存储布局](./storage) |
|
||||
| 过滤器(模式语法参考) | 下方[过滤器类型](#过滤器类型) / [过滤器教程](./filters) |
|
||||
|
||||
## 密钥
|
||||
|
||||
|
|
@ -16,14 +16,14 @@ 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 机器人 Token |
|
||||
| `TELEGRAM_TOKEN` | Telegram 机器人 Token(BotFather 获取)—— Telegram 路由必需 |
|
||||
| 变量 | 说明 |
|
||||
| ----------------------- | ----------------------------------------------------------- |
|
||||
| `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 机器人 Token |
|
||||
| `TELEGRAM_TOKEN` | Telegram 机器人 Token(BotFather 获取)—— Telegram 路由必需 |
|
||||
|
||||
> [!NOTE]
|
||||
> `GITHUB_APP_ID` 与 `GITHUB_PRIVATE_KEY`(PKCS#8 PEM)用于 GitHub App **安装流程**
|
||||
|
|
@ -33,19 +33,19 @@ WebHooker 的运行需要若干密钥。本地开发时放入 `.dev.vars`,生
|
|||
|
||||
### 可选密钥
|
||||
|
||||
| 变量 | 说明 | 默认值 |
|
||||
|-----------------------------|--------------------------------------------------------------------------------------------------|-------------------------|
|
||||
| `DISCORD_PUBLIC_KEY` | Discord 应用的公钥(开发者门户获取),交互功能必需 | 未设置时交互返回 401 |
|
||||
| `DISCORD_APPLICATION_ID` | Discord 应用 ID;省略时自动获取 | 自动获取 |
|
||||
| `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` |
|
||||
| `BASE_URL` | OAuth 回调的公共 URL | `http://localhost:8787` |
|
||||
| `ADMIN_USER_IDS` | 允许访问 WebUI 的 GitHub 用户 ID(或登录名),逗号分隔 | 未设置时 WebUI 关闭 |
|
||||
| `ALLOW_SELF_SIGNUP` | 开启(`1`/`true`)后,没有任何分组权限的 GitHub 用户首次登录会自动获得个人分组而非 403 | 关闭 |
|
||||
| `AUDIT_RETENTION_DAYS` | 定时清理时审计日志的保留天数 | `90` |
|
||||
| `NUXT_PUBLIC_DOCS_URL` | 落地页使用的文档站 URL(客户端运行时配置) | 落地页默认值 |
|
||||
| `NUXT_PUBLIC_REPO_URL` | 落地页使用的 GitHub 仓库 URL | 落地页默认值 |
|
||||
| `NUXT_PUBLIC_LEGAL_CONTACT` | `/terms` 与 `/privacy` 页面展示的联系方式 | 未设置时显示占位文本 |
|
||||
| 变量 | 说明 | 默认值 |
|
||||
| --------------------------- | ---------------------------------------------------------------------------------------------- | ----------------------- |
|
||||
| `DISCORD_PUBLIC_KEY` | Discord 应用的公钥(开发者门户获取),交互功能必需 | 未设置时交互返回 401 |
|
||||
| `DISCORD_APPLICATION_ID` | Discord 应用 ID;省略时自动获取 | 自动获取 |
|
||||
| `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` |
|
||||
| `BASE_URL` | OAuth 回调的公共 URL | `http://localhost:8787` |
|
||||
| `ADMIN_USER_IDS` | 允许访问 WebUI 的 GitHub 用户 ID(或登录名),逗号分隔 | 未设置时 WebUI 关闭 |
|
||||
| `ALLOW_SELF_SIGNUP` | 开启(`1`/`true`)后,没有任何分组权限的 GitHub 用户首次登录会自动获得个人分组而非 403 | 关闭 |
|
||||
| `AUDIT_RETENTION_DAYS` | 定时清理时审计日志的保留天数 | `90` |
|
||||
| `NUXT_PUBLIC_DOCS_URL` | 落地页使用的文档站 URL(客户端运行时配置) | 落地页默认值 |
|
||||
| `NUXT_PUBLIC_REPO_URL` | 落地页使用的 GitHub 仓库 URL | 落地页默认值 |
|
||||
| `NUXT_PUBLIC_LEGAL_CONTACT` | `/terms` 与 `/privacy` 页面展示的联系方式 | 未设置时显示占位文本 |
|
||||
|
||||
## Web 控制台
|
||||
|
||||
|
|
@ -65,14 +65,14 @@ WebHooker 在 `/admin` 提供内置配置控制台,可在浏览器中管理路
|
|||
|
||||
实操指南见[过滤器教程](./filters),包含完整示例。
|
||||
|
||||
| 类型 | 匹配对象 | 示例 |
|
||||
|-----------|------------------|--------------------------------------|
|
||||
| `event` | GitHub 事件名称 | `push`, `pull_*`, `pull_request` |
|
||||
| `repo` | 仓库全名 | `org/repo`, `org/*` |
|
||||
| `actor` | 发送者登录名 | `username`, `[bot]`, `*[bot]` |
|
||||
| `action` | 事件操作 | `opened`, `closed`, `published` |
|
||||
| `branch` | 分支名称 | `main`, `feature-?`, `/^release-/` |
|
||||
| `keyword` | 载荷正文中的文本 | `deploy`, `/fix\s+\d+/` |
|
||||
| 类型 | 匹配对象 | 示例 |
|
||||
| --------- | ---------------- | ---------------------------------- |
|
||||
| `event` | GitHub 事件名称 | `push`, `pull_*`, `pull_request` |
|
||||
| `repo` | 仓库全名 | `org/repo`, `org/*` |
|
||||
| `actor` | 发送者登录名 | `username`, `[bot]`, `*[bot]` |
|
||||
| `action` | 事件操作 | `opened`, `closed`, `published` |
|
||||
| `branch` | 分支名称 | `main`, `feature-?`, `/^release-/` |
|
||||
| `keyword` | 载荷正文中的文本 | `deploy`, `/fix\s+\d+/` |
|
||||
|
||||
### 过滤器行为
|
||||
|
||||
|
|
|
|||
|
|
@ -1,263 +1,263 @@
|
|||
# 过滤器教程
|
||||
|
||||
过滤器决定哪些 Webhook 事件会被[路由](./routes)转发。只有当路由 `filters` 数组中的**每一个**过滤器都匹配时,路由才会触发(AND 逻辑)。本页是一份上手教程:解释每种过滤器类型的行为,以及如何组合它们实现真实场景的路由规则。
|
||||
|
||||
参考表格见配置指南的[过滤器类型](./configuration#过滤器类型),完整事件列表见[支持的事件](../events/supported)。
|
||||
|
||||
## 匹配机制
|
||||
|
||||
- 路由中所有过滤器都必须匹配,否则该路由被跳过。
|
||||
- 每个过滤器将事件与 Webhook 载荷的某个字段进行匹配。
|
||||
- 所有过滤器类型都**不区分大小写**。
|
||||
- `match` 值可以是单个字符串,也可以是字符串数组。数组相当于 OR——只要其中一个值匹配,该过滤器即匹配。
|
||||
- 设置 `"exclude": true` 会反转结果(NOT 逻辑):当值**不**匹配时,该过滤器才匹配。
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "event",
|
||||
"match": ["push", "pull_request"],
|
||||
"exclude": false
|
||||
}
|
||||
```
|
||||
|
||||
上面这条路由同时匹配 `push` 和 `pull_request` 事件。
|
||||
|
||||
## 模式语法
|
||||
|
||||
所有过滤器类型共享以下三种模式写法:
|
||||
|
||||
| 模式 | 含义 |
|
||||
| -------------- | ----------------------------------------------------------------- |
|
||||
| `纯文本` | 字段过滤器:**完全相等**匹配;`keyword`:在载荷中任意位置搜索。 |
|
||||
| `*` / `?` | **通配符(glob)**——`*` 任意长度、`?` 恰好一个字符。 |
|
||||
| `/正则表达式/` | 按**正则表达式**编译(忽略大小写标志)。 |
|
||||
|
||||
- 字段过滤器(`event`/`repo`/`actor`/`action`/`branch`)的纯文本与通配符匹配整个值;`keyword` 则在载荷中任意位置搜索。
|
||||
- 正则表达式始终是搜索语义:`/^feat/` 匹配以 `feat` **开头**的值,`/feat/` 匹配任意位置出现 `feat` 的值。
|
||||
|
||||
示例:
|
||||
|
||||
```json
|
||||
{ "type": "event", "match": "pull_*" }
|
||||
```
|
||||
|
||||
匹配 `pull_request`、`pull_request_review`、`pull_request_review_comment` 等。
|
||||
|
||||
```json
|
||||
{ "type": "repo", "match": "myorg/*" }
|
||||
```
|
||||
|
||||
```json
|
||||
{ "type": "branch", "match": "feature-?" }
|
||||
```
|
||||
|
||||
匹配 `feature-x`、`feature-1`,但不匹配 `feature-xy`。
|
||||
|
||||
```json
|
||||
{ "type": "branch", "match": "/^feat/" }
|
||||
```
|
||||
|
||||
匹配任何以 `feat` 开头的分支名。
|
||||
|
||||
> [!TIP]
|
||||
> 通配符和正则同样不区分大小写,且 `*` 可以跨过仓库名中的 `/`(`myorg/*` 也能匹配 `myorg/sub/backend`)。
|
||||
|
||||
## 各过滤器类型详解
|
||||
|
||||
### `event` — 事件类型
|
||||
|
||||
匹配 GitHub 事件名称,如 `push`、`pull_request`、`issues`、`release`。它是每条路由的主干。
|
||||
|
||||
```json
|
||||
{ "type": "event", "match": "release" }
|
||||
```
|
||||
|
||||
用数组匹配多种事件:
|
||||
|
||||
```json
|
||||
{ "type": "event", "match": ["create", "delete"] }
|
||||
```
|
||||
|
||||
### `repo` — 仓库
|
||||
|
||||
匹配仓库**全名**(`owner/name`)。不区分大小写。
|
||||
|
||||
```json
|
||||
{ "type": "repo", "match": "myorg/backend" }
|
||||
```
|
||||
|
||||
将多个仓库路由到同一频道:
|
||||
|
||||
```json
|
||||
{ "type": "repo", "match": ["myorg/backend", "myorg/frontend"] }
|
||||
```
|
||||
|
||||
### `actor` — 发送者
|
||||
|
||||
匹配触发事件的 **GitHub 发送者登录名**(载荷中的 `sender.login`)。常用于忽略机器人。
|
||||
|
||||
```json
|
||||
{ "type": "actor", "match": "dependabot[bot]", "exclude": true }
|
||||
```
|
||||
|
||||
上面这条路由对**除** Dependabot 触发之外的所有事件都会触发。
|
||||
|
||||
### `action` — 事件操作
|
||||
|
||||
匹配载荷中的 `action` 字段,如 `opened`、`closed`、`published`、`completed`。并非所有事件都带有 action——参见[过滤器兼容性](../events/supported#过滤器兼容性)。与 `event` 组合可精确到某个生命周期步骤:
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "event",
|
||||
"match": "pull_request",
|
||||
"exclude": false
|
||||
},
|
||||
{
|
||||
"type": "action",
|
||||
"match": ["opened", "reopened"]
|
||||
}
|
||||
```
|
||||
|
||||
上面的规则在拉取请求被打开或重新打开时触发(合并/关闭/编辑时不触发)。
|
||||
|
||||
### `branch` — 分支
|
||||
|
||||
匹配事件涉及的分支。何种字段算作「分支」取决于事件类型:
|
||||
|
||||
| 事件 | 提取的分支 |
|
||||
| --------------------------- | ----------------------------------- |
|
||||
| `push` | 推送到的目标分支 |
|
||||
| `pull_request`(及 review) | 拉取请求的 **head**(源)分支 |
|
||||
| `create` / `delete` | 创建/删除的分支或标签 |
|
||||
| `workflow_run` | 工作流运行所在的 `head_branch` |
|
||||
| `workflow_job` | 作业运行所在的 `head_branch` |
|
||||
| `check_suite` | 检查套件的 `head_branch` |
|
||||
| `deployment` | 部署引用(去除 `refs/heads/` 前缀) |
|
||||
| `code_scanning_alert` | 告警所属的分支 |
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "event",
|
||||
"match": "push"
|
||||
},
|
||||
{
|
||||
"type": "branch",
|
||||
"match": "main"
|
||||
}
|
||||
```
|
||||
|
||||
仅当推送到 `main` 时触发。要关注多个长期分支:
|
||||
|
||||
```json
|
||||
{ "type": "branch", "match": ["main", "develop"] }
|
||||
```
|
||||
|
||||
> [!NOTE]
|
||||
> `branch` 匹配不区分大小写。需要前缀或通配符式匹配时,可直接使用通配符(`feature/*`)或用 `/` 包裹正则(`/^release-/`)。
|
||||
|
||||
### `keyword` — 载荷中的文本
|
||||
|
||||
匹配整个 JSON 载荷(转为小写)。它是最灵活的过滤器:纯文本在任意位置搜索,`*`/`?` 通配符带通配搜索,`//` 包裹的模式按正则表达式编译(带 `i` 标志)。
|
||||
|
||||
```json
|
||||
{ "type": "keyword", "match": "deploy" }
|
||||
```
|
||||
|
||||
当载荷中任意位置包含 `deploy` 时触发。由于载荷已被转为小写,`Deploy`、`DEPLOY` 等都会匹配。
|
||||
|
||||
```json
|
||||
{ "type": "keyword", "match": "*release-*" }
|
||||
```
|
||||
|
||||
```json
|
||||
{ "type": "keyword", "match": "/^(fix|hotfix)/" }
|
||||
```
|
||||
|
||||
```json
|
||||
{ "type": "keyword", "match": "/release-[0-9]+/" }
|
||||
```
|
||||
|
||||
行为细节:
|
||||
|
||||
- 超过 200 个字符的模式**不**编译为通配符/正则,回退为纯文本匹配。
|
||||
- 被 `/` 包裹但**不是合法正则**的模式匹配**任何内容都不命中**(过滤器恒为 false),而不会报错。
|
||||
- 要搜索是通配符或正则特殊字符的文本(如 `v1.2.3`),使用纯文本形式即可——不含 `*`、`?` 且未被 `//` 包裹的模式按字面匹配。
|
||||
- 搜索覆盖**整个**载荷:提交信息、PR 标题与正文、标签、引用,甚至仓库名和发送者名。
|
||||
|
||||
### `keyword` 与 `exclude` 组合
|
||||
|
||||
与其他过滤器一样,`exclude` 会反转关键词匹配:
|
||||
|
||||
```json
|
||||
{ "type": "keyword", "match": "/wip|draft/", "exclude": true }
|
||||
```
|
||||
|
||||
跳过载荷中提及 `wip` 或 `draft` 的事件。
|
||||
|
||||
## 示例 1:PR 通知,跳过机器人和草稿
|
||||
|
||||
转发拉取请求动态,但忽略机器人作者和草稿 PR,发往 `#prs` 频道:
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "pr-notices",
|
||||
"name": "PR Notices",
|
||||
"enabled": true,
|
||||
"groupId": "eng",
|
||||
"filters": [
|
||||
{ "type": "event", "match": "pull_request" },
|
||||
{ "type": "actor", "match": "dependabot[bot]", "exclude": true },
|
||||
{ "type": "keyword", "match": "\"draft\": true", "exclude": true }
|
||||
],
|
||||
"target": { "channelId": "111111111111111111" }
|
||||
}
|
||||
```
|
||||
|
||||
`"draft": true` 模式匹配 GitHub 在拉取请求载荷中包含的 `draft` 字段;配合 `exclude: true` 即可过滤掉草稿 PR。
|
||||
|
||||
## 示例 2:仅发布通知频道
|
||||
|
||||
只转发特定仓库的已发布 release:
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "release-alerts",
|
||||
"name": "Release Alerts",
|
||||
"enabled": true,
|
||||
"groupId": "eng",
|
||||
"filters": [
|
||||
{ "type": "event", "match": "release" },
|
||||
{ "type": "action", "match": "published" },
|
||||
{ "type": "repo", "match": "myorg/backend" }
|
||||
],
|
||||
"target": { "channelId": "222222222222222222" }
|
||||
}
|
||||
```
|
||||
|
||||
## 示例 3:CI 失败
|
||||
|
||||
转发任意分支上以失败结束的 workflow run,发往 `#ci` 频道:
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "ci-failures",
|
||||
"name": "CI Failures",
|
||||
"enabled": true,
|
||||
"groupId": "eng",
|
||||
"filters": [
|
||||
{ "type": "event", "match": "workflow_run" },
|
||||
{ "type": "action", "match": "completed" },
|
||||
{ "type": "keyword", "match": "\"conclusion\":\"failure\"" }
|
||||
],
|
||||
"target": { "channelId": "333333333333333333" }
|
||||
}
|
||||
```
|
||||
|
||||
## 常见陷阱
|
||||
|
||||
- **通配符是 glob,不是正则。** `repo: "myorg/*"` 匹配 `myorg` 下的任意仓库(含 `myorg/sub/backend`),但 `repo: "myorg/.*"` 按字面匹配。需要正则请用 `/` 包裹:`"/myorg\/.*/"`。
|
||||
- **被 `/` 包裹的非法正则永远不匹配。** 与纯文本不同——未包裹的非法模式按字面匹配。只有确定是真正的正则时才使用 `//` 包裹。
|
||||
- **`action` 过滤器遇到无 action 的事件永远不匹配。** 先确认该事件带有 `action` 字段(见[过滤器兼容性](../events/supported#过滤器兼容性))。
|
||||
- **`branch` 过滤器遇到无分支的事件永远不匹配。** 在 `issues` 事件上使用 `branch` 过滤器恒为假。此时需要类似分支的匹配可用 `keyword`。
|
||||
- **`keyword` 会搜索一切。** 因为它扫描整个载荷,`"fix"` 这样的模式可能同时匹配提交信息、issue 标题和仓库名。请尽量写得更具体。
|
||||
- **牢记 `exclude` 语义。** `exclude: true` 反转的是整个过滤器——数组中的一个值不匹配并不会「阻断」路由;只有当**所有**值都不匹配时,取反后的过滤器才匹配。
|
||||
# 过滤器教程
|
||||
|
||||
过滤器决定哪些 Webhook 事件会被[路由](./routes)转发。只有当路由 `filters` 数组中的**每一个**过滤器都匹配时,路由才会触发(AND 逻辑)。本页是一份上手教程:解释每种过滤器类型的行为,以及如何组合它们实现真实场景的路由规则。
|
||||
|
||||
参考表格见配置指南的[过滤器类型](./configuration#过滤器类型),完整事件列表见[支持的事件](../events/supported)。
|
||||
|
||||
## 匹配机制
|
||||
|
||||
- 路由中所有过滤器都必须匹配,否则该路由被跳过。
|
||||
- 每个过滤器将事件与 Webhook 载荷的某个字段进行匹配。
|
||||
- 所有过滤器类型都**不区分大小写**。
|
||||
- `match` 值可以是单个字符串,也可以是字符串数组。数组相当于 OR——只要其中一个值匹配,该过滤器即匹配。
|
||||
- 设置 `"exclude": true` 会反转结果(NOT 逻辑):当值**不**匹配时,该过滤器才匹配。
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "event",
|
||||
"match": ["push", "pull_request"],
|
||||
"exclude": false
|
||||
}
|
||||
```
|
||||
|
||||
上面这条路由同时匹配 `push` 和 `pull_request` 事件。
|
||||
|
||||
## 模式语法
|
||||
|
||||
所有过滤器类型共享以下三种模式写法:
|
||||
|
||||
| 模式 | 含义 |
|
||||
| -------------- | --------------------------------------------------------------- |
|
||||
| `纯文本` | 字段过滤器:**完全相等**匹配;`keyword`:在载荷中任意位置搜索。 |
|
||||
| `*` / `?` | **通配符(glob)**——`*` 任意长度、`?` 恰好一个字符。 |
|
||||
| `/正则表达式/` | 按**正则表达式**编译(忽略大小写标志)。 |
|
||||
|
||||
- 字段过滤器(`event`/`repo`/`actor`/`action`/`branch`)的纯文本与通配符匹配整个值;`keyword` 则在载荷中任意位置搜索。
|
||||
- 正则表达式始终是搜索语义:`/^feat/` 匹配以 `feat` **开头**的值,`/feat/` 匹配任意位置出现 `feat` 的值。
|
||||
|
||||
示例:
|
||||
|
||||
```json
|
||||
{ "type": "event", "match": "pull_*" }
|
||||
```
|
||||
|
||||
匹配 `pull_request`、`pull_request_review`、`pull_request_review_comment` 等。
|
||||
|
||||
```json
|
||||
{ "type": "repo", "match": "myorg/*" }
|
||||
```
|
||||
|
||||
```json
|
||||
{ "type": "branch", "match": "feature-?" }
|
||||
```
|
||||
|
||||
匹配 `feature-x`、`feature-1`,但不匹配 `feature-xy`。
|
||||
|
||||
```json
|
||||
{ "type": "branch", "match": "/^feat/" }
|
||||
```
|
||||
|
||||
匹配任何以 `feat` 开头的分支名。
|
||||
|
||||
> [!TIP]
|
||||
> 通配符和正则同样不区分大小写,且 `*` 可以跨过仓库名中的 `/`(`myorg/*` 也能匹配 `myorg/sub/backend`)。
|
||||
|
||||
## 各过滤器类型详解
|
||||
|
||||
### `event` — 事件类型
|
||||
|
||||
匹配 GitHub 事件名称,如 `push`、`pull_request`、`issues`、`release`。它是每条路由的主干。
|
||||
|
||||
```json
|
||||
{ "type": "event", "match": "release" }
|
||||
```
|
||||
|
||||
用数组匹配多种事件:
|
||||
|
||||
```json
|
||||
{ "type": "event", "match": ["create", "delete"] }
|
||||
```
|
||||
|
||||
### `repo` — 仓库
|
||||
|
||||
匹配仓库**全名**(`owner/name`)。不区分大小写。
|
||||
|
||||
```json
|
||||
{ "type": "repo", "match": "myorg/backend" }
|
||||
```
|
||||
|
||||
将多个仓库路由到同一频道:
|
||||
|
||||
```json
|
||||
{ "type": "repo", "match": ["myorg/backend", "myorg/frontend"] }
|
||||
```
|
||||
|
||||
### `actor` — 发送者
|
||||
|
||||
匹配触发事件的 **GitHub 发送者登录名**(载荷中的 `sender.login`)。常用于忽略机器人。
|
||||
|
||||
```json
|
||||
{ "type": "actor", "match": "dependabot[bot]", "exclude": true }
|
||||
```
|
||||
|
||||
上面这条路由对**除** Dependabot 触发之外的所有事件都会触发。
|
||||
|
||||
### `action` — 事件操作
|
||||
|
||||
匹配载荷中的 `action` 字段,如 `opened`、`closed`、`published`、`completed`。并非所有事件都带有 action——参见[过滤器兼容性](../events/supported#过滤器兼容性)。与 `event` 组合可精确到某个生命周期步骤:
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "event",
|
||||
"match": "pull_request",
|
||||
"exclude": false
|
||||
},
|
||||
{
|
||||
"type": "action",
|
||||
"match": ["opened", "reopened"]
|
||||
}
|
||||
```
|
||||
|
||||
上面的规则在拉取请求被打开或重新打开时触发(合并/关闭/编辑时不触发)。
|
||||
|
||||
### `branch` — 分支
|
||||
|
||||
匹配事件涉及的分支。何种字段算作「分支」取决于事件类型:
|
||||
|
||||
| 事件 | 提取的分支 |
|
||||
| --------------------------- | ----------------------------------- |
|
||||
| `push` | 推送到的目标分支 |
|
||||
| `pull_request`(及 review) | 拉取请求的 **head**(源)分支 |
|
||||
| `create` / `delete` | 创建/删除的分支或标签 |
|
||||
| `workflow_run` | 工作流运行所在的 `head_branch` |
|
||||
| `workflow_job` | 作业运行所在的 `head_branch` |
|
||||
| `check_suite` | 检查套件的 `head_branch` |
|
||||
| `deployment` | 部署引用(去除 `refs/heads/` 前缀) |
|
||||
| `code_scanning_alert` | 告警所属的分支 |
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "event",
|
||||
"match": "push"
|
||||
},
|
||||
{
|
||||
"type": "branch",
|
||||
"match": "main"
|
||||
}
|
||||
```
|
||||
|
||||
仅当推送到 `main` 时触发。要关注多个长期分支:
|
||||
|
||||
```json
|
||||
{ "type": "branch", "match": ["main", "develop"] }
|
||||
```
|
||||
|
||||
> [!NOTE]
|
||||
> `branch` 匹配不区分大小写。需要前缀或通配符式匹配时,可直接使用通配符(`feature/*`)或用 `/` 包裹正则(`/^release-/`)。
|
||||
|
||||
### `keyword` — 载荷中的文本
|
||||
|
||||
匹配整个 JSON 载荷(转为小写)。它是最灵活的过滤器:纯文本在任意位置搜索,`*`/`?` 通配符带通配搜索,`//` 包裹的模式按正则表达式编译(带 `i` 标志)。
|
||||
|
||||
```json
|
||||
{ "type": "keyword", "match": "deploy" }
|
||||
```
|
||||
|
||||
当载荷中任意位置包含 `deploy` 时触发。由于载荷已被转为小写,`Deploy`、`DEPLOY` 等都会匹配。
|
||||
|
||||
```json
|
||||
{ "type": "keyword", "match": "*release-*" }
|
||||
```
|
||||
|
||||
```json
|
||||
{ "type": "keyword", "match": "/^(fix|hotfix)/" }
|
||||
```
|
||||
|
||||
```json
|
||||
{ "type": "keyword", "match": "/release-[0-9]+/" }
|
||||
```
|
||||
|
||||
行为细节:
|
||||
|
||||
- 超过 200 个字符的模式**不**编译为通配符/正则,回退为纯文本匹配。
|
||||
- 被 `/` 包裹但**不是合法正则**的模式匹配**任何内容都不命中**(过滤器恒为 false),而不会报错。
|
||||
- 要搜索是通配符或正则特殊字符的文本(如 `v1.2.3`),使用纯文本形式即可——不含 `*`、`?` 且未被 `//` 包裹的模式按字面匹配。
|
||||
- 搜索覆盖**整个**载荷:提交信息、PR 标题与正文、标签、引用,甚至仓库名和发送者名。
|
||||
|
||||
### `keyword` 与 `exclude` 组合
|
||||
|
||||
与其他过滤器一样,`exclude` 会反转关键词匹配:
|
||||
|
||||
```json
|
||||
{ "type": "keyword", "match": "/wip|draft/", "exclude": true }
|
||||
```
|
||||
|
||||
跳过载荷中提及 `wip` 或 `draft` 的事件。
|
||||
|
||||
## 示例 1:PR 通知,跳过机器人和草稿
|
||||
|
||||
转发拉取请求动态,但忽略机器人作者和草稿 PR,发往 `#prs` 频道:
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "pr-notices",
|
||||
"name": "PR Notices",
|
||||
"enabled": true,
|
||||
"groupId": "eng",
|
||||
"filters": [
|
||||
{ "type": "event", "match": "pull_request" },
|
||||
{ "type": "actor", "match": "dependabot[bot]", "exclude": true },
|
||||
{ "type": "keyword", "match": "\"draft\": true", "exclude": true }
|
||||
],
|
||||
"target": { "channelId": "111111111111111111" }
|
||||
}
|
||||
```
|
||||
|
||||
`"draft": true` 模式匹配 GitHub 在拉取请求载荷中包含的 `draft` 字段;配合 `exclude: true` 即可过滤掉草稿 PR。
|
||||
|
||||
## 示例 2:仅发布通知频道
|
||||
|
||||
只转发特定仓库的已发布 release:
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "release-alerts",
|
||||
"name": "Release Alerts",
|
||||
"enabled": true,
|
||||
"groupId": "eng",
|
||||
"filters": [
|
||||
{ "type": "event", "match": "release" },
|
||||
{ "type": "action", "match": "published" },
|
||||
{ "type": "repo", "match": "myorg/backend" }
|
||||
],
|
||||
"target": { "channelId": "222222222222222222" }
|
||||
}
|
||||
```
|
||||
|
||||
## 示例 3:CI 失败
|
||||
|
||||
转发任意分支上以失败结束的 workflow run,发往 `#ci` 频道:
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "ci-failures",
|
||||
"name": "CI Failures",
|
||||
"enabled": true,
|
||||
"groupId": "eng",
|
||||
"filters": [
|
||||
{ "type": "event", "match": "workflow_run" },
|
||||
{ "type": "action", "match": "completed" },
|
||||
{ "type": "keyword", "match": "\"conclusion\":\"failure\"" }
|
||||
],
|
||||
"target": { "channelId": "333333333333333333" }
|
||||
}
|
||||
```
|
||||
|
||||
## 常见陷阱
|
||||
|
||||
- **通配符是 glob,不是正则。** `repo: "myorg/*"` 匹配 `myorg` 下的任意仓库(含 `myorg/sub/backend`),但 `repo: "myorg/.*"` 按字面匹配。需要正则请用 `/` 包裹:`"/myorg\/.*/"`。
|
||||
- **被 `/` 包裹的非法正则永远不匹配。** 与纯文本不同——未包裹的非法模式按字面匹配。只有确定是真正的正则时才使用 `//` 包裹。
|
||||
- **`action` 过滤器遇到无 action 的事件永远不匹配。** 先确认该事件带有 `action` 字段(见[过滤器兼容性](../events/supported#过滤器兼容性))。
|
||||
- **`branch` 过滤器遇到无分支的事件永远不匹配。** 在 `issues` 事件上使用 `branch` 过滤器恒为假。此时需要类似分支的匹配可用 `keyword`。
|
||||
- **`keyword` 会搜索一切。** 因为它扫描整个载荷,`"fix"` 这样的模式可能同时匹配提交信息、issue 标题和仓库名。请尽量写得更具体。
|
||||
- **牢记 `exclude` 语义。** `exclude: true` 反转的是整个过滤器——数组中的一个值不匹配并不会「阻断」路由;只有当**所有**值都不匹配时,取反后的过滤器才匹配。
|
||||
|
|
|
|||
|
|
@ -1,6 +1,4 @@
|
|||
import defaultNitroErrorHandler, {
|
||||
defineNitroErrorHandler,
|
||||
} from "nitropack/runtime/error";
|
||||
import defaultNitroErrorHandler, { defineNitroErrorHandler } from "nitropack/runtime/error";
|
||||
import { setResponseStatus } from "h3";
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -21,7 +21,10 @@ async function readJsonBody(event: H3Event): Promise<Record<string, unknown> | n
|
|||
}
|
||||
}
|
||||
|
||||
async function userOctokit(event: H3Event, userId: string): Promise<Awaited<ReturnType<typeof getUserOctokit>>> {
|
||||
async function userOctokit(
|
||||
event: H3Event,
|
||||
userId: string,
|
||||
): Promise<Awaited<ReturnType<typeof getUserOctokit>>> {
|
||||
return getUserOctokit(userId, cfEnv(event).KV);
|
||||
}
|
||||
|
||||
|
|
@ -123,16 +126,7 @@ export async function apiClose(event: H3Event): Promise<Record<string, unknown>>
|
|||
export async function apiReact(event: H3Event): Promise<Record<string, unknown>> {
|
||||
const userId = await bearerUserId(event);
|
||||
const body = await readJsonBody(event);
|
||||
const reactions = [
|
||||
"+1",
|
||||
"-1",
|
||||
"laugh",
|
||||
"confused",
|
||||
"heart",
|
||||
"hooray",
|
||||
"rocket",
|
||||
"eyes",
|
||||
] as const;
|
||||
const reactions = ["+1", "-1", "laugh", "confused", "heart", "hooray", "rocket", "eyes"] as const;
|
||||
if (
|
||||
!body ||
|
||||
!isNonEmptyString(body.owner) ||
|
||||
|
|
@ -151,14 +145,7 @@ export async function apiReact(event: H3Event): Promise<Record<string, unknown>>
|
|||
repo: body.repo,
|
||||
issue_number: body.issueNumber,
|
||||
content: body.reaction as
|
||||
| "+1"
|
||||
| "-1"
|
||||
| "laugh"
|
||||
| "confused"
|
||||
| "heart"
|
||||
| "hooray"
|
||||
| "rocket"
|
||||
| "eyes",
|
||||
"+1" | "-1" | "laugh" | "confused" | "heart" | "hooray" | "rocket" | "eyes",
|
||||
});
|
||||
} catch (err) {
|
||||
log.error({ err }, "Failed to create reaction");
|
||||
|
|
|
|||
|
|
@ -402,7 +402,10 @@ export async function adminInvite(event: H3Event): Promise<void> {
|
|||
}
|
||||
const session = await getAdminSession(env.KV, getHeader(event, "cookie"));
|
||||
if (!session) {
|
||||
await sendRedirect(event, `/auth/github?redirect=${encodeURIComponent(`/admin/invite?token=${token}`)}`);
|
||||
await sendRedirect(
|
||||
event,
|
||||
`/auth/github?redirect=${encodeURIComponent(`/admin/invite?token=${token}`)}`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
const result = await acceptInvite(env.KV, token, session.userId, session.login);
|
||||
|
|
@ -480,8 +483,7 @@ export async function adminApiGroupsPut(event: H3Event): Promise<Record<string,
|
|||
const members = g.members ?? normalizeGroupMembers(g);
|
||||
const stillMine = members.some(
|
||||
(m) =>
|
||||
m.role === "owner" &&
|
||||
identityMatches([m.login], auth.session.userId, auth.session.login),
|
||||
m.role === "owner" && identityMatches([m.login], auth.session.userId, auth.session.login),
|
||||
);
|
||||
const otherOwner = ownerCount(members) > 1;
|
||||
if (!stillMine && !otherOwner) {
|
||||
|
|
@ -648,7 +650,10 @@ export async function adminApiLogs(event: H3Event): Promise<Record<string, unkno
|
|||
}
|
||||
|
||||
/** GET /admin/api/logs/:id */
|
||||
export async function adminApiLogsById(event: H3Event, id: number): Promise<Record<string, unknown>> {
|
||||
export async function adminApiLogsById(
|
||||
event: H3Event,
|
||||
id: number,
|
||||
): Promise<Record<string, unknown>> {
|
||||
const auth = await requireAnyAccess(event);
|
||||
const env = cfEnv(event);
|
||||
if (!Number.isInteger(id) || id <= 0) return respondError(event, 400, "Invalid log id");
|
||||
|
|
@ -783,7 +788,10 @@ export async function adminGroupInvitesGet(
|
|||
}
|
||||
|
||||
/** DELETE /admin/api/invites/:token */
|
||||
export async function adminInviteDelete(event: H3Event, token: string): Promise<Record<string, unknown>> {
|
||||
export async function adminInviteDelete(
|
||||
event: H3Event,
|
||||
token: string,
|
||||
): Promise<Record<string, unknown>> {
|
||||
await requireAnyAccess(event);
|
||||
const env = cfEnv(event);
|
||||
const invite = await getInvite(env.KV, token);
|
||||
|
|
|
|||
|
|
@ -64,11 +64,7 @@ export function requireGroup(event: H3Event, groupId: string): GroupAccess {
|
|||
}
|
||||
|
||||
/** Requires at least `min` role in the group (owner|admin|viewer). */
|
||||
export function requireGroupRole(
|
||||
event: H3Event,
|
||||
groupId: string,
|
||||
min: GroupRole,
|
||||
): GroupAccess {
|
||||
export function requireGroupRole(event: H3Event, groupId: string, min: GroupRole): GroupAccess {
|
||||
const access = requireGroup(event, groupId);
|
||||
if (!access.ok) return access;
|
||||
const auth = currentAuth(event);
|
||||
|
|
@ -98,7 +94,6 @@ export async function bearerUserId(event: H3Event): Promise<string> {
|
|||
if (!auth?.startsWith("Bearer "))
|
||||
throw createError({ statusCode: 401, statusMessage: "Missing authorization" });
|
||||
const userId = await findUserIdByToken(env.KV, auth.slice(7));
|
||||
if (!userId)
|
||||
throw createError({ statusCode: 401, statusMessage: "Invalid or expired token" });
|
||||
if (!userId) throw createError({ statusCode: 401, statusMessage: "Invalid or expired token" });
|
||||
return userId;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -157,8 +157,11 @@ export async function handleInstallPage(event: H3Event): Promise<string | void>
|
|||
return;
|
||||
}
|
||||
const accountLogin =
|
||||
(await getInstallationAccount(env.GITHUB_APP_ID ?? "", env.GITHUB_PRIVATE_KEY ?? "", installationId)) ??
|
||||
"";
|
||||
(await getInstallationAccount(
|
||||
env.GITHUB_APP_ID ?? "",
|
||||
env.GITHUB_PRIVATE_KEY ?? "",
|
||||
installationId,
|
||||
)) ?? "";
|
||||
const groups = await loadGroups(env.KV);
|
||||
const scope = resolveScope(env, groups, session.userId, session.login);
|
||||
const owned = groups.filter((g) => roleAt(scope, g.id) === "owner");
|
||||
|
|
@ -216,8 +219,11 @@ export async function handleInstallBind(event: H3Event): Promise<void> {
|
|||
|
||||
// Default: auto-create a dedicated inst-{id} group.
|
||||
const accountLogin =
|
||||
(await getInstallationAccount(env.GITHUB_APP_ID ?? "", env.GITHUB_PRIVATE_KEY ?? "", installationId)) ??
|
||||
"";
|
||||
(await getInstallationAccount(
|
||||
env.GITHUB_APP_ID ?? "",
|
||||
env.GITHUB_PRIVATE_KEY ?? "",
|
||||
installationId,
|
||||
)) ?? "";
|
||||
const group = await ensureInstallationGroup(env.KV, installationId, accountLogin);
|
||||
if (!group) {
|
||||
await sendRedirect(event, "/admin?error=install");
|
||||
|
|
@ -248,7 +254,10 @@ export async function handleInstallBind(event: H3Event): Promise<void> {
|
|||
adminIds: [...new Set([...(group.adminIds ?? []), session.login])],
|
||||
};
|
||||
const all = await loadGroups(env.KV);
|
||||
await saveGroups(env.KV, all.map((g) => (g.id === group.id ? updated : g)));
|
||||
await saveGroups(
|
||||
env.KV,
|
||||
all.map((g) => (g.id === group.id ? updated : g)),
|
||||
);
|
||||
await recordAudit(env.DB, {
|
||||
ts: Date.now(),
|
||||
actorId: session.userId,
|
||||
|
|
|
|||
|
|
@ -117,10 +117,7 @@ export async function processWebhook(
|
|||
}
|
||||
|
||||
/** h3 wrapper for `POST /webhook` / `POST /webhook/:groupId`. */
|
||||
export async function handleWebhookRequest(
|
||||
event: H3Event,
|
||||
tenantId?: string,
|
||||
): Promise<unknown> {
|
||||
export async function handleWebhookRequest(event: H3Event, tenantId?: string): Promise<unknown> {
|
||||
const contentLength = Number(getHeader(event, "content-length") ?? 0);
|
||||
if (contentLength > MAX_BODY_SIZE) {
|
||||
setResponseStatus(event, 413);
|
||||
|
|
|
|||
|
|
@ -47,8 +47,8 @@ export default <Partial<Config>>{
|
|||
card: "var(--shadow)",
|
||||
"card-hover": "0 8px 24px -12px rgba(15, 23, 42, 0.25)",
|
||||
"accent-lg": "0 12px 28px -10px var(--accent)",
|
||||
"drawer": "-16px 0 48px rgba(15, 23, 42, 0.12)",
|
||||
"modal": "0 12px 40px rgba(15, 23, 42, 0.25)",
|
||||
drawer: "-16px 0 48px rgba(15, 23, 42, 0.12)",
|
||||
modal: "0 12px 40px rgba(15, 23, 42, 0.25)",
|
||||
},
|
||||
keyframes: {
|
||||
rise: {
|
||||
|
|
|
|||
|
|
@ -1,9 +1,5 @@
|
|||
import { describe, it, expect } from "bun:test";
|
||||
import {
|
||||
adminGroupRename,
|
||||
adminGroupRoutesGet,
|
||||
adminApiMe,
|
||||
} from "../server/lib/web/admin";
|
||||
import { adminGroupRename, adminGroupRoutesGet, adminApiMe } from "../server/lib/web/admin";
|
||||
import { createAdminSession, adminCookie } from "../server/lib/web/session";
|
||||
import { loadGroups } from "../server/lib/web/groups";
|
||||
import { loadRoutes } from "../server/lib/config";
|
||||
|
|
|
|||
|
|
@ -34,7 +34,9 @@ describe("message title spec", () => {
|
|||
expect(msg.title).toBe(
|
||||
"acme/widget: Pushed 1 commit to [`main`](https://github.com/acme/widget/tree/main)",
|
||||
);
|
||||
expect(msg.description).toBe("[View comparison](https://github.com/acme/widget/compare/abc...def)");
|
||||
expect(msg.description).toBe(
|
||||
"[View comparison](https://github.com/acme/widget/compare/abc...def)",
|
||||
);
|
||||
});
|
||||
|
||||
it("pull_request title is repo#number: title", () => {
|
||||
|
|
@ -101,7 +103,9 @@ describe("message title spec", () => {
|
|||
sender,
|
||||
}),
|
||||
);
|
||||
expect(msg.title).toBe("acme/widget: [CI — success](https://github.com/acme/widget/actions/runs/42)");
|
||||
expect(msg.title).toBe(
|
||||
"acme/widget: [CI — success](https://github.com/acme/widget/actions/runs/42)",
|
||||
);
|
||||
expect(msg.fields![1].value).toBe("✅ build");
|
||||
});
|
||||
|
||||
|
|
@ -122,7 +126,9 @@ describe("message title spec", () => {
|
|||
sender,
|
||||
}),
|
||||
);
|
||||
expect(queued.title).toBe("acme/widget: [CI — queued](https://github.com/acme/widget/actions/runs/42)");
|
||||
expect(queued.title).toBe(
|
||||
"acme/widget: [CI — queued](https://github.com/acme/widget/actions/runs/42)",
|
||||
);
|
||||
expect(queued.fields![0].value).toBe("⏳ queued");
|
||||
|
||||
const running = formatEvent(
|
||||
|
|
@ -134,7 +140,9 @@ describe("message title spec", () => {
|
|||
sender,
|
||||
}),
|
||||
);
|
||||
expect(running.title).toBe("acme/widget: [CI — running](https://github.com/acme/widget/actions/runs/42)");
|
||||
expect(running.title).toBe(
|
||||
"acme/widget: [CI — running](https://github.com/acme/widget/actions/runs/42)",
|
||||
);
|
||||
expect(running.fields![0].value).toBe("🔄 running");
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -269,7 +269,10 @@ describe("POST /auth/github/install/bind", () => {
|
|||
const env = createEnv({ KV: kv });
|
||||
const sessionId = await createAdminSession(kv, "1001", "alice");
|
||||
|
||||
const event = bindEvent(env, adminCookie(sessionId), { installation_id: "555", group: "theirs" });
|
||||
const event = bindEvent(env, adminCookie(sessionId), {
|
||||
installation_id: "555",
|
||||
group: "theirs",
|
||||
});
|
||||
await handleInstallBind(event);
|
||||
expect(responseStatus(event)).toBe(302);
|
||||
expect(responseHeader(event, "location")).toBe("/admin?error=forbidden");
|
||||
|
|
@ -329,7 +332,10 @@ describe("oauth misc", () => {
|
|||
it("starts the OAuth flow with a state token", async () => {
|
||||
const kv = createMockKV();
|
||||
const env = createEnv({ KV: kv, GITHUB_CLIENT_ID: "client-1" });
|
||||
const event = makeEvent("/auth/github?redirect=/admin", { headers: { accept: "text/html" }, env });
|
||||
const event = makeEvent("/auth/github?redirect=/admin", {
|
||||
headers: { accept: "text/html" },
|
||||
env,
|
||||
});
|
||||
await handleOAuthStart(event);
|
||||
expect(responseStatus(event)).toBe(302);
|
||||
const location = responseHeader(event, "location") ?? "";
|
||||
|
|
|
|||
|
|
@ -1,5 +1,11 @@
|
|||
import { describe, it, expect, beforeEach } from "bun:test";
|
||||
import { createInvite, getInvite, listInvites, revokeInvite, acceptInvite } from "../server/lib/web/invites";
|
||||
import {
|
||||
createInvite,
|
||||
getInvite,
|
||||
listInvites,
|
||||
revokeInvite,
|
||||
acceptInvite,
|
||||
} from "../server/lib/web/invites";
|
||||
import { saveGroups, loadGroups } from "../server/lib/web/groups";
|
||||
import type { Group } from "../server/lib/types";
|
||||
|
||||
|
|
|
|||
|
|
@ -184,22 +184,20 @@ describe("matchRoute", () => {
|
|||
|
||||
it("matches branch filter with single-char ? wildcard", () => {
|
||||
const route = { ...baseRoute, filters: [{ type: "branch" as const, match: "feature-?" }] };
|
||||
expect(
|
||||
matchRoute(route, { event: "push", payload: { ref: "refs/heads/feature-x" } }),
|
||||
).toBe(true);
|
||||
expect(
|
||||
matchRoute(route, { event: "push", payload: { ref: "refs/heads/feature-xy" } }),
|
||||
).toBe(false);
|
||||
expect(matchRoute(route, { event: "push", payload: { ref: "refs/heads/feature-x" } })).toBe(
|
||||
true,
|
||||
);
|
||||
expect(matchRoute(route, { event: "push", payload: { ref: "refs/heads/feature-xy" } })).toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
it("matches branch filter with //-wrapped regex", () => {
|
||||
const route = { ...baseRoute, filters: [{ type: "branch" as const, match: "/^feat/" }] };
|
||||
expect(
|
||||
matchRoute(route, { event: "push", payload: { ref: "refs/heads/feature-x" } }),
|
||||
).toBe(true);
|
||||
expect(
|
||||
matchRoute(route, { event: "push", payload: { ref: "refs/heads/main" } }),
|
||||
).toBe(false);
|
||||
expect(matchRoute(route, { event: "push", payload: { ref: "refs/heads/feature-x" } })).toBe(
|
||||
true,
|
||||
);
|
||||
expect(matchRoute(route, { event: "push", payload: { ref: "refs/heads/main" } })).toBe(false);
|
||||
});
|
||||
|
||||
it("treats glob special chars literally when not wrapped", () => {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue