mirror of
https://github.com/ReCloudStudio/WebHooker.git
synced 2026-09-22 16:11:29 +00:00
chore: auto-fix lint & formatting [skip ci]
This commit is contained in:
parent
41ad1a036b
commit
df51b71121
22 changed files with 1419 additions and 1408 deletions
726
README.md
726
README.md
|
|
@ -1,363 +1,363 @@
|
|||
# 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 (not currently used by the code; kept for compatibility) |
|
||||
| `GITHUB_PRIVATE_KEY` | App private key (PKCS#8 PEM; not currently used by the code; kept for compatibility) |
|
||||
| `GITHUB_CLIENT_ID` | OAuth client ID |
|
||||
| `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) |
|
||||
| `DOCS_URL` | Optional docs site URL used by the landing page |
|
||||
| `GITHUB_REPO_URL` | Optional GitHub repo URL used by the landing page |
|
||||
| `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). The legacy singular `target` field is still migrated automatically. There is no fallback to a default channel.
|
||||
|
||||
Set `discordRoleIds` on a route to ping Discord roles (身份组) whenever it fires:
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "release-notify",
|
||||
"name": "Notify on Release",
|
||||
"enabled": true,
|
||||
"groupId": "default",
|
||||
"discordRoleIds": ["111111111111111111"],
|
||||
"filters": [{ "type": "event", "match": "release" }],
|
||||
"targets": [{ "platform": "discord", "channelId": "CHANNEL_ID" }]
|
||||
}
|
||||
```
|
||||
|
||||
Routes belong to **groups** (KV `config:groups`) that scope admin access and can restrict which org/user events flow in — including which source platform (`providers`: `github` / `gitea`). See `config.example.yaml` and `docs/guide/configuration.md` for the full schema.
|
||||
|
||||
### Web UI (`/admin`)
|
||||
|
||||
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`.
|
||||
|
||||
**Access model.** Every group has `members` with a role: `owner` (manage group, members, invites; edit routes), `admin` (edit routes, view logs), or `viewer` (read-only). Super admins (`ADMIN_USER_IDS`) bypass everything. Legacy `adminIds` are read as owners. Owners generate single-use, 7-day invite links from the group page; group admins and viewers can browse, admins edit, owners administer. All admin operations (logins, group/route/member/invite changes) are recorded in the D1 `audit_logs` table, pruned after `AUDIT_RETENTION_DAYS` (default 90).
|
||||
|
||||
See `config.example.yaml` for full syntax examples.
|
||||
|
||||
### Filter Types
|
||||
|
||||
| Type | Matches | Notes |
|
||||
| --------- | -------------------------------------- | ------------------------------------------------------------------------------------------------------------------ |
|
||||
| `event` | `push`, `pull_request`, `issues`, etc. | GitHub event name |
|
||||
| `repo` | `org/repo` full name | |
|
||||
| `actor` | Sender login | |
|
||||
| `action` | `opened`, `closed`, `published`, etc. | |
|
||||
| `branch` | Branch name | Works for push, PR/review, create/delete, workflow_run, workflow_job, check_suite, deployment, code_scanning_alert |
|
||||
| `keyword` | Text in payload body | All filters support `*`/`?` globs and `/regex/` patterns (case-insensitive) |
|
||||
|
||||
Set `exclude: true` to invert any filter. See the [Filter Tutorial](https://webhooker.docs.worldexecute.me/guide/filters) for the pattern syntax.
|
||||
|
||||
## 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
|
||||
|
||||
## GitHub App Setup
|
||||
|
||||
### 1. Create App
|
||||
|
||||
1. Go to <https://github.com/settings/apps/new>
|
||||
2. Fill in:
|
||||
- **GitHub App name**: `WebHooker` (or your choice)
|
||||
- **Homepage URL**: your domain
|
||||
- **Webhook URL**: `https://your-domain/webhook`
|
||||
- **Webhook secret**: generate and copy to `GITHUB_WEBHOOK_SECRET`
|
||||
3. Set permissions:
|
||||
- **Repository permissions**: Contents (read), Issues (write), Pull requests (write), Metadata (read), Checks (read), Deployments (read), Discussions (read), Code scanning alerts (read), Dependabot alerts (read)
|
||||
- **Organization permissions**: Members (read) — if needed
|
||||
4. Subscribe to events:
|
||||
- Push, Pull request, Issues, Issue comment, Workflow run, Workflow job, Status, Deployment, Deployment status, Ping, Release, Create, Delete, Star, Fork, Check run, Check suite, Pull request review, Pull request review comment, Commit comment, Member, Label, Milestone, Discussion, Discussion comment, Repository, Code scanning alert, Dependabot alert
|
||||
5. Generate private key — `GITHUB_PRIVATE_KEY` is currently unused by the code (only client ID/secret power the OAuth flow), so it is optional; store it if you later enable GitHub App authentication.
|
||||
|
||||
### 2. Install App
|
||||
|
||||
1. After creation, go to the App settings page
|
||||
2. Click "Install App" → select org/user
|
||||
3. Choose repositories to monitor
|
||||
|
||||
### 3. Configure OAuth
|
||||
|
||||
1. Go to App → OAuth settings
|
||||
2. Set **Callback URL**: `https://your-domain/auth/github/callback`
|
||||
3. Copy Client ID and Client Secret to env
|
||||
|
||||
## Discord Bot Setup
|
||||
|
||||
Create a bot at <https://discord.com/developers/applications>, copy its token to `DISCORD_TOKEN`.
|
||||
|
||||
### OAuth2 Invite
|
||||
|
||||
Add the bot to your server with the `bot` scope and the following permissions:
|
||||
|
||||
| Permission | Value | Why |
|
||||
| ------------------------ | -------------- | ----------------------------------------------- |
|
||||
| View Channels | `1024` | See the target channel to post messages |
|
||||
| Send Messages | `2048` | Send embeds/messages to channels |
|
||||
| Send Messages in Threads | `274877906944` | Send to threads when a route targets `threadId` |
|
||||
|
||||
Combined permission integer: `274877910016`
|
||||
|
||||
Invite URL (replace `CLIENT_ID` with your bot's client ID). The `applications.commands` scope is required so the slash / context-menu commands can be registered:
|
||||
|
||||
```
|
||||
https://discord.com/oauth2/authorize?client_id=YOUR_BOT_CLIENT_ID&permissions=274877910016&scope=bot+applications.commands
|
||||
```
|
||||
|
||||
### Interactions Endpoint
|
||||
|
||||
Copy the application **Public Key** (Developer Portal → General Information) to `DISCORD_PUBLIC_KEY` and set the **Interactions Endpoint URL** to `https://your-domain/discord/interactions`. All interactions (slash commands, buttons, modals) are verified with Ed25519 signatures.
|
||||
|
||||
The bot never connects to the Discord Gateway, so it shows as **offline** — messaging is unaffected (always REST).
|
||||
|
||||
### 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). Comments are posted using **your own** linked GitHub account (OAuth), and permission is delegated to GitHub — if GitHub rejects the action (e.g. editing someone else's comment) the bot tells you so. All replies are ephemeral (only you see them).
|
||||
|
||||
**1. Link your account** (once):
|
||||
|
||||
```
|
||||
/gh login → returns an ephemeral link to authorize your GitHub account
|
||||
/gh logout → unlink your GitHub account
|
||||
```
|
||||
|
||||
**2. Add / edit / delete a comment** — two equivalent ways:
|
||||
|
||||
- **Right-click a notification** (recommended): right-click a bot-issued issue / PR / comment notification → **Apps** → **GitHub: 添加评论 / 编辑评论 / 删除评论**. The target is auto-extracted from the notification embed; no link needed.
|
||||
- **Slash command with a link**:
|
||||
|
||||
```
|
||||
/gh comment add link:<issue or PR url> e.g. https://github.com/owner/repo/issues/123
|
||||
/gh comment edit link:<comment url> url must contain #issuecomment-<id>
|
||||
/gh comment del link:<comment url> url must contain #issuecomment-<id>
|
||||
```
|
||||
|
||||
For `edit` / `del`, copy the specific comment link on GitHub (comment ⋯ menu → **Copy link**). `add` / `edit` open a modal to enter/adjust the comment body (prefilled for edit).
|
||||
|
||||
**3. Merge / close a PR** — notifications for open PRs include **合并 / 关闭** (merge/close) buttons:
|
||||
|
||||
- Clicking a button merges (squash) or closes the PR as **your linked** GitHub account; GitHub enforces permission. On success the buttons are removed from the notification and the result is shown in an ephemeral reply.
|
||||
|
||||
**Requirements:**
|
||||
|
||||
| Item | How |
|
||||
| ------------ | --------------------------------------------------------------------- |
|
||||
| Public key | `DISCORD_PUBLIC_KEY` set + Interactions Endpoint URL configured |
|
||||
| Invite scope | Bot invited with `applications.commands` (see invite URL above) |
|
||||
| OAuth | `GITHUB_CLIENT_ID` / `GITHUB_CLIENT_SECRET` and `BASE_URL` configured |
|
||||
| User linked | Each user runs `/gh login` first |
|
||||
|
||||
## Telegram Bot Setup
|
||||
|
||||
1. Create a bot with [@BotFather](https://t.me/BotFather) and copy its token to `TELEGRAM_TOKEN`.
|
||||
2. (Optional) Set `TELEGRAM_WEBHOOK_SECRET`; the webhook registration passes it to Telegram as the `secret_token`, and `POST /telegram/webhook` verifies it with a timing-safe compare.
|
||||
3. The worker syncs the webhook from the scheduled trigger (`setWebhook` to `{BASE_URL}/telegram/webhook`), so no manual `setWebhook` call is needed — just make sure `BASE_URL` is set.
|
||||
4. Add the bot to a group (or enable topics) and route events to `chatId` / `topicId` in the route config.
|
||||
|
||||
In Telegram, `/gh` commands work by replying to a notification message:
|
||||
|
||||
- `/gh login` — link your GitHub account (returns an OAuth link)
|
||||
- `/gh logout` — unlink
|
||||
- `/gh comment <text>` — reply to an issue/PR notification to comment as yourself
|
||||
- `/gh merge` / `/gh close` — reply to a PR notification to merge/close it
|
||||
|
||||
Avatars are rendered as a link-preview card using the built-in `GET /api/richheader` (overridable with `TELEGRAM_RICH_HEADER_HOST`).
|
||||
|
||||
## Deployment
|
||||
|
||||
```bash
|
||||
# Set secrets in Cloudflare
|
||||
bunx wrangler secret put GITHUB_WEBHOOK_SECRET
|
||||
bunx wrangler secret put GITHUB_CLIENT_ID
|
||||
bunx wrangler secret put GITHUB_CLIENT_SECRET
|
||||
bunx wrangler secret put DISCORD_TOKEN
|
||||
bunx wrangler secret put DISCORD_PUBLIC_KEY
|
||||
bunx wrangler secret put TELEGRAM_TOKEN
|
||||
bunx wrangler secret put ADMIN_USER_IDS
|
||||
|
||||
# Create KV namespace
|
||||
bunx wrangler kv namespace create KV
|
||||
# Update wrangler.jsonc with the KV namespace ID
|
||||
|
||||
# Create D1 database and run migrations
|
||||
bunx wrangler d1 create webhooker
|
||||
# Update wrangler.jsonc d1_databases with the database ID
|
||||
bun run db:migrate:prod # apply migrations to the remote D1 database
|
||||
|
||||
# Deploy
|
||||
bunx wrangler deploy
|
||||
```
|
||||
|
||||
## Development
|
||||
|
||||
```bash
|
||||
bun run dev # Nuxt dev server (HMR)
|
||||
bun run typecheck # Type checking
|
||||
bun run lint # ESLint
|
||||
bun test # Unit tests
|
||||
```
|
||||
|
||||
## Supported Events
|
||||
|
||||
| Event | Formatter |
|
||||
| ----------------------------- | ------------------------------------------------------- |
|
||||
| `push` | Commit list, branch, author |
|
||||
| `pull_request` | PR title, branch, diff stats |
|
||||
| `issues` | Issue title, labels, assignees |
|
||||
| `issue_comment` | Comment body, issue reference |
|
||||
| `workflow_run` | Workflow status, conclusion, duration (edited in place) |
|
||||
| `workflow_job` | Job name, status, conclusion |
|
||||
| `status` | Commit status, context, state |
|
||||
| `deployment` | Environment, ref, task |
|
||||
| `deployment_status` | Environment, status, commit ref |
|
||||
| `check_run` | Status, conclusion, details URL (edited in place) |
|
||||
| `check_suite` | Suite conclusion, head branch, commit |
|
||||
| `ping` | Webhook confirmation |
|
||||
| `release` | Tag, body, assets |
|
||||
| `create` / `delete` | Branch/tag creation/deletion |
|
||||
| `star` | Star count, repository |
|
||||
| `fork` | Fork source → target |
|
||||
| `pull_request_review` | Review state, body preview |
|
||||
| `pull_request_review_comment` | Inline code comment, file path, line |
|
||||
| `commit_comment` | Commit SHA, comment body |
|
||||
| `member` | Collaborator add/remove |
|
||||
| `label` | Label name, color, description |
|
||||
| `milestone` | Progress bar, open/closed counts, due date |
|
||||
| `discussion` | Discussion title, category, action |
|
||||
| `discussion_comment` | Comment body, discussion reference |
|
||||
| `repository` | Repo rename/transfer details |
|
||||
| `code_scanning_alert` | Severity, rule ID, file path |
|
||||
| `dependabot_alert` | Severity, package, vulnerable range, fix version |
|
||||
|
||||
Any other event type falls back to the generic formatter (event type, action, actor, repo, raw payload).
|
||||
|
||||
## License
|
||||
|
||||
MIT
|
||||
# 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 (not currently used by the code; kept for compatibility) |
|
||||
| `GITHUB_PRIVATE_KEY` | App private key (PKCS#8 PEM; not currently used by the code; kept for compatibility) |
|
||||
| `GITHUB_CLIENT_ID` | OAuth client ID |
|
||||
| `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) |
|
||||
| `DOCS_URL` | Optional docs site URL used by the landing page |
|
||||
| `GITHUB_REPO_URL` | Optional GitHub repo URL used by the landing page |
|
||||
| `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). The legacy singular `target` field is still migrated automatically. There is no fallback to a default channel.
|
||||
|
||||
Set `discordRoleIds` on a route to ping Discord roles (身份组) whenever it fires:
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "release-notify",
|
||||
"name": "Notify on Release",
|
||||
"enabled": true,
|
||||
"groupId": "default",
|
||||
"discordRoleIds": ["111111111111111111"],
|
||||
"filters": [{ "type": "event", "match": "release" }],
|
||||
"targets": [{ "platform": "discord", "channelId": "CHANNEL_ID" }]
|
||||
}
|
||||
```
|
||||
|
||||
Routes belong to **groups** (KV `config:groups`) that scope admin access and can restrict which org/user events flow in — including which source platform (`providers`: `github` / `gitea`). See `config.example.yaml` and `docs/guide/configuration.md` for the full schema.
|
||||
|
||||
### Web UI (`/admin`)
|
||||
|
||||
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`.
|
||||
|
||||
**Access model.** Every group has `members` with a role: `owner` (manage group, members, invites; edit routes), `admin` (edit routes, view logs), or `viewer` (read-only). Super admins (`ADMIN_USER_IDS`) bypass everything. Legacy `adminIds` are read as owners. Owners generate single-use, 7-day invite links from the group page; group admins and viewers can browse, admins edit, owners administer. All admin operations (logins, group/route/member/invite changes) are recorded in the D1 `audit_logs` table, pruned after `AUDIT_RETENTION_DAYS` (default 90).
|
||||
|
||||
See `config.example.yaml` for full syntax examples.
|
||||
|
||||
### Filter Types
|
||||
|
||||
| Type | Matches | Notes |
|
||||
| --------- | -------------------------------------- | ------------------------------------------------------------------------------------------------------------------ |
|
||||
| `event` | `push`, `pull_request`, `issues`, etc. | GitHub event name |
|
||||
| `repo` | `org/repo` full name | |
|
||||
| `actor` | Sender login | |
|
||||
| `action` | `opened`, `closed`, `published`, etc. | |
|
||||
| `branch` | Branch name | Works for push, PR/review, create/delete, workflow_run, workflow_job, check_suite, deployment, code_scanning_alert |
|
||||
| `keyword` | Text in payload body | All filters support `*`/`?` globs and `/regex/` patterns (case-insensitive) |
|
||||
|
||||
Set `exclude: true` to invert any filter. See the [Filter Tutorial](https://webhooker.docs.worldexecute.me/guide/filters) for the pattern syntax.
|
||||
|
||||
## 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
|
||||
|
||||
## GitHub App Setup
|
||||
|
||||
### 1. Create App
|
||||
|
||||
1. Go to <https://github.com/settings/apps/new>
|
||||
2. Fill in:
|
||||
- **GitHub App name**: `WebHooker` (or your choice)
|
||||
- **Homepage URL**: your domain
|
||||
- **Webhook URL**: `https://your-domain/webhook`
|
||||
- **Webhook secret**: generate and copy to `GITHUB_WEBHOOK_SECRET`
|
||||
3. Set permissions:
|
||||
- **Repository permissions**: Contents (read), Issues (write), Pull requests (write), Metadata (read), Checks (read), Deployments (read), Discussions (read), Code scanning alerts (read), Dependabot alerts (read)
|
||||
- **Organization permissions**: Members (read) — if needed
|
||||
4. Subscribe to events:
|
||||
- Push, Pull request, Issues, Issue comment, Workflow run, Workflow job, Status, Deployment, Deployment status, Ping, Release, Create, Delete, Star, Fork, Check run, Check suite, Pull request review, Pull request review comment, Commit comment, Member, Label, Milestone, Discussion, Discussion comment, Repository, Code scanning alert, Dependabot alert
|
||||
5. Generate private key — `GITHUB_PRIVATE_KEY` is currently unused by the code (only client ID/secret power the OAuth flow), so it is optional; store it if you later enable GitHub App authentication.
|
||||
|
||||
### 2. Install App
|
||||
|
||||
1. After creation, go to the App settings page
|
||||
2. Click "Install App" → select org/user
|
||||
3. Choose repositories to monitor
|
||||
|
||||
### 3. Configure OAuth
|
||||
|
||||
1. Go to App → OAuth settings
|
||||
2. Set **Callback URL**: `https://your-domain/auth/github/callback`
|
||||
3. Copy Client ID and Client Secret to env
|
||||
|
||||
## Discord Bot Setup
|
||||
|
||||
Create a bot at <https://discord.com/developers/applications>, copy its token to `DISCORD_TOKEN`.
|
||||
|
||||
### OAuth2 Invite
|
||||
|
||||
Add the bot to your server with the `bot` scope and the following permissions:
|
||||
|
||||
| Permission | Value | Why |
|
||||
| ------------------------ | -------------- | ----------------------------------------------- |
|
||||
| View Channels | `1024` | See the target channel to post messages |
|
||||
| Send Messages | `2048` | Send embeds/messages to channels |
|
||||
| Send Messages in Threads | `274877906944` | Send to threads when a route targets `threadId` |
|
||||
|
||||
Combined permission integer: `274877910016`
|
||||
|
||||
Invite URL (replace `CLIENT_ID` with your bot's client ID). The `applications.commands` scope is required so the slash / context-menu commands can be registered:
|
||||
|
||||
```
|
||||
https://discord.com/oauth2/authorize?client_id=YOUR_BOT_CLIENT_ID&permissions=274877910016&scope=bot+applications.commands
|
||||
```
|
||||
|
||||
### Interactions Endpoint
|
||||
|
||||
Copy the application **Public Key** (Developer Portal → General Information) to `DISCORD_PUBLIC_KEY` and set the **Interactions Endpoint URL** to `https://your-domain/discord/interactions`. All interactions (slash commands, buttons, modals) are verified with Ed25519 signatures.
|
||||
|
||||
The bot never connects to the Discord Gateway, so it shows as **offline** — messaging is unaffected (always REST).
|
||||
|
||||
### 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). Comments are posted using **your own** linked GitHub account (OAuth), and permission is delegated to GitHub — if GitHub rejects the action (e.g. editing someone else's comment) the bot tells you so. All replies are ephemeral (only you see them).
|
||||
|
||||
**1. Link your account** (once):
|
||||
|
||||
```
|
||||
/gh login → returns an ephemeral link to authorize your GitHub account
|
||||
/gh logout → unlink your GitHub account
|
||||
```
|
||||
|
||||
**2. Add / edit / delete a comment** — two equivalent ways:
|
||||
|
||||
- **Right-click a notification** (recommended): right-click a bot-issued issue / PR / comment notification → **Apps** → **GitHub: 添加评论 / 编辑评论 / 删除评论**. The target is auto-extracted from the notification embed; no link needed.
|
||||
- **Slash command with a link**:
|
||||
|
||||
```
|
||||
/gh comment add link:<issue or PR url> e.g. https://github.com/owner/repo/issues/123
|
||||
/gh comment edit link:<comment url> url must contain #issuecomment-<id>
|
||||
/gh comment del link:<comment url> url must contain #issuecomment-<id>
|
||||
```
|
||||
|
||||
For `edit` / `del`, copy the specific comment link on GitHub (comment ⋯ menu → **Copy link**). `add` / `edit` open a modal to enter/adjust the comment body (prefilled for edit).
|
||||
|
||||
**3. Merge / close a PR** — notifications for open PRs include **合并 / 关闭** (merge/close) buttons:
|
||||
|
||||
- Clicking a button merges (squash) or closes the PR as **your linked** GitHub account; GitHub enforces permission. On success the buttons are removed from the notification and the result is shown in an ephemeral reply.
|
||||
|
||||
**Requirements:**
|
||||
|
||||
| Item | How |
|
||||
| ------------ | --------------------------------------------------------------------- |
|
||||
| Public key | `DISCORD_PUBLIC_KEY` set + Interactions Endpoint URL configured |
|
||||
| Invite scope | Bot invited with `applications.commands` (see invite URL above) |
|
||||
| OAuth | `GITHUB_CLIENT_ID` / `GITHUB_CLIENT_SECRET` and `BASE_URL` configured |
|
||||
| User linked | Each user runs `/gh login` first |
|
||||
|
||||
## Telegram Bot Setup
|
||||
|
||||
1. Create a bot with [@BotFather](https://t.me/BotFather) and copy its token to `TELEGRAM_TOKEN`.
|
||||
2. (Optional) Set `TELEGRAM_WEBHOOK_SECRET`; the webhook registration passes it to Telegram as the `secret_token`, and `POST /telegram/webhook` verifies it with a timing-safe compare.
|
||||
3. The worker syncs the webhook from the scheduled trigger (`setWebhook` to `{BASE_URL}/telegram/webhook`), so no manual `setWebhook` call is needed — just make sure `BASE_URL` is set.
|
||||
4. Add the bot to a group (or enable topics) and route events to `chatId` / `topicId` in the route config.
|
||||
|
||||
In Telegram, `/gh` commands work by replying to a notification message:
|
||||
|
||||
- `/gh login` — link your GitHub account (returns an OAuth link)
|
||||
- `/gh logout` — unlink
|
||||
- `/gh comment <text>` — reply to an issue/PR notification to comment as yourself
|
||||
- `/gh merge` / `/gh close` — reply to a PR notification to merge/close it
|
||||
|
||||
Avatars are rendered as a link-preview card using the built-in `GET /api/richheader` (overridable with `TELEGRAM_RICH_HEADER_HOST`).
|
||||
|
||||
## Deployment
|
||||
|
||||
```bash
|
||||
# Set secrets in Cloudflare
|
||||
bunx wrangler secret put GITHUB_WEBHOOK_SECRET
|
||||
bunx wrangler secret put GITHUB_CLIENT_ID
|
||||
bunx wrangler secret put GITHUB_CLIENT_SECRET
|
||||
bunx wrangler secret put DISCORD_TOKEN
|
||||
bunx wrangler secret put DISCORD_PUBLIC_KEY
|
||||
bunx wrangler secret put TELEGRAM_TOKEN
|
||||
bunx wrangler secret put ADMIN_USER_IDS
|
||||
|
||||
# Create KV namespace
|
||||
bunx wrangler kv namespace create KV
|
||||
# Update wrangler.jsonc with the KV namespace ID
|
||||
|
||||
# Create D1 database and run migrations
|
||||
bunx wrangler d1 create webhooker
|
||||
# Update wrangler.jsonc d1_databases with the database ID
|
||||
bun run db:migrate:prod # apply migrations to the remote D1 database
|
||||
|
||||
# Deploy
|
||||
bunx wrangler deploy
|
||||
```
|
||||
|
||||
## Development
|
||||
|
||||
```bash
|
||||
bun run dev # Nuxt dev server (HMR)
|
||||
bun run typecheck # Type checking
|
||||
bun run lint # ESLint
|
||||
bun test # Unit tests
|
||||
```
|
||||
|
||||
## Supported Events
|
||||
|
||||
| Event | Formatter |
|
||||
| ----------------------------- | ------------------------------------------------------- |
|
||||
| `push` | Commit list, branch, author |
|
||||
| `pull_request` | PR title, branch, diff stats |
|
||||
| `issues` | Issue title, labels, assignees |
|
||||
| `issue_comment` | Comment body, issue reference |
|
||||
| `workflow_run` | Workflow status, conclusion, duration (edited in place) |
|
||||
| `workflow_job` | Job name, status, conclusion |
|
||||
| `status` | Commit status, context, state |
|
||||
| `deployment` | Environment, ref, task |
|
||||
| `deployment_status` | Environment, status, commit ref |
|
||||
| `check_run` | Status, conclusion, details URL (edited in place) |
|
||||
| `check_suite` | Suite conclusion, head branch, commit |
|
||||
| `ping` | Webhook confirmation |
|
||||
| `release` | Tag, body, assets |
|
||||
| `create` / `delete` | Branch/tag creation/deletion |
|
||||
| `star` | Star count, repository |
|
||||
| `fork` | Fork source → target |
|
||||
| `pull_request_review` | Review state, body preview |
|
||||
| `pull_request_review_comment` | Inline code comment, file path, line |
|
||||
| `commit_comment` | Commit SHA, comment body |
|
||||
| `member` | Collaborator add/remove |
|
||||
| `label` | Label name, color, description |
|
||||
| `milestone` | Progress bar, open/closed counts, due date |
|
||||
| `discussion` | Discussion title, category, action |
|
||||
| `discussion_comment` | Comment body, discussion reference |
|
||||
| `repository` | Repo rename/transfer details |
|
||||
| `code_scanning_alert` | Severity, rule ID, file path |
|
||||
| `dependabot_alert` | Severity, package, vulnerable range, fix version |
|
||||
|
||||
Any other event type falls back to the generic formatter (event type, action, actor, repo, raw payload).
|
||||
|
||||
## 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>
|
||||
|
||||
|
|
|
|||
|
|
@ -1,356 +1,356 @@
|
|||
# Configuration
|
||||
|
||||
## Secrets
|
||||
|
||||
WebHooker requires several secrets to function. For local development, store them in `.dev.vars`. For production, use Cloudflare Worker Secrets.
|
||||
|
||||
### 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 |
|
||||
| `GITHUB_CLIENT_SECRET` | OAuth client secret from App settings |
|
||||
| `DISCORD_TOKEN` | Discord bot token |
|
||||
| `TELEGRAM_TOKEN` | Telegram bot token (from BotFather) — required for Telegram routes |
|
||||
|
||||
> [!NOTE]
|
||||
> `GITHUB_APP_ID` and `GITHUB_PRIVATE_KEY` are not currently used by the code — the
|
||||
> OAuth flow only needs `GITHUB_CLIENT_ID` / `GITHUB_CLIENT_SECRET`. They are kept
|
||||
> in the schema for compatibility in case GitHub App authentication is added later.
|
||||
|
||||
### 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) |
|
||||
| `TELEGRAM_RICH_HEADER_HOST` | Base URL of an external rich-header service; when unset, the built-in `GET /api/richheader` serves the Telegram avatar card | Built-in `/api/richheader` |
|
||||
| `BASE_URL` | Public URL for OAuth callbacks | `http://localhost:8787` |
|
||||
| `ADMIN_USER_IDS` | Comma-separated GitHub user IDs (or logins) allowed to access the Web UI | Disabled |
|
||||
| `ALLOW_SELF_SIGNUP` | When enabled (`1`/`true`), GitHub users without any group access get a personal group on first login instead of `403` | Disabled |
|
||||
| `AUDIT_RETENTION_DAYS` | Audit-log retention in days for the scheduled cleanup | `90` |
|
||||
|
||||
## Webhook Providers
|
||||
|
||||
WebHooker ingests webhooks from multiple forges through the same `POST /webhook` endpoint; the provider is auto-detected from the request headers, so point every forge's webhook at `{BASE_URL}/webhook`.
|
||||
|
||||
| Provider | Event header | Signature header | Signature format | Secret |
|
||||
| -------- | ---------------- | --------------------- | -------------------------- | ----------------------- |
|
||||
| GitHub | `X-GitHub-Event` | `X-Hub-Signature-256` | `sha256=<hex>` HMAC-SHA256 | `GITHUB_WEBHOOK_SECRET` |
|
||||
| Gitea | `X-Gitea-Event` | `X-Gitea-Signature` | plain hex HMAC-SHA256 | `GITEA_WEBHOOK_SECRET` |
|
||||
|
||||
Gitea payloads are normalized to the same internal shape as GitHub events, so routes, filters, and the 28 formatters work unchanged. Unknown or unmapped Gitea events fall back to the generic formatter. Repository/commit/user links are derived from the payload's `repository.html_url`, so they point at your Gitea instance.
|
||||
|
||||
## Web UI
|
||||
|
||||
WebHooker ships with a built-in config console at `/admin` for managing routes in the browser. It is protected by GitHub OAuth plus an admin whitelist.
|
||||
|
||||
### Setup
|
||||
|
||||
1. Configure `ADMIN_USER_IDS` with the GitHub user IDs allowed to manage everything. Logins are also accepted, e.g. `ADMIN_USER_IDS=12345,RhenCloud`. If unset, the console is disabled (unless `ALLOW_SELF_SIGNUP` is enabled).
|
||||
2. Open `/admin` and sign in with GitHub.
|
||||
3. Users without any access get `403`, except when `ALLOW_SELF_SIGNUP=1` (they receive a personal group) or when they follow a group [invite link](#invites).
|
||||
|
||||
### Endpoints
|
||||
|
||||
The console is served as an SPA at `/admin`; its tabs are deep-linkable via the URL path (`/admin/groups`, `/admin/logs`, `/admin/audit`). URLs outside `/admin` that do not match an endpoint below return a plain `404` instead of the console.
|
||||
|
||||
| Endpoint | Description |
|
||||
| ----------------------------------------------- | ----------------------------------------------------------------- |
|
||||
| `GET /admin` | Config console UI |
|
||||
| `GET /admin/login` | Start GitHub OAuth sign-in |
|
||||
| `GET /admin/logout` | Destroy session |
|
||||
| `GET /admin/invite?token=…` | Accept a group invite (browser page) |
|
||||
| `GET /admin/api/me` | Current session, scope, groups, and roles |
|
||||
| `GET /admin/api/routes` | List routes (scoped to access) |
|
||||
| `PUT /admin/api/routes` | Replace routes (owner/admin per group) |
|
||||
| `GET /admin/api/groups` | List groups + the signed-in user's role each |
|
||||
| `PUT /admin/api/groups` | Replace groups (super: all; owner: own only) |
|
||||
| `GET /admin/api/groups/:id/routes` | List a group's routes |
|
||||
| `PUT /admin/api/groups/:id/routes` | Replace a group's routes (owner/admin) |
|
||||
| `PUT /admin/api/groups/:id/rename` | Rename a group (owner); routes, webhook secret and invites follow |
|
||||
| `GET /admin/api/logs` | Send logs (scoped to accessible routes) |
|
||||
| `GET /admin/api/logs/:id` | Single send-log entry (scoped) |
|
||||
| `POST /admin/api/groups/:id/invites` | Create an invite link (owner) |
|
||||
| `GET /admin/api/groups/:id/invites` | List pending invites (owner) |
|
||||
| `DELETE /admin/api/invites/:token` | Revoke an invite (owner) |
|
||||
| `GET /admin/api/audit` | Audit log (scoped to accessible groups) |
|
||||
| `GET /admin/api/groups/:id/webhook` | Group webhook endpoint info (owner) |
|
||||
| `POST /admin/api/groups/:id/webhook/regenerate` | Generate/regenerate the group webhook secret (owner) |
|
||||
| `DELETE /admin/api/groups/:id/webhook` | Disable the group webhook ingress (owner) |
|
||||
|
||||
The console lets you add, edit, delete, and toggle routes. Saved routes are written to KV `config:routes` immediately and the config cache is invalidated so the webhook pipeline picks them up on the next run.
|
||||
|
||||
## Webhook Endpoints
|
||||
|
||||
### Global endpoint (`POST /webhook`)
|
||||
|
||||
The legacy global endpoint verifies payloads against the operator's global secrets (`GITHUB_WEBHOOK_SECRET`, `GITEA_WEBHOOK_SECRET`) and dispatches into **all** routes. GitHub App installations deliver here; use `installationId` on groups to keep tenants isolated.
|
||||
|
||||
### Per-group endpoint (`POST /webhook/{groupId}`)
|
||||
|
||||
Every group can opt into its own webhook ingress with an independent secret (generated from the group page — Webhook endpoint panel, owner role). Payloads are verified against the **group's** secret instead of the global ones, and only that group's routes are eligible. This is how SaaS users configure Gitea, classic GitHub, or custom webhooks without sharing (or knowing) the operator's secrets.
|
||||
|
||||
- Supported for any provider: GitHub (`X-Hub-Signature-256`), Gitea (`X-Gitea-Signature`), custom (`X-WebHooker-Signature`)
|
||||
- The secret is a 64-char hex string; regenerate from the console invalidates the old one immediately
|
||||
- Delivery-id dedup keys are tenant-scoped (`delivery:{groupId}:{id}`)
|
||||
- When the group has no secret (or no longer exists) the endpoint returns `404`
|
||||
|
||||
### Custom webhooks
|
||||
|
||||
Post arbitrary JSON to `POST /webhook/{groupId}` (or the global endpoint) with the body signed as `X-WebHooker-Signature: sha256=<hmac-sha256 hex of the raw body>` using the group's secret. The payload becomes a `custom` event that flows through the normal route pipeline — create a route with `event: custom` (there is a console template) and it dispatches to that route's targets, records `send_logs`, and appears in the group's webhook log channel.
|
||||
|
||||
Payload schema:
|
||||
|
||||
```json
|
||||
{
|
||||
"title": "Deploy failed",
|
||||
"description": "Prod rollout failed at 12:03 UTC",
|
||||
"color": "red",
|
||||
"url": "https://ci.example.com/runs/42",
|
||||
"repo": "acme/widget",
|
||||
"author": {
|
||||
"name": "alice",
|
||||
"iconUrl": "https://…/alice.png",
|
||||
"url": "https://github.com/alice"
|
||||
},
|
||||
"fields": [{ "name": "Env", "value": "prod", "inline": true }],
|
||||
"footer": "my-monitor",
|
||||
"deliveryId": "alert-123"
|
||||
}
|
||||
```
|
||||
|
||||
| Field | Type | Description |
|
||||
| ------------- | -------- | ---------------------------------------------------------------------------------------------------------------- |
|
||||
| `title` | string | Message title (falls back to "Custom message") |
|
||||
| `description` | string | Optional message body |
|
||||
| `color` | string | Optional embed color: a word (`red`, `green`, `yellow`, `blue`, `purple`, `orange`, `cyan`, `gray`) or `#rrggbb` |
|
||||
| `url` | string | Optional link for the title |
|
||||
| `repo` | string | Optional `owner/repo`; prefixes the title and is used as the footer |
|
||||
| `author` | object | Optional `{ name, iconUrl, url }` |
|
||||
| `fields` | object[] | Optional embed fields `{ name, value, inline }` |
|
||||
| `footer` | string | Optional footer override |
|
||||
| `deliveryId` | string | Optional id for sender-side dedup (retries) |
|
||||
|
||||
### GitHub App tenant isolation
|
||||
|
||||
When the GitHub App is installed, its events arrive at the global endpoint for **every** installation. To keep tenants apart, bind each group to the installation id that should feed it: `"installationId": 12345678`. The id is visible in the App's installation webhook payload (`installation.id`) or on the GitHub App installation page URL. Events from any other installation are rejected for that group even if its `owners` list is empty. Groups without `installationId` keep the legacy behavior (`owners` filtering).
|
||||
|
||||
Binding is **auto-configured** — the GitHub App's _Setup URL_ should point to `{BASE_URL}/auth/github/install`. Right after a user installs the App, the browser lands there and they choose where the installation binds: a **new group** (`inst-{installationId}`, default) or any **existing group they own** (owner role checked again on submit; `POST /auth/github/install/bind` performs the provisioning). No manual id entry is needed. As a fallback (e.g. when the Setup URL is not configured), the `installation.created` webhook event creates/binds the group automatically — existing groups whose `owners` match the installing account are bound, otherwise a dedicated `inst-{installationId}` group is created. Then just add routes/members in the console.
|
||||
|
||||
## Routes
|
||||
|
||||
Routes define which events get forwarded to which channel (Discord or Telegram). They are stored in Cloudflare KV under the key `config:routes` as a JSON array.
|
||||
|
||||
There are **no default routes** — each route must define its own target. If no routes are configured, no events are forwarded.
|
||||
|
||||
### Route Schema
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "unique-route-id",
|
||||
"name": "Human-readable name",
|
||||
"enabled": true,
|
||||
"groupId": "my-group",
|
||||
"fallback": false,
|
||||
"stop": false,
|
||||
"discordRoleIds": ["111111111111111111"],
|
||||
"filters": [
|
||||
{ "type": "event", "match": "push" },
|
||||
{ "type": "repo", "match": "org/repo", "exclude": false }
|
||||
],
|
||||
"targets": [
|
||||
{
|
||||
"platform": "discord",
|
||||
"channelId": "REQUIRED_CHANNEL_ID",
|
||||
"threadId": "OPTIONAL_THREAD_ID"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
Each entry of `targets` is a push destination, so one route can forward to several channels at once (e.g. a Discord channel **and** a Telegram group). `target.platform` selects the platform: `discord` (default) or `telegram`. For **Discord**, `target.channelId` is required (a thread in `target.threadId` is optional). For **Telegram**, `target.chatId` (the group/supergroup chat id, e.g. `-1001234567890`) is required and `target.topicId` (the `message_thread_id` of a topic, equivalent of a Discord thread) is optional. There is no fallback to a default channel.
|
||||
|
||||
### Discord Role Mentions
|
||||
|
||||
Set `discordRoleIds` on a route to ping one or more Discord roles (身份组) whenever that route fires. The mention (`<@&roleId>`) is prepended to the message content of every **Discord** target of the route; Telegram targets ignore this field. Mentions only trigger notifications when the bot has the `Mention Everyone` permission (or the role is marked mentionable), and the bot must be able to see the role.
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "release-notify",
|
||||
"name": "Notify on Release",
|
||||
"enabled": true,
|
||||
"groupId": "default",
|
||||
"discordRoleIds": ["111111111111111111", "222222222222222222"],
|
||||
"filters": [{ "type": "event", "match": "release" }],
|
||||
"targets": [{ "platform": "discord", "channelId": "REQUIRED_CHANNEL_ID" }]
|
||||
}
|
||||
```
|
||||
|
||||
You can add role ids in the admin console under _Discord role mentions_.
|
||||
|
||||
Other route fields:
|
||||
|
||||
| Field | Type | Required | Description |
|
||||
| ---------------- | -------- | -------- | ----------------------------------------------------------------------------------------------- |
|
||||
| `groupId` | string | Yes | Id of the [group](#groups) this route belongs to |
|
||||
| `fallback` | boolean | No | When `true`, fires only if no non-fallback route matched the event; its own filters are ignored |
|
||||
| `stop` | boolean | No | When `true` and this route matches, no further routes are evaluated for this event |
|
||||
| `discordRoleIds` | string[] | No | Discord role ids to ping when this route fires; applied to Discord targets only |
|
||||
|
||||
### Custom Route Example
|
||||
|
||||
```json
|
||||
[
|
||||
{
|
||||
"id": "backend-prs",
|
||||
"name": "Backend PRs",
|
||||
"enabled": true,
|
||||
"groupId": "backend-team",
|
||||
"filters": [
|
||||
{ "type": "repo", "match": "myorg/backend" },
|
||||
{ "type": "event", "match": "pull_request" },
|
||||
{ "type": "actor", "match": "[bot]", "exclude": true }
|
||||
],
|
||||
"targets": [
|
||||
{
|
||||
"platform": "telegram",
|
||||
"chatId": "-1001234567890",
|
||||
"topicId": "9876543210"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
## Groups
|
||||
|
||||
Routes belong to groups. Groups scope admin access and can restrict which events flow into them. They are stored in Cloudflare KV under the key `config:groups` as a JSON array.
|
||||
|
||||
### Group Schema
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "backend-team",
|
||||
"name": "Backend Team",
|
||||
"members": [
|
||||
{ "login": "rhencloud", "role": "owner" },
|
||||
{ "login": "octobot", "role": "admin" },
|
||||
{ "login": "reader", "role": "viewer" }
|
||||
],
|
||||
"owners": ["myorg"],
|
||||
"providers": ["github", "gitea"],
|
||||
"installationId": 12345678,
|
||||
"logTarget": { "platform": "discord", "channelId": "123456789", "threadId": "987654321" }
|
||||
}
|
||||
```
|
||||
|
||||
| Field | Type | Required | Description |
|
||||
| ---------------- | -------- | -------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `id` | string | Yes | Lowercase id (`a-z0-9`, `-`); referenced by each route's `groupId`. Editable: renaming a group re-points its routes, per-group webhook secret and pending invites |
|
||||
| `name` | string | Yes | Human-readable group name |
|
||||
| `members` | object[] | No | `{ login, role }` entries; role is `owner`, `admin`, or `viewer` |
|
||||
| `adminIds` | string[] | No | Deprecated legacy field; treated as `members` with role `owner` when present |
|
||||
| `owners` | string[] | No | Org/user logins whose events are accepted into this group; empty = all |
|
||||
| `providers` | string[] | No | Source platforms allowed into this group (`github`, `gitea`); empty = all |
|
||||
| `installationId` | number | No | GitHub App installation id bound to this group; only that installation's events are accepted (empty = all) |
|
||||
| `emoji` | boolean | No | Whether to include emoji in this group's messages (default `true`) |
|
||||
| `lang` | string | No | Message language for every route in this group (e.g. `en`, `zh`; custom via KV `i18n:<lang>`) — defaults to `en` |
|
||||
| `logTarget` | object | No | Webhook log channel: a Discord `{ platform, channelId, threadId? }` or Telegram `{ platform, chatId, topicId? }` target that receives a summary of every webhook the group's routes dispatch |
|
||||
|
||||
### Roles
|
||||
|
||||
Every group member has one of three roles. Super admins (`ADMIN_USER_IDS`) always bypass them.
|
||||
|
||||
| Role | View routes/logs | Edit routes | Manage members & invites | Edit group settings |
|
||||
| -------- | ---------------- | ----------- | ------------------------ | ------------------- |
|
||||
| `owner` | ✓ | ✓ | ✓ | ✓ (except `owners`) |
|
||||
| `admin` | ✓ | ✓ | ✗ | ✗ |
|
||||
| `viewer` | ✓ (read-only) | ✗ | ✗ | ✗ |
|
||||
|
||||
### Access Model
|
||||
|
||||
- **Super admins** (`ADMIN_USER_IDS`) see and edit every group and all routes; only they can edit a group's `owners` list.
|
||||
- **Owners** manage their group's routes, members, invites, name, id, `emoji`, and `providers`. They cannot remove the last owner or demote themselves when no other owner remains.
|
||||
- **Admins** edit routes inside their groups and view logs; **viewers** get a read-only console.
|
||||
- Group admin endpoints operate on a single group at a time via `/admin/api/groups/:id/routes`; `groupId` is forced from the path parameter.
|
||||
- The `owners` list restricts which event actors (sender logins) the group's routes will dispatch at all.
|
||||
- The `providers` list restricts which forge's events (`github`, `gitea`) the group's routes will dispatch. This lets you keep GitHub and Gitea groups separate even when org/user names collide.
|
||||
|
||||
### Webhook Log Channel
|
||||
|
||||
A group may set `logTarget` to a Discord channel/thread or Telegram chat/topic. Whenever the group's routes dispatch a webhook, a single summary message is sent there: the event type/action, the repo, the delivery id, and one line per route×target with an ✅/❌ outcome (including the error for failed sends). The message is green when every dispatch succeeded and red when any failed. The summary uses the group's message language. Log messages are sent best-effort and are not themselves recorded in the D1 send log.
|
||||
|
||||
### Invites
|
||||
|
||||
Owners (and super admins) can create single-use invite links valid for 7 days from the group's _Members_ panel. Accepting an invite adds the user with the invited role (`admin` or `viewer` — never `owner`); an existing `viewer` is upgraded to `admin`. Invites are stored in KV as `invite:{token}`.
|
||||
|
||||
### Self Sign-up
|
||||
|
||||
With `ALLOW_SELF_SIGNUP=1`, a GitHub user who has no group access gets a personal group (`u-{userId}`, owned by them) on first login instead of a `403`. This is the entry point for a fully self-service SaaS install; disable it to keep the console invite-only.
|
||||
|
||||
## Filter Types
|
||||
|
||||
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]` |
|
||||
| `action` | Event action | `opened`, `closed`, `published` |
|
||||
| `branch` | Branch name | `main`, `feature-?`, `/^release-/` |
|
||||
| `keyword` | Text in payload body | `deploy`, `/fix\s+\d+/` |
|
||||
|
||||
### Filter Behavior
|
||||
|
||||
- All filters in a route must match for the route to trigger (AND logic)
|
||||
- Set `"exclude": true` on any filter to invert it (NOT logic)
|
||||
- Every filter type supports the same pattern forms: plain text, `*`/`?` **globs** (`*` = any run, `?` = one character), and `/regular expression/` — all case-insensitive
|
||||
- Field filters (`event`/`repo`/`actor`/`action`/`branch`) glob-match the whole value; `keyword` globs and regexes search anywhere in the payload; plain `keyword` text is a substring search
|
||||
- Patterns longer than 200 characters are not compiled as glob/regex; an invalid `//`-wrapped regex matches nothing
|
||||
- `branch` filter works for push, pull_request, pull_request_review, pull_request_review_comment, create/delete, workflow_run, workflow_job, check_suite, deployment, and code_scanning_alert events
|
||||
|
||||
### Match Values
|
||||
|
||||
Filters accept either a single string or an array of strings:
|
||||
|
||||
```json
|
||||
{ "type": "event", "match": "push" }
|
||||
{ "type": "event", "match": ["push", "pull_request"] }
|
||||
```
|
||||
|
||||
## KV Storage Layout
|
||||
|
||||
| Key Pattern | Value | TTL |
|
||||
| ------------------------------ | ----------------------------------------------------------------------------- | ------------------ |
|
||||
| `config:routes` | JSON array of routes | Permanent |
|
||||
| `config:groups` | JSON array of groups | Permanent |
|
||||
| `session:{id}` | Admin session `{ userId, login }` | 7 days |
|
||||
| `token:{userId}` | `{ userId, accessToken, expiresAt, refreshToken? }` | 0.9 × token expiry |
|
||||
| `token-reverse:{sha256}` | User id for reverse lookup by token | 0.9 × token expiry |
|
||||
| `state:{hex}` | `{ redirectTo, expiresAt, discordUserId?, telegramUserId?, telegramChatId? }` | 600 seconds |
|
||||
| `invite:{token}` | `{ groupId, role, expiresAt, createdBy, note? }` | 7 days |
|
||||
| `invite:group:{id}` | Token index per group (keeps invite listing consistent) | Permanent |
|
||||
| `delivery:{id}` | Webhook delivery id (dedup marker) | 300 seconds |
|
||||
| `msg:{routeId}:{key}:{target}` | Message id tracking for in-place updates (e.g. `workflow_run` / `check_run`) | 7 days |
|
||||
| `cmd:guild:{id}` | Guild id whose commands were registered (dedup) | Permanent |
|
||||
| `cmd:registered:global` | Global command registration marker (dedup) | 1 day |
|
||||
| `config:discord-app-id` | Cached Discord application id | Permanent |
|
||||
| `i18n:{lang}` | Translation overrides merged on top of English | Permanent |
|
||||
|
||||
## D1 Storage Layout
|
||||
|
||||
The D1 database (`DB` binding, database `webhooker`) holds four tables:
|
||||
|
||||
| Table | Purpose |
|
||||
| ---------------- | ---------------------------------------------------------------------------------------------- |
|
||||
| `send_logs` | One row per dispatch attempt (route id, event, target, ok/error, duration, error code, detail) |
|
||||
| `audit_logs` | One row per admin operation (login/logout, group/route/member/invite changes) |
|
||||
| `discord_links` | Maps `discord_user_id` → `github_user_id` for `/gh` Discord commands |
|
||||
| `telegram_links` | Maps `telegram_user_id` → `github_user_id` for `/gh` Telegram commands |
|
||||
|
||||
`audit_logs` is pruned automatically by the scheduled trigger after `AUDIT_RETENTION_DAYS` (default 90).
|
||||
# Configuration
|
||||
|
||||
## Secrets
|
||||
|
||||
WebHooker requires several secrets to function. For local development, store them in `.dev.vars`. For production, use Cloudflare Worker Secrets.
|
||||
|
||||
### 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 |
|
||||
| `GITHUB_CLIENT_SECRET` | OAuth client secret from App settings |
|
||||
| `DISCORD_TOKEN` | Discord bot token |
|
||||
| `TELEGRAM_TOKEN` | Telegram bot token (from BotFather) — required for Telegram routes |
|
||||
|
||||
> [!NOTE]
|
||||
> `GITHUB_APP_ID` and `GITHUB_PRIVATE_KEY` are not currently used by the code — the
|
||||
> OAuth flow only needs `GITHUB_CLIENT_ID` / `GITHUB_CLIENT_SECRET`. They are kept
|
||||
> in the schema for compatibility in case GitHub App authentication is added later.
|
||||
|
||||
### 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) |
|
||||
| `TELEGRAM_RICH_HEADER_HOST` | Base URL of an external rich-header service; when unset, the built-in `GET /api/richheader` serves the Telegram avatar card | Built-in `/api/richheader` |
|
||||
| `BASE_URL` | Public URL for OAuth callbacks | `http://localhost:8787` |
|
||||
| `ADMIN_USER_IDS` | Comma-separated GitHub user IDs (or logins) allowed to access the Web UI | Disabled |
|
||||
| `ALLOW_SELF_SIGNUP` | When enabled (`1`/`true`), GitHub users without any group access get a personal group on first login instead of `403` | Disabled |
|
||||
| `AUDIT_RETENTION_DAYS` | Audit-log retention in days for the scheduled cleanup | `90` |
|
||||
|
||||
## Webhook Providers
|
||||
|
||||
WebHooker ingests webhooks from multiple forges through the same `POST /webhook` endpoint; the provider is auto-detected from the request headers, so point every forge's webhook at `{BASE_URL}/webhook`.
|
||||
|
||||
| Provider | Event header | Signature header | Signature format | Secret |
|
||||
| -------- | ---------------- | --------------------- | -------------------------- | ----------------------- |
|
||||
| GitHub | `X-GitHub-Event` | `X-Hub-Signature-256` | `sha256=<hex>` HMAC-SHA256 | `GITHUB_WEBHOOK_SECRET` |
|
||||
| Gitea | `X-Gitea-Event` | `X-Gitea-Signature` | plain hex HMAC-SHA256 | `GITEA_WEBHOOK_SECRET` |
|
||||
|
||||
Gitea payloads are normalized to the same internal shape as GitHub events, so routes, filters, and the 28 formatters work unchanged. Unknown or unmapped Gitea events fall back to the generic formatter. Repository/commit/user links are derived from the payload's `repository.html_url`, so they point at your Gitea instance.
|
||||
|
||||
## Web UI
|
||||
|
||||
WebHooker ships with a built-in config console at `/admin` for managing routes in the browser. It is protected by GitHub OAuth plus an admin whitelist.
|
||||
|
||||
### Setup
|
||||
|
||||
1. Configure `ADMIN_USER_IDS` with the GitHub user IDs allowed to manage everything. Logins are also accepted, e.g. `ADMIN_USER_IDS=12345,RhenCloud`. If unset, the console is disabled (unless `ALLOW_SELF_SIGNUP` is enabled).
|
||||
2. Open `/admin` and sign in with GitHub.
|
||||
3. Users without any access get `403`, except when `ALLOW_SELF_SIGNUP=1` (they receive a personal group) or when they follow a group [invite link](#invites).
|
||||
|
||||
### Endpoints
|
||||
|
||||
The console is served as an SPA at `/admin`; its tabs are deep-linkable via the URL path (`/admin/groups`, `/admin/logs`, `/admin/audit`). URLs outside `/admin` that do not match an endpoint below return a plain `404` instead of the console.
|
||||
|
||||
| Endpoint | Description |
|
||||
| ----------------------------------------------- | ----------------------------------------------------------------- |
|
||||
| `GET /admin` | Config console UI |
|
||||
| `GET /admin/login` | Start GitHub OAuth sign-in |
|
||||
| `GET /admin/logout` | Destroy session |
|
||||
| `GET /admin/invite?token=…` | Accept a group invite (browser page) |
|
||||
| `GET /admin/api/me` | Current session, scope, groups, and roles |
|
||||
| `GET /admin/api/routes` | List routes (scoped to access) |
|
||||
| `PUT /admin/api/routes` | Replace routes (owner/admin per group) |
|
||||
| `GET /admin/api/groups` | List groups + the signed-in user's role each |
|
||||
| `PUT /admin/api/groups` | Replace groups (super: all; owner: own only) |
|
||||
| `GET /admin/api/groups/:id/routes` | List a group's routes |
|
||||
| `PUT /admin/api/groups/:id/routes` | Replace a group's routes (owner/admin) |
|
||||
| `PUT /admin/api/groups/:id/rename` | Rename a group (owner); routes, webhook secret and invites follow |
|
||||
| `GET /admin/api/logs` | Send logs (scoped to accessible routes) |
|
||||
| `GET /admin/api/logs/:id` | Single send-log entry (scoped) |
|
||||
| `POST /admin/api/groups/:id/invites` | Create an invite link (owner) |
|
||||
| `GET /admin/api/groups/:id/invites` | List pending invites (owner) |
|
||||
| `DELETE /admin/api/invites/:token` | Revoke an invite (owner) |
|
||||
| `GET /admin/api/audit` | Audit log (scoped to accessible groups) |
|
||||
| `GET /admin/api/groups/:id/webhook` | Group webhook endpoint info (owner) |
|
||||
| `POST /admin/api/groups/:id/webhook/regenerate` | Generate/regenerate the group webhook secret (owner) |
|
||||
| `DELETE /admin/api/groups/:id/webhook` | Disable the group webhook ingress (owner) |
|
||||
|
||||
The console lets you add, edit, delete, and toggle routes. Saved routes are written to KV `config:routes` immediately and the config cache is invalidated so the webhook pipeline picks them up on the next run.
|
||||
|
||||
## Webhook Endpoints
|
||||
|
||||
### Global endpoint (`POST /webhook`)
|
||||
|
||||
The legacy global endpoint verifies payloads against the operator's global secrets (`GITHUB_WEBHOOK_SECRET`, `GITEA_WEBHOOK_SECRET`) and dispatches into **all** routes. GitHub App installations deliver here; use `installationId` on groups to keep tenants isolated.
|
||||
|
||||
### Per-group endpoint (`POST /webhook/{groupId}`)
|
||||
|
||||
Every group can opt into its own webhook ingress with an independent secret (generated from the group page — Webhook endpoint panel, owner role). Payloads are verified against the **group's** secret instead of the global ones, and only that group's routes are eligible. This is how SaaS users configure Gitea, classic GitHub, or custom webhooks without sharing (or knowing) the operator's secrets.
|
||||
|
||||
- Supported for any provider: GitHub (`X-Hub-Signature-256`), Gitea (`X-Gitea-Signature`), custom (`X-WebHooker-Signature`)
|
||||
- The secret is a 64-char hex string; regenerate from the console invalidates the old one immediately
|
||||
- Delivery-id dedup keys are tenant-scoped (`delivery:{groupId}:{id}`)
|
||||
- When the group has no secret (or no longer exists) the endpoint returns `404`
|
||||
|
||||
### Custom webhooks
|
||||
|
||||
Post arbitrary JSON to `POST /webhook/{groupId}` (or the global endpoint) with the body signed as `X-WebHooker-Signature: sha256=<hmac-sha256 hex of the raw body>` using the group's secret. The payload becomes a `custom` event that flows through the normal route pipeline — create a route with `event: custom` (there is a console template) and it dispatches to that route's targets, records `send_logs`, and appears in the group's webhook log channel.
|
||||
|
||||
Payload schema:
|
||||
|
||||
```json
|
||||
{
|
||||
"title": "Deploy failed",
|
||||
"description": "Prod rollout failed at 12:03 UTC",
|
||||
"color": "red",
|
||||
"url": "https://ci.example.com/runs/42",
|
||||
"repo": "acme/widget",
|
||||
"author": {
|
||||
"name": "alice",
|
||||
"iconUrl": "https://…/alice.png",
|
||||
"url": "https://github.com/alice"
|
||||
},
|
||||
"fields": [{ "name": "Env", "value": "prod", "inline": true }],
|
||||
"footer": "my-monitor",
|
||||
"deliveryId": "alert-123"
|
||||
}
|
||||
```
|
||||
|
||||
| Field | Type | Description |
|
||||
| ------------- | -------- | ---------------------------------------------------------------------------------------------------------------- |
|
||||
| `title` | string | Message title (falls back to "Custom message") |
|
||||
| `description` | string | Optional message body |
|
||||
| `color` | string | Optional embed color: a word (`red`, `green`, `yellow`, `blue`, `purple`, `orange`, `cyan`, `gray`) or `#rrggbb` |
|
||||
| `url` | string | Optional link for the title |
|
||||
| `repo` | string | Optional `owner/repo`; prefixes the title and is used as the footer |
|
||||
| `author` | object | Optional `{ name, iconUrl, url }` |
|
||||
| `fields` | object[] | Optional embed fields `{ name, value, inline }` |
|
||||
| `footer` | string | Optional footer override |
|
||||
| `deliveryId` | string | Optional id for sender-side dedup (retries) |
|
||||
|
||||
### GitHub App tenant isolation
|
||||
|
||||
When the GitHub App is installed, its events arrive at the global endpoint for **every** installation. To keep tenants apart, bind each group to the installation id that should feed it: `"installationId": 12345678`. The id is visible in the App's installation webhook payload (`installation.id`) or on the GitHub App installation page URL. Events from any other installation are rejected for that group even if its `owners` list is empty. Groups without `installationId` keep the legacy behavior (`owners` filtering).
|
||||
|
||||
Binding is **auto-configured** — the GitHub App's _Setup URL_ should point to `{BASE_URL}/auth/github/install`. Right after a user installs the App, the browser lands there and they choose where the installation binds: a **new group** (`inst-{installationId}`, default) or any **existing group they own** (owner role checked again on submit; `POST /auth/github/install/bind` performs the provisioning). No manual id entry is needed. As a fallback (e.g. when the Setup URL is not configured), the `installation.created` webhook event creates/binds the group automatically — existing groups whose `owners` match the installing account are bound, otherwise a dedicated `inst-{installationId}` group is created. Then just add routes/members in the console.
|
||||
|
||||
## Routes
|
||||
|
||||
Routes define which events get forwarded to which channel (Discord or Telegram). They are stored in Cloudflare KV under the key `config:routes` as a JSON array.
|
||||
|
||||
There are **no default routes** — each route must define its own target. If no routes are configured, no events are forwarded.
|
||||
|
||||
### Route Schema
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "unique-route-id",
|
||||
"name": "Human-readable name",
|
||||
"enabled": true,
|
||||
"groupId": "my-group",
|
||||
"fallback": false,
|
||||
"stop": false,
|
||||
"discordRoleIds": ["111111111111111111"],
|
||||
"filters": [
|
||||
{ "type": "event", "match": "push" },
|
||||
{ "type": "repo", "match": "org/repo", "exclude": false }
|
||||
],
|
||||
"targets": [
|
||||
{
|
||||
"platform": "discord",
|
||||
"channelId": "REQUIRED_CHANNEL_ID",
|
||||
"threadId": "OPTIONAL_THREAD_ID"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
Each entry of `targets` is a push destination, so one route can forward to several channels at once (e.g. a Discord channel **and** a Telegram group). `target.platform` selects the platform: `discord` (default) or `telegram`. For **Discord**, `target.channelId` is required (a thread in `target.threadId` is optional). For **Telegram**, `target.chatId` (the group/supergroup chat id, e.g. `-1001234567890`) is required and `target.topicId` (the `message_thread_id` of a topic, equivalent of a Discord thread) is optional. There is no fallback to a default channel.
|
||||
|
||||
### Discord Role Mentions
|
||||
|
||||
Set `discordRoleIds` on a route to ping one or more Discord roles (身份组) whenever that route fires. The mention (`<@&roleId>`) is prepended to the message content of every **Discord** target of the route; Telegram targets ignore this field. Mentions only trigger notifications when the bot has the `Mention Everyone` permission (or the role is marked mentionable), and the bot must be able to see the role.
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "release-notify",
|
||||
"name": "Notify on Release",
|
||||
"enabled": true,
|
||||
"groupId": "default",
|
||||
"discordRoleIds": ["111111111111111111", "222222222222222222"],
|
||||
"filters": [{ "type": "event", "match": "release" }],
|
||||
"targets": [{ "platform": "discord", "channelId": "REQUIRED_CHANNEL_ID" }]
|
||||
}
|
||||
```
|
||||
|
||||
You can add role ids in the admin console under _Discord role mentions_.
|
||||
|
||||
Other route fields:
|
||||
|
||||
| Field | Type | Required | Description |
|
||||
| ---------------- | -------- | -------- | ----------------------------------------------------------------------------------------------- |
|
||||
| `groupId` | string | Yes | Id of the [group](#groups) this route belongs to |
|
||||
| `fallback` | boolean | No | When `true`, fires only if no non-fallback route matched the event; its own filters are ignored |
|
||||
| `stop` | boolean | No | When `true` and this route matches, no further routes are evaluated for this event |
|
||||
| `discordRoleIds` | string[] | No | Discord role ids to ping when this route fires; applied to Discord targets only |
|
||||
|
||||
### Custom Route Example
|
||||
|
||||
```json
|
||||
[
|
||||
{
|
||||
"id": "backend-prs",
|
||||
"name": "Backend PRs",
|
||||
"enabled": true,
|
||||
"groupId": "backend-team",
|
||||
"filters": [
|
||||
{ "type": "repo", "match": "myorg/backend" },
|
||||
{ "type": "event", "match": "pull_request" },
|
||||
{ "type": "actor", "match": "[bot]", "exclude": true }
|
||||
],
|
||||
"targets": [
|
||||
{
|
||||
"platform": "telegram",
|
||||
"chatId": "-1001234567890",
|
||||
"topicId": "9876543210"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
## Groups
|
||||
|
||||
Routes belong to groups. Groups scope admin access and can restrict which events flow into them. They are stored in Cloudflare KV under the key `config:groups` as a JSON array.
|
||||
|
||||
### Group Schema
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "backend-team",
|
||||
"name": "Backend Team",
|
||||
"members": [
|
||||
{ "login": "rhencloud", "role": "owner" },
|
||||
{ "login": "octobot", "role": "admin" },
|
||||
{ "login": "reader", "role": "viewer" }
|
||||
],
|
||||
"owners": ["myorg"],
|
||||
"providers": ["github", "gitea"],
|
||||
"installationId": 12345678,
|
||||
"logTarget": { "platform": "discord", "channelId": "123456789", "threadId": "987654321" }
|
||||
}
|
||||
```
|
||||
|
||||
| Field | Type | Required | Description |
|
||||
| ---------------- | -------- | -------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `id` | string | Yes | Lowercase id (`a-z0-9`, `-`); referenced by each route's `groupId`. Editable: renaming a group re-points its routes, per-group webhook secret and pending invites |
|
||||
| `name` | string | Yes | Human-readable group name |
|
||||
| `members` | object[] | No | `{ login, role }` entries; role is `owner`, `admin`, or `viewer` |
|
||||
| `adminIds` | string[] | No | Deprecated legacy field; treated as `members` with role `owner` when present |
|
||||
| `owners` | string[] | No | Org/user logins whose events are accepted into this group; empty = all |
|
||||
| `providers` | string[] | No | Source platforms allowed into this group (`github`, `gitea`); empty = all |
|
||||
| `installationId` | number | No | GitHub App installation id bound to this group; only that installation's events are accepted (empty = all) |
|
||||
| `emoji` | boolean | No | Whether to include emoji in this group's messages (default `true`) |
|
||||
| `lang` | string | No | Message language for every route in this group (e.g. `en`, `zh`; custom via KV `i18n:<lang>`) — defaults to `en` |
|
||||
| `logTarget` | object | No | Webhook log channel: a Discord `{ platform, channelId, threadId? }` or Telegram `{ platform, chatId, topicId? }` target that receives a summary of every webhook the group's routes dispatch |
|
||||
|
||||
### Roles
|
||||
|
||||
Every group member has one of three roles. Super admins (`ADMIN_USER_IDS`) always bypass them.
|
||||
|
||||
| Role | View routes/logs | Edit routes | Manage members & invites | Edit group settings |
|
||||
| -------- | ---------------- | ----------- | ------------------------ | ------------------- |
|
||||
| `owner` | ✓ | ✓ | ✓ | ✓ (except `owners`) |
|
||||
| `admin` | ✓ | ✓ | ✗ | ✗ |
|
||||
| `viewer` | ✓ (read-only) | ✗ | ✗ | ✗ |
|
||||
|
||||
### Access Model
|
||||
|
||||
- **Super admins** (`ADMIN_USER_IDS`) see and edit every group and all routes; only they can edit a group's `owners` list.
|
||||
- **Owners** manage their group's routes, members, invites, name, id, `emoji`, and `providers`. They cannot remove the last owner or demote themselves when no other owner remains.
|
||||
- **Admins** edit routes inside their groups and view logs; **viewers** get a read-only console.
|
||||
- Group admin endpoints operate on a single group at a time via `/admin/api/groups/:id/routes`; `groupId` is forced from the path parameter.
|
||||
- The `owners` list restricts which event actors (sender logins) the group's routes will dispatch at all.
|
||||
- The `providers` list restricts which forge's events (`github`, `gitea`) the group's routes will dispatch. This lets you keep GitHub and Gitea groups separate even when org/user names collide.
|
||||
|
||||
### Webhook Log Channel
|
||||
|
||||
A group may set `logTarget` to a Discord channel/thread or Telegram chat/topic. Whenever the group's routes dispatch a webhook, a single summary message is sent there: the event type/action, the repo, the delivery id, and one line per route×target with an ✅/❌ outcome (including the error for failed sends). The message is green when every dispatch succeeded and red when any failed. The summary uses the group's message language. Log messages are sent best-effort and are not themselves recorded in the D1 send log.
|
||||
|
||||
### Invites
|
||||
|
||||
Owners (and super admins) can create single-use invite links valid for 7 days from the group's _Members_ panel. Accepting an invite adds the user with the invited role (`admin` or `viewer` — never `owner`); an existing `viewer` is upgraded to `admin`. Invites are stored in KV as `invite:{token}`.
|
||||
|
||||
### Self Sign-up
|
||||
|
||||
With `ALLOW_SELF_SIGNUP=1`, a GitHub user who has no group access gets a personal group (`u-{userId}`, owned by them) on first login instead of a `403`. This is the entry point for a fully self-service SaaS install; disable it to keep the console invite-only.
|
||||
|
||||
## Filter Types
|
||||
|
||||
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]` |
|
||||
| `action` | Event action | `opened`, `closed`, `published` |
|
||||
| `branch` | Branch name | `main`, `feature-?`, `/^release-/` |
|
||||
| `keyword` | Text in payload body | `deploy`, `/fix\s+\d+/` |
|
||||
|
||||
### Filter Behavior
|
||||
|
||||
- All filters in a route must match for the route to trigger (AND logic)
|
||||
- Set `"exclude": true` on any filter to invert it (NOT logic)
|
||||
- Every filter type supports the same pattern forms: plain text, `*`/`?` **globs** (`*` = any run, `?` = one character), and `/regular expression/` — all case-insensitive
|
||||
- Field filters (`event`/`repo`/`actor`/`action`/`branch`) glob-match the whole value; `keyword` globs and regexes search anywhere in the payload; plain `keyword` text is a substring search
|
||||
- Patterns longer than 200 characters are not compiled as glob/regex; an invalid `//`-wrapped regex matches nothing
|
||||
- `branch` filter works for push, pull_request, pull_request_review, pull_request_review_comment, create/delete, workflow_run, workflow_job, check_suite, deployment, and code_scanning_alert events
|
||||
|
||||
### Match Values
|
||||
|
||||
Filters accept either a single string or an array of strings:
|
||||
|
||||
```json
|
||||
{ "type": "event", "match": "push" }
|
||||
{ "type": "event", "match": ["push", "pull_request"] }
|
||||
```
|
||||
|
||||
## KV Storage Layout
|
||||
|
||||
| Key Pattern | Value | TTL |
|
||||
| ------------------------------ | ----------------------------------------------------------------------------- | ------------------ |
|
||||
| `config:routes` | JSON array of routes | Permanent |
|
||||
| `config:groups` | JSON array of groups | Permanent |
|
||||
| `session:{id}` | Admin session `{ userId, login }` | 7 days |
|
||||
| `token:{userId}` | `{ userId, accessToken, expiresAt, refreshToken? }` | 0.9 × token expiry |
|
||||
| `token-reverse:{sha256}` | User id for reverse lookup by token | 0.9 × token expiry |
|
||||
| `state:{hex}` | `{ redirectTo, expiresAt, discordUserId?, telegramUserId?, telegramChatId? }` | 600 seconds |
|
||||
| `invite:{token}` | `{ groupId, role, expiresAt, createdBy, note? }` | 7 days |
|
||||
| `invite:group:{id}` | Token index per group (keeps invite listing consistent) | Permanent |
|
||||
| `delivery:{id}` | Webhook delivery id (dedup marker) | 300 seconds |
|
||||
| `msg:{routeId}:{key}:{target}` | Message id tracking for in-place updates (e.g. `workflow_run` / `check_run`) | 7 days |
|
||||
| `cmd:guild:{id}` | Guild id whose commands were registered (dedup) | Permanent |
|
||||
| `cmd:registered:global` | Global command registration marker (dedup) | 1 day |
|
||||
| `config:discord-app-id` | Cached Discord application id | Permanent |
|
||||
| `i18n:{lang}` | Translation overrides merged on top of English | Permanent |
|
||||
|
||||
## D1 Storage Layout
|
||||
|
||||
The D1 database (`DB` binding, database `webhooker`) holds four tables:
|
||||
|
||||
| Table | Purpose |
|
||||
| ---------------- | ---------------------------------------------------------------------------------------------- |
|
||||
| `send_logs` | One row per dispatch attempt (route id, event, target, ok/error, duration, error code, detail) |
|
||||
| `audit_logs` | One row per admin operation (login/logout, group/route/member/invite changes) |
|
||||
| `discord_links` | Maps `discord_user_id` → `github_user_id` for `/gh` Discord commands |
|
||||
| `telegram_links` | Maps `telegram_user_id` → `github_user_id` for `/gh` Telegram commands |
|
||||
|
||||
`audit_logs` is pruned automatically by the scheduled trigger after `AUDIT_RETENTION_DAYS` (default 90).
|
||||
|
|
|
|||
|
|
@ -1,263 +1,263 @@
|
|||
# Filter Tutorial
|
||||
|
||||
Filters decide which webhook events a [route](./configuration#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](./configuration#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 |
|
||||
|
|
|
|||
|
|
@ -296,14 +296,14 @@ owner(及超级管理员)可在分组的「成员」面板创建一次性邀
|
|||
|
||||
实操指南见[过滤器教程](./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`, `*release-*`, `/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`, `*release-*`, `/fix\s+\d+/` |
|
||||
|
||||
### 过滤器行为
|
||||
|
||||
|
|
|
|||
|
|
@ -1,263 +1,263 @@
|
|||
# 过滤器教程
|
||||
|
||||
过滤器决定哪些 Webhook 事件会被[路由](./configuration#路由)转发。只有当路由 `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 事件会被[路由](./configuration#路由)转发。只有当路由 `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