# 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 | | `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 | ## 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 routes. Logins are also accepted, e.g. `ADMIN_USER_IDS=12345,RhenCloud`. If unset, the console is disabled. 2. Open `/admin` and sign in with GitHub. 3. Only users in the whitelist receive a session cookie; everyone else gets `403`. ### Endpoints | Endpoint | Description | | ---------------------------------- | --------------------------------------- | | `GET /admin` | Config console UI | | `GET /admin/login` | Start GitHub OAuth sign-in | | `GET /admin/logout` | Destroy session | | `GET /admin/api/me` | Current session, scope, and groups | | `GET /admin/api/routes` | List routes (admin only) | | `PUT /admin/api/routes` | Replace routes (admin only) | | `GET /admin/api/groups` | List groups (scoped to access) | | `PUT /admin/api/groups` | Replace groups (super admin only) | | `GET /admin/api/groups/:id/routes` | List a group's routes | | `PUT /admin/api/groups/:id/routes` | Replace a group's routes | | `GET /admin/api/logs` | Send logs (scoped to accessible routes) | | `GET /admin/api/logs/:id` | Single send-log entry (scoped) | 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. ## 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, "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. 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 | | `lang` | string | No | Message language override for this route (e.g. `en`, `zh`); defaults to the global setting | ### 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", "adminIds": ["rhencloud"], "owners": ["myorg"] } ``` | Field | Type | Required | Description | | ---------- | -------- | -------- | ---------------------------------------------------------------------- | | `id` | string | Yes | Lowercase id (`a-z0-9`, `-`); referenced by each route's `groupId` | | `name` | string | Yes | Human-readable group name | | `adminIds` | string[] | Yes | GitHub user IDs or logins who may manage this group's routes | | `owners` | string[] | No | Org/user logins whose events are accepted into this group; empty = all | | `emoji` | boolean | No | Whether to include emoji in this group's messages (default `true`) | ### Access Model - **Super admins** (`ADMIN_USER_IDS`) see and edit every group and all routes. - **Group admins** (`adminIds`) only see and edit the groups they manage; submitting a route outside their groups returns `403`. - Group admin endpoints operate on a single group at a time via `/admin/api/groups/:id/routes`; `groupId` is forced from the path parameter. - The `owners` list restricts which event actors (sender logins) the group's routes will dispatch at all. ## Filter Types See the [Filter Tutorial](./filters) for a hands-on guide with worked examples. | Type | Matches | Example | | --------- | -------------------- | -------------------------------- | | `event` | GitHub event name | `push`, `pull_request`, `issues` | | `repo` | Repository full name | `org/repo` | | `actor` | Sender login | `username`, `[bot]` | | `action` | Event action | `opened`, `closed`, `published` | | `branch` | Branch name | `main`, `develop` | | `keyword` | Text in payload body | `deploy`, `/fix\s+\d+/` (regex) | ### 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) - Non-keyword filters are **exact, case-insensitive matches** — no wildcards (`repo: "org/*"` does not match anything) - `keyword` filter supports regex patterns — falls back to substring match if regex is invalid or longer than 200 characters - `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 | | `delivery:{id}` | Webhook delivery id (dedup marker) | 300 seconds | | `msg:{routeId}:{key}:{target}` | Message id tracking for in-place updates (e.g. `workflow_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 three tables: | Table | Purpose | | ---------------- | ---------------------------------------------------------------------------------------------- | | `send_logs` | One row per dispatch attempt (route id, event, target, ok/error, duration, error code, detail) | | `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 |