mirror of
https://github.com/ReCloudStudio/WebHooker.git
synced 2026-09-22 16:11:29 +00:00
feat(filters): JSONPath field filters, operators, AST groups, fragments, and test-match
Add a field filter type reading any payload value by JSONPath with array expansion, 12 comparison operators (eq/ne/contains/startsWith/endsWith/regex/gt/gte/lt/lte/in/exists), a visual AST builder (all/any/not) in the route editor, chip-based multi-value input, a stateless POST /admin/api/test-match dry-run, and named filter fragments stored in D1 (d1_fragments, migration 0010) inlined into route ASTs on insert.
This commit is contained in:
parent
c955db03c3
commit
c090281cb2
30 changed files with 1793 additions and 422 deletions
17
AGENTS.md
17
AGENTS.md
|
|
@ -26,6 +26,7 @@ Core pipeline: GitHub Webhook → Worker (verify + filter + format) → Discord
|
||||||
- Per-group forge branding: optional `Group.forgeSources` (a list of `{ host, type: "github" | "gitea", name? }` the group defines itself) labels each message's footer with the first entry whose type matches the event's provider and whose host matches the repository URL's hostname (GitHub matches `github.com`, so two Gitea instances can be `git1.example.com`/`git2.example.com`); the label is the entry's optional `name` (fallback: host); links are derived from the repo URL and footer icons use raster PNGs Discord renders (GitHub's `fluidicon.png`, Gitea's `/assets/img/favicon.png` — `.ico` favicons are silently ignored); Discord shows the footer icon + name, Telegram a linked name
|
- Per-group forge branding: optional `Group.forgeSources` (a list of `{ host, type: "github" | "gitea", name? }` the group defines itself) labels each message's footer with the first entry whose type matches the event's provider and whose host matches the repository URL's hostname (GitHub matches `github.com`, so two Gitea instances can be `git1.example.com`/`git2.example.com`); the label is the entry's optional `name` (fallback: host); links are derived from the repo URL and footer icons use raster PNGs Discord renders (GitHub's `fluidicon.png`, Gitea's `/assets/img/favicon.png` — `.ico` favicons are silently ignored); Discord shows the footer icon + name, Telegram a linked name
|
||||||
- Delivery queue: when the `QUEUE` binding is present, webhook ingress enqueues one message per event (not per target) to a Cloudflare Queue (`webhooker-delivery`); a Nitro `cloudflare:queue` plugin consumes batches, dispatches, and retries retryable failures (5xx/network/429-exhaustion) with exponential backoff (5s/30s/2m/10m) up to the queue `max_retries`, after which the DLQ (`webhooker-delivery-dlq`) marks the delivery dead. Oversized payloads (>~100 KB) are parked in R2 (`webhooks/YYYY/MM/DD/*.json`, falling back to KV `queue:payload:*` without the R2 binding) and resolved by the consumer; delivery state is tracked as the D1 `delivery_state` table (KV `delivery-state:*` fallback). Without the `QUEUE` binding, dispatch stays inline (existing behavior)
|
- Delivery queue: when the `QUEUE` binding is present, webhook ingress enqueues one message per event (not per target) to a Cloudflare Queue (`webhooker-delivery`); a Nitro `cloudflare:queue` plugin consumes batches, dispatches, and retries retryable failures (5xx/network/429-exhaustion) with exponential backoff (5s/30s/2m/10m) up to the queue `max_retries`, after which the DLQ (`webhooker-delivery-dlq`) marks the delivery dead. Oversized payloads (>~100 KB) are parked in R2 (`webhooks/YYYY/MM/DD/*.json`, falling back to KV `queue:payload:*` without the R2 binding) and resolved by the consumer; delivery state is tracked as the D1 `delivery_state` table (KV `delivery-state:*` fallback). Without the `QUEUE` binding, dispatch stays inline (existing behavior)
|
||||||
- Storage quotas: the design avoids KV write pressure (Workers Free KV allows ~1,000 writes/day) by keeping high-frequency ephemeral writes in D1 instead — webhook dedup (`dedup_keys`, atomic `ON CONFLICT` UPSERT), delivery state and message tracking all use D1 rows via `canUseD1` (prepare+batch probe) with automatic KV fallback when D1 is unavailable or unmigrated. D1 Free allows 100,000 rows written/day, so the per-event KV write cost drops to ~0; config stays cached in memory + KV with D1 authoritative
|
- Storage quotas: the design avoids KV write pressure (Workers Free KV allows ~1,000 writes/day) by keeping high-frequency ephemeral writes in D1 instead — webhook dedup (`dedup_keys`, atomic `ON CONFLICT` UPSERT), delivery state and message tracking all use D1 rows via `canUseD1` (prepare+batch probe) with automatic KV fallback when D1 is unavailable or unmigrated. D1 Free allows 100,000 rows written/day, so the per-event KV write cost drops to ~0; config stays cached in memory + KV with D1 authoritative
|
||||||
|
- Filter DSL: a route's flat `filters` can be replaced by a nested `ast` (`all`/`any`/`not` nodes, `server/lib/events/filter-ast.ts`) that takes precedence when present; the `field` filter type reads any payload value by JSONPath (`path`, arrays expand so any element matches) and 12 comparison operators (`op`: `eq`/`ne`/`contains`/`startsWith`/`endsWith`/`regex`/`gt`/`gte`/`lt`/`lte`/`in`/`exists`) extend the legacy glob/regex/case-insensitive-exact semantics; named filter fragments (D1 `d1_fragments` via `server/lib/fragments.ts`, per-group) are editor-side templates inlined into the route's `ast` on insert — the matcher never resolves fragment references; the route editor (`RouteEditor.vue`) exposes a recursive AST builder (`FilterNodeEditor.vue`), chip multi-value input (`TagInput.vue`), live explanation and a stateless `POST /admin/api/test-match` dry-run
|
||||||
- Local dev: wrangler + Miniflare
|
- Local dev: wrangler + Miniflare
|
||||||
|
|
||||||
## Architecture
|
## Architecture
|
||||||
|
|
@ -36,9 +37,10 @@ app/ # Vue 3 UI (Nuxt app dir)
|
||||||
├── assets/css/main.css # Tailwind entry: theme tokens (RGB-triplet vars) + @layer components (@apply) + Vue transition glue
|
├── assets/css/main.css # Tailwind entry: theme tokens (RGB-triplet vars) + @layer components (@apply) + Vue transition glue
|
||||||
├── pages/ # index (landing), terms, privacy, admin/[...slug] (console SPA)
|
├── pages/ # index (landing), terms, privacy, admin/[...slug] (console SPA)
|
||||||
├── components/ # ConsolePage (sidebar shell + topbar), AdminHome (overview dashboard),
|
├── components/ # ConsolePage (sidebar shell + topbar), AdminHome (overview dashboard),
|
||||||
│ # RouteCard/Editor, GroupEditor, MembersPanel, WebhookPanel,
|
│ # RouteCard/Editor (RouteEditor has FilterNodeEditor AST builder + TagInput chips),
|
||||||
|
│ # GroupEditor, MembersPanel, WebhookPanel,
|
||||||
│ # SendLogs, AuditLog, MetricsPanel, AppToasts, LegalLayout
|
│ # SendLogs, AuditLog, MetricsPanel, AppToasts, LegalLayout
|
||||||
├── composables/ # useI18n, useToasts, useGroups, useGroupRoutes, useLogs, useAudit, useInvites, useWebhook
|
├── composables/ # useI18n, useToasts, useGroups, useGroupRoutes, useFilterNode (AST helpers), useFragments, useLogs, useAudit, useInvites, useWebhook
|
||||||
├── types.ts # shared client types (Route, Group, Filter, ...)
|
├── types.ts # shared client types (Route, Group, Filter, ...)
|
||||||
└── utils/legal.ts # terms/privacy HTML bodies (zh/en)
|
└── utils/legal.ts # terms/privacy HTML bodies (zh/en)
|
||||||
server/ # Nitro server
|
server/ # Nitro server
|
||||||
|
|
@ -50,6 +52,7 @@ server/ # Nitro server
|
||||||
└── lib/
|
└── lib/
|
||||||
├── types.ts # Env, Config, Route, Filter, Group, WebhookEvent, NeutralMessage
|
├── types.ts # Env, Config, Route, Filter, Group, WebhookEvent, NeutralMessage
|
||||||
├── config.ts # loadRoutes/saveRoutes (delegates to D1 ConfigStore when available, else KV config:routes), loadConfig from env
|
├── config.ts # loadRoutes/saveRoutes (delegates to D1 ConfigStore when available, else KV config:routes), loadConfig from env
|
||||||
|
├── fragments.ts # named filter fragments (loadFragments/saveFragments — D1 d1_fragments, no KV)
|
||||||
├── config/ # config schema + migration + validation
|
├── config/ # config schema + migration + validation
|
||||||
│ └── schema.ts # CONFIG_SCHEMA_VERSION, valibot route/group/filter schemas, migrateRoutes/Groups, validateRoutes/Groups (non-destructive), explainRoute
|
│ └── schema.ts # CONFIG_SCHEMA_VERSION, valibot route/group/filter schemas, migrateRoutes/Groups, validateRoutes/Groups (non-destructive), explainRoute
|
||||||
├── cf.ts # cfEnv(event) — env bindings from event.context.cloudflare
|
├── cf.ts # cfEnv(event) — env bindings from event.context.cloudflare
|
||||||
|
|
@ -62,7 +65,7 @@ server/ # Nitro server
|
||||||
│ └── dispatch.ts # Platform-neutral dispatch: match routes → formatEvent → driver.send/edit (batched recordSend + group filter + per-group webhook log)
|
│ └── dispatch.ts # Platform-neutral dispatch: match routes → formatEvent → driver.send/edit (batched recordSend + group filter + per-group webhook log)
|
||||||
├── events/
|
├── events/
|
||||||
│ ├── match.ts # matchRoute, eventOwners; unified pattern syntax (*/? globs + //-wrapped regex)
|
│ ├── match.ts # matchRoute, eventOwners; unified pattern syntax (*/? globs + //-wrapped regex)
|
||||||
│ └── filter-ast.ts # FilterNode evaluator (all/any/not), containsKeyword, explainFilter/explainFilterNode; pattern helpers (regex/glob compile)
|
│ └── filter-ast.ts # FilterNode evaluator (all/any/not + field JSONPath + 12 ops), containsKeyword, explainFilter/explainFilterNode; pattern helpers (regex/glob compile)
|
||||||
├── providers/ # Forge webhook providers (verify + parse/normalize to GitHub-shaped events)
|
├── providers/ # Forge webhook providers (verify + parse/normalize to GitHub-shaped events)
|
||||||
│ ├── types.ts # Provider interface (matches/verify/parse)
|
│ ├── types.ts # Provider interface (matches/verify/parse)
|
||||||
│ ├── hmac.ts # HMAC-SHA256 + timing-safe compare helpers
|
│ ├── hmac.ts # HMAC-SHA256 + timing-safe compare helpers
|
||||||
|
|
@ -141,8 +144,10 @@ tests/__snapshots__/ # formatter snapshot golden files (toMatchSnapshot)
|
||||||
- Normalize Gitea webhook payloads to a GitHub-shaped `WebhookEvent` (push `compare_url` → `compare`, `pull_request_comment` → `pull_request_review_comment`, ...)
|
- Normalize Gitea webhook payloads to a GitHub-shaped `WebhookEvent` (push `compare_url` → `compare`, `pull_request_comment` → `pull_request_review_comment`, ...)
|
||||||
- Verify Discord interactions (Web Crypto Ed25519, X-Signature-Ed25519 over timestamp + body)
|
- Verify Discord interactions (Web Crypto Ed25519, X-Signature-Ed25519 over timestamp + body)
|
||||||
- Verify Telegram webhook calls (X-Telegram-Bot-Api-Secret-Token when configured)
|
- Verify Telegram webhook calls (X-Telegram-Bot-Api-Secret-Token when configured)
|
||||||
- Filter events by: event type, repo name, actor, action, branch, keyword — every filter type supports `*`/`?` glob matching and `//`-wrapped regular expressions (case-insensitive)
|
- Filter events by: event type, repo name, actor, action, branch, keyword, or any payload field (`field` with a JSONPath `path` — arrays expand so any element matches) — every filter type supports `*`/`?` glob matching and `//`-wrapped regular expressions (case-insensitive) plus 12 comparison operators (`op`: `eq`/`ne`/`contains`/`startsWith`/`endsWith`/`regex`/`gt`/`gte`/`lt`/`lte`/`in`/`exists`)
|
||||||
- Combine filters into an AST (`Route.ast`: `all`/`any`/`not` nodes) that overrides the flat `filters` AND-list; `explainRoute`/`explainFilterNode` render a human-readable description of the tree
|
- Combine filters into an AST (`Route.ast`: `all`/`any`/`not` nodes) that overrides the flat `filters` AND-list; `explainRoute`/`explainFilterNode` render a human-readable description of the tree
|
||||||
|
- Store named filter fragments (D1 `d1_fragments`, per group, `GET`/`PUT /admin/api/groups/:groupId/fragments`) as editor-side templates; inserting a fragment inlines its node into the route's `ast` — dispatch never resolves fragment references
|
||||||
|
- Offer a stateless filter dry-run (`POST /admin/api/test-match`, body `{ node | filters, event?, payload }`) that evaluates in memory and returns `{ matched, explanation }` — no event payload is stored
|
||||||
- Validate route/group config against valibot schemas on load (non-destructive: invalid entries log a warning but still load); `CONFIG_SCHEMA_VERSION` marks the schema version and `migrateRoutes`/`migrateGroups` migrate legacy shapes (e.g. `target` → `targets`)
|
- Validate route/group config against valibot schemas on load (non-destructive: invalid entries log a warning but still load); `CONFIG_SCHEMA_VERSION` marks the schema version and `migrateRoutes`/`migrateGroups` migrate legacy shapes (e.g. `target` → `targets`)
|
||||||
- Filter routes by group owner restriction (`Group.owners`), group source-platform restriction (`Group.providers`: github/gitea), GitHub App installation restriction (`Group.installationId`), and skip fallback routes whenever a regular route matched; stop evaluating further routes when a matched route has `stop: true`
|
- Filter routes by group owner restriction (`Group.owners`), group source-platform restriction (`Group.providers`: github/gitea), GitHub App installation restriction (`Group.installationId`), and skip fallback routes whenever a regular route matched; stop evaluating further routes when a matched route has `stop: true`
|
||||||
- Auto-provision GitHub App installs: the App's Setup URL flow (`/auth/github/install` choice page + `POST /auth/github/install/bind`, owner-role verified for existing groups) and the `installation.created` webhook fallback both create `inst-{installationId}` groups or bind existing groups
|
- Auto-provision GitHub App installs: the App's Setup URL flow (`/auth/github/install` choice page + `POST /auth/github/install/bind`, owner-role verified for existing groups) and the `installation.created` webhook fallback both create `inst-{installationId}` groups or bind existing groups
|
||||||
|
|
@ -236,7 +241,7 @@ Rule: no functional change ships without its documentation; docs and code must n
|
||||||
- **Production**: `wrangler secret put <NAME>` for each secret
|
- **Production**: `wrangler secret put <NAME>` for each secret
|
||||||
- **Routes**: config in D1 `d1_routes`/`d1_groups` (authoritative), seeded from legacy KV `config:routes`/`config:groups` on first load; cache-only KV keys
|
- **Routes**: config in D1 `d1_routes`/`d1_groups` (authoritative), seeded from legacy KV `config:routes`/`config:groups` on first load; cache-only KV keys
|
||||||
- **KV namespace**: Required binding for token/state/session/cache storage (dedup/delivery-state/message-tracking fall back to KV when D1 is unavailable)
|
- **KV namespace**: Required binding for token/state/session/cache storage (dedup/delivery-state/message-tracking fall back to KV when D1 is unavailable)
|
||||||
- **D1 database**: Binding `DB` (database `webhooker`, id `214a0104-3235-47c0-b7bf-ddda95f3c8ac`) for `send_logs` + `audit_logs` + `discord_links` + `telegram_links` + `d1_groups` + `d1_routes` + `dedup_keys` + `delivery_state` + `message_tracking` tables
|
- **D1 database**: Binding `DB` (database `webhooker`, id `214a0104-3235-47c0-b7bf-ddda95f3c8ac`) for `send_logs` + `audit_logs` + `discord_links` + `telegram_links` + `d1_groups` + `d1_routes` + `d1_fragments` + `dedup_keys` + `delivery_state` + `message_tracking` tables
|
||||||
- **Queue**: optional `QUEUE` producer binding plus consumers `webhooker-delivery` and its DLQ `webhooker-delivery-dlq` (declared in `wrangler.jsonc`); when absent, webhook dispatch stays inline
|
- **Queue**: optional `QUEUE` producer binding plus consumers `webhooker-delivery` and its DLQ `webhooker-delivery-dlq` (declared in `wrangler.jsonc`); when absent, webhook dispatch stays inline
|
||||||
- **R2**: optional `PAYLOAD` binding (bucket `webhooker-payloads`) for oversized queue payloads; without it, oversized payloads fall back to KV `queue:payload:*`
|
- **R2**: optional `PAYLOAD` binding (bucket `webhooker-payloads`) for oversized queue payloads; without it, oversized payloads fall back to KV `queue:payload:*`
|
||||||
- **Access control**: `ADMIN_USER_IDS` (super admins), `ALLOW_SELF_SIGNUP` (optional personal group on first login), `AUDIT_RETENTION_DAYS` (default 90) — all plain env vars, not secrets
|
- **Access control**: `ADMIN_USER_IDS` (super admins), `ALLOW_SELF_SIGNUP` (optional personal group on first login), `AUDIT_RETENTION_DAYS` (default 90) — all plain env vars, not secrets
|
||||||
|
|
@ -257,7 +262,7 @@ bunx wrangler d1 create webhooker
|
||||||
bunx wrangler queues create webhooker-delivery
|
bunx wrangler queues create webhooker-delivery
|
||||||
bunx wrangler queues create webhooker-delivery-dlq
|
bunx wrangler queues create webhooker-delivery-dlq
|
||||||
# Queues are declared in wrangler.jsonc (QUEUE binding); no env var needed
|
# Queues are declared in wrangler.jsonc (QUEUE binding); no env var needed
|
||||||
bun run db:migrate:prod # wrangler d1 migrations apply webhooker --remote (migrations/0001..0008)
|
bun run db:migrate:prod # wrangler d1 migrations apply webhooker --remote (migrations/0001..0010)
|
||||||
# R2 bucket for oversized payloads (P0): bunx wrangler r2 bucket create webhooker-payloads
|
# R2 bucket for oversized payloads (P0): bunx wrangler r2 bucket create webhooker-payloads
|
||||||
bunx wrangler deploy
|
bunx wrangler deploy
|
||||||
```
|
```
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,8 @@ GitHub / Gitea webhook → Discord / Telegram dispatcher. Receives webhook event
|
||||||
- **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`, with optional timestamp+nonce replay protection)
|
- **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`, with optional timestamp+nonce replay protection)
|
||||||
- **GitHub App tenant isolation** — bind a group to a GitHub App installation id so only that org/user's events enter it
|
- **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)
|
- HMAC-SHA256 signature verification (Web Crypto API)
|
||||||
- Filter by event type, repo, actor, action, branch, keyword (supports `*`/`?` globs and `/regex/`)
|
- Filter by event type, repo, actor, action, branch, keyword, or any payload field (`field` with a JSONPath `path`, e.g. `pull_request.user.login`); supports `*`/`?` globs and `/regex/` patterns plus 12 comparison operators (`eq`/`ne`/`contains`/`startsWith`/`endsWith`/`regex`/`gt`/`gte`/`lt`/`lte`/`in`/`exists`)
|
||||||
|
- Combine filters into a boolean AST (`all` / `any` / `not` nodes) via the route editor's visual builder; reuse named filter fragments and dry-run any filter against a pasted JSON payload without storing it
|
||||||
- Rich messages with color coding, author avatars, fields, and timestamps — rendered as Discord embeds and Telegram HTML
|
- 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)
|
- 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
|
- `workflow_run` / `check_run` progress is edited **in place** (single message updated as the run advances) on both platforms
|
||||||
|
|
@ -39,7 +40,7 @@ GitHub Webhook → Cloudflare Worker (Nuxt 4 / Nitro)
|
||||||
- **Cloudflare Worker** — HTTP ingress, signature verification, routing, platform dispatch
|
- **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
|
- **Interactions Endpoint** — HTTPS callback (no Discord Gateway connection, no Durable Object); the bot stays offline and commands are registered via the API
|
||||||
- **KV** — cache + ephemeral state: token storage (`token:{userId}`), OAuth state (`state:{hex}`), admin sessions (`session:{id}`), per-group webhook secrets (`tenant:{groupId}`), invites, config cache, delivery dedup/state/message-tracking **fallback** (`delivery:*`, `delivery-state:*`, `msg:*` used only when D1 is unavailable) and message-update locks (`msg:lock:*`)
|
- **KV** — cache + ephemeral state: token storage (`token:{userId}`), OAuth state (`state:{hex}`), admin sessions (`session:{id}`), per-group webhook secrets (`tenant:{groupId}`), invites, config cache, delivery dedup/state/message-tracking **fallback** (`delivery:*`, `delivery-state:*`, `msg:*` used only when D1 is unavailable) and message-update locks (`msg:lock:*`)
|
||||||
- **D1** — source of truth for routes/groups (`d1_routes`/`d1_groups`), send logs (`send_logs`), audit logs (`audit_logs`), dedup (`dedup_keys`), delivery state (`delivery_state`), message-update tracking (`message_tracking`), Discord↔GitHub links (`discord_links`), Telegram↔GitHub links (`telegram_links`)
|
- **D1** — source of truth for routes/groups (`d1_routes`/`d1_groups`), named filter fragments (`d1_fragments`), send logs (`send_logs`), audit logs (`audit_logs`), dedup (`dedup_keys`), delivery state (`delivery_state`), message-update tracking (`message_tracking`), Discord↔GitHub links (`discord_links`), Telegram↔GitHub links (`telegram_links`)
|
||||||
- **Queue** — async delivery when `QUEUE` is bound: `webhooker-delivery` (exponential retry) + DLQ `webhooker-delivery-dlq`; oversized payloads parked in R2 (`PAYLOAD` binding, `webhooks/YYYY/MM/DD/*.json`, falling back to KV `queue:payload:*`)
|
- **Queue** — async delivery when `QUEUE` is bound: `webhooker-delivery` (exponential retry) + DLQ `webhooker-delivery-dlq`; oversized payloads parked in R2 (`PAYLOAD` binding, `webhooks/YYYY/MM/DD/*.json`, falling back to KV `queue:payload:*`)
|
||||||
|
|
||||||
## Quick Start
|
## Quick Start
|
||||||
|
|
@ -98,7 +99,7 @@ Routes are stored in D1 (`d1_routes`, seeded from legacy KV `config:routes` on f
|
||||||
]
|
]
|
||||||
```
|
```
|
||||||
|
|
||||||
`target.platform` selects the push target: `discord` (default) or `telegram`. Discord targets require `target.channelId` (optional `threadId` for a thread); Telegram targets require `target.chatId` (optional `topicId` for a topic). Routes belong to **groups** (D1 `d1_groups`, seeded from legacy KV `config:groups`) that scope admin access and can restrict which org/user events flow in. See the [Routes & Targets](https://webhooker.docs.worldexecute.me/guide/routes) and [Groups & Access Control](https://webhooker.docs.worldexecute.me/guide/groups) guides for the full schema.
|
`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). A route may also set `stop: true` (skip later routes). Routes belong to **groups** (D1 `d1_groups`, seeded from legacy KV `config:groups`) that scope admin access and can restrict which org/user events flow in. See the [Routes & Targets](https://webhooker.docs.worldexecute.me/guide/routes) and [Groups & Access Control](https://webhooker.docs.worldexecute.me/guide/groups) guides for the full schema.
|
||||||
|
|
||||||
### Web UI (`/admin`)
|
### Web UI (`/admin`)
|
||||||
|
|
||||||
|
|
@ -112,7 +113,7 @@ Sign out at `/admin/logout`. Every group has `members` with a role (`owner` / `a
|
||||||
|
|
||||||
### Filter Types
|
### Filter Types
|
||||||
|
|
||||||
Every filter supports plain text, `*`/`?` globs, and `/regex/` patterns (case-insensitive); set `exclude: true` to invert. Filters can also be grouped into an AST via an optional `ast` on a route (`all` / `any` / `not` nodes) to express boolean combinations beyond the default AND-list. See the [Filter Tutorial](https://webhooker.docs.worldexecute.me/guide/filters) for the pattern syntax and the full filter reference.
|
Every filter supports plain text, `*`/`?` globs, and `/regex/` patterns (case-insensitive); set `exclude: true` to invert. A `field` filter reads any payload value by JSONPath (arrays expand so any element matches) and accepts an `op` operator (default `eq`); `op: "exists"` checks presence without a `match`. Filters can also be grouped into an AST via an optional `ast` on a route (`all` / `any` / `not` nodes) to express boolean combinations beyond the default AND-list — when present, `ast` takes precedence over `filters`. The route editor provides a visual AST builder, chip multi-value input, a stateless match tester, and named filter fragments (stored in D1 `d1_fragments`). See the [Filter Tutorial](https://webhooker.docs.worldexecute.me/guide/filters) for the pattern syntax and the full filter reference.
|
||||||
|
|
||||||
## API
|
## API
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,8 @@ GitHub / Gitea webhook → Discord / Telegram 分发服务。通过 Cloudflare W
|
||||||
- **分组级 webhook 入口** — 每个分组可拥有独立的 `POST /webhook/{groupId}` URL + secret(Gitea、classic GitHub webhook,以及用 `X-WebHooker-Signature` 签名的任意自定义 JSON,可选的 timestamp+nonce 重放防护)
|
- **分组级 webhook 入口** — 每个分组可拥有独立的 `POST /webhook/{groupId}` URL + secret(Gitea、classic GitHub webhook,以及用 `X-WebHooker-Signature` 签名的任意自定义 JSON,可选的 timestamp+nonce 重放防护)
|
||||||
- **GitHub App 租户隔离** — 将分组绑定到 GitHub App 安装 ID,只有该组织/用户的事件才能进入该分组
|
- **GitHub App 租户隔离** — 将分组绑定到 GitHub App 安装 ID,只有该组织/用户的事件才能进入该分组
|
||||||
- HMAC-SHA256 签名验证(Web Crypto API)
|
- HMAC-SHA256 签名验证(Web Crypto API)
|
||||||
- 按事件类型、仓库、操作人、操作、分支、关键词过滤(支持 `*`/`?` 通配符与 `/正则/`)
|
- 按事件类型、仓库、操作人、操作、分支、关键词,或任意载荷字段(`field` + JSONPath `path`,如 `pull_request.user.login`)过滤;支持 `*`/`?` 通配符与 `/正则/`,另有 12 个比较操作符(`eq`/`ne`/`contains`/`startsWith`/`endsWith`/`regex`/`gt`/`gte`/`lt`/`lte`/`in`/`exists`)
|
||||||
|
- 在路由编辑器的可视化构建器中把过滤器组合成布尔 AST(`all` / `any` / `not` 节点);可复用命名过滤器片段,并可对粘贴的 JSON 载荷做无存储的试匹配
|
||||||
- 富消息:颜色编码、作者头像、字段、时间戳——渲染为 Discord embed 与 Telegram HTML
|
- 富消息:颜色编码、作者头像、字段、时间戳——渲染为 Discord embed 与 Telegram HTML
|
||||||
- 路由到 Discord 频道/子区与 Telegram 群组/话题(一条路由可多目标)
|
- 路由到 Discord 频道/子区与 Telegram 群组/话题(一条路由可多目标)
|
||||||
- `workflow_run` / `check_run` 进度**原地编辑**同一条消息(运行推进时更新),两个平台均支持
|
- `workflow_run` / `check_run` 进度**原地编辑**同一条消息(运行推进时更新),两个平台均支持
|
||||||
|
|
@ -39,7 +40,7 @@ GitHub Webhook → Cloudflare Worker (Nuxt 4 / Nitro)
|
||||||
- **Cloudflare Worker** — HTTP 入口、签名验证、路由分发
|
- **Cloudflare Worker** — HTTP 入口、签名验证、路由分发
|
||||||
- **Interactions Endpoint** — HTTPS 回调(无 Discord Gateway 连接、无 Durable Object);bot 保持离线,命令通过 API 注册
|
- **Interactions Endpoint** — HTTPS 回调(无 Discord Gateway 连接、无 Durable Object);bot 保持离线,命令通过 API 注册
|
||||||
- **KV** — 缓存 + 临时状态:Token 存储(`token:{userId}`)、OAuth state(`state:{hex}`)、管理员会话(`session:{id}`)、分组级 webhook secret(`tenant:{groupId}`)、邀请、配置缓存、投递去重/投递状态/消息更新追踪的回退(`delivery:*`、`delivery-state:*`、`msg:*` 仅在 D1 不可用时使用)与消息更新锁(`msg:lock:*`)
|
- **KV** — 缓存 + 临时状态:Token 存储(`token:{userId}`)、OAuth state(`state:{hex}`)、管理员会话(`session:{id}`)、分组级 webhook secret(`tenant:{groupId}`)、邀请、配置缓存、投递去重/投递状态/消息更新追踪的回退(`delivery:*`、`delivery-state:*`、`msg:*` 仅在 D1 不可用时使用)与消息更新锁(`msg:lock:*`)
|
||||||
- **D1** — 路由/分组(`d1_routes`/`d1_groups`)、发送日志(`send_logs`)、审计日志(`audit_logs`)、去重(`dedup_keys`)、投递状态(`delivery_state`)、消息更新追踪(`message_tracking`)、Discord↔GitHub 绑定(`discord_links`)、Telegram↔GitHub 绑定(`telegram_links`)
|
- **D1** — 路由/分组(`d1_routes`/`d1_groups`)、命名过滤器片段(`d1_fragments`)、发送日志(`send_logs`)、审计日志(`audit_logs`)、去重(`dedup_keys`)、投递状态(`delivery_state`)、消息更新追踪(`message_tracking`)、Discord↔GitHub 绑定(`discord_links`)、Telegram↔GitHub 绑定(`telegram_links`)
|
||||||
- **Queue** — 绑定 `QUEUE` 时异步投递:`webhooker-delivery`(指数退避重试)+ 死信队列 `webhooker-delivery-dlq`;超大负载暂存于 R2(`PAYLOAD` 绑定,`webhooks/YYYY/MM/DD/*.json`,回退 KV `queue:payload:*`)
|
- **Queue** — 绑定 `QUEUE` 时异步投递:`webhooker-delivery`(指数退避重试)+ 死信队列 `webhooker-delivery-dlq`;超大负载暂存于 R2(`PAYLOAD` 绑定,`webhooks/YYYY/MM/DD/*.json`,回退 KV `queue:payload:*`)
|
||||||
|
|
||||||
## 快速开始
|
## 快速开始
|
||||||
|
|
@ -98,7 +99,7 @@ bunx wrangler dev # 启动本地开发服务器
|
||||||
]
|
]
|
||||||
```
|
```
|
||||||
|
|
||||||
`target.platform` 选择推送目标:`discord`(默认)或 `telegram`。Discord 目标需 `target.channelId`(可选 `threadId` 指向子区);Telegram 目标需 `target.chatId`(可选 `topicId` 指向话题)。路由隶属于**分组**(D1 `d1_groups`,首次加载时从旧版 KV `config:groups` 同步),分组用于限定管理权限,并可限制哪些组织/用户的事件流入。完整模式见[路由与目标](https://webhooker.docs.worldexecute.me/zh/guide/routes)与[分组与访问控制](https://webhooker.docs.worldexecute.me/zh/guide/groups)指南。
|
`target.platform` 选择推送目标:`discord`(默认)或 `telegram`。Discord 目标需 `target.channelId`(可选 `threadId` 指向子区);Telegram 目标需 `target.chatId`(可选 `topicId` 指向话题)。路由还可设置 `stop: true`(跳过后续路由)。路由隶属于**分组**(D1 `d1_groups`,首次加载时从旧版 KV `config:groups` 同步),分组用于限定管理权限,并可限制哪些组织/用户的事件流入。完整模式见[路由与目标](https://webhooker.docs.worldexecute.me/zh/guide/routes)与[分组与访问控制](https://webhooker.docs.worldexecute.me/zh/guide/groups)指南。
|
||||||
|
|
||||||
### Web 控制台(`/admin`)
|
### Web 控制台(`/admin`)
|
||||||
|
|
||||||
|
|
@ -112,7 +113,7 @@ bunx wrangler dev # 启动本地开发服务器
|
||||||
|
|
||||||
### 过滤器类型
|
### 过滤器类型
|
||||||
|
|
||||||
所有过滤器均支持纯文本、`*`/`?` 通配符与 `/正则/`(不区分大小写);设置 `exclude: true` 可取反。过滤器还可通过路由上可选的 `ast`(`all` / `any` / `not` 节点)组合为 AST,以表达默认 AND 列表之外的布尔组合。模式语法与完整过滤器参考见[过滤器教程](https://webhooker.docs.worldexecute.me/zh/guide/filters)。
|
所有过滤器均支持纯文本、`*`/`?` 通配符与 `/正则/`(不区分大小写);设置 `exclude: true` 可取反。`field` 过滤器通过 JSONPath(`path`)读取任意载荷字段(数组会展开,任一元素匹配即可),并可用 `op` 指定比较操作符(默认 `eq`);`op: "exists"` 只判断字段是否存在、无需 `match`。过滤器还可通过路由上可选的 `ast`(`all` / `any` / `not` 节点)组合为 AST,以表达默认 AND 列表之外的布尔组合——当 `ast` 存在时优先于 `filters`。路由编辑器提供可视化 AST 构建器、chip 多值输入、无状态试匹配器与命名过滤器片段(存于 D1 `d1_fragments`)。模式语法与完整过滤器参考见[过滤器教程](https://webhooker.docs.worldexecute.me/zh/guide/filters)。
|
||||||
|
|
||||||
## API
|
## API
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -655,6 +655,110 @@
|
||||||
justify-self: end;
|
justify-self: end;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.node-editor {
|
||||||
|
@apply mb-1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.node-nested {
|
||||||
|
@apply ml-4 border-l border-border pl-3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.leaf-row {
|
||||||
|
@apply mb-1.5 flex flex-wrap items-center gap-2 rounded-sm border border-border bg-surface-2 p-2.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.node-select {
|
||||||
|
@apply w-[120px] shrink-0 rounded border border-border-strong bg-surface px-2 py-1.5 font-ui text-[12.5px] font-medium text-text;
|
||||||
|
}
|
||||||
|
|
||||||
|
.node-path {
|
||||||
|
@apply w-[160px] shrink-0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.node-values {
|
||||||
|
@apply min-w-[180px] flex-1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.node-exclude {
|
||||||
|
@apply flex shrink-0 cursor-pointer items-center gap-1.5 text-[12px] font-medium text-muted;
|
||||||
|
}
|
||||||
|
|
||||||
|
.node-group {
|
||||||
|
@apply mb-1.5 rounded-sm border border-border bg-surface-2 p-2.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.node-group-head {
|
||||||
|
@apply mb-2 flex flex-wrap items-center gap-2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.node-combo {
|
||||||
|
@apply w-[130px] rounded border border-border-strong bg-surface px-2 py-1.5 font-ui text-[12.5px] font-semibold text-text;
|
||||||
|
}
|
||||||
|
|
||||||
|
.node-combo-label {
|
||||||
|
@apply inline-flex items-center rounded border border-border-strong bg-surface px-2 py-1.5 font-ui text-[12.5px] font-semibold text-text;
|
||||||
|
}
|
||||||
|
|
||||||
|
.node-actions {
|
||||||
|
@apply ml-auto flex flex-wrap items-center gap-1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.node-add {
|
||||||
|
@apply px-2 py-1 text-[12px];
|
||||||
|
}
|
||||||
|
|
||||||
|
.node-children {
|
||||||
|
@apply flex flex-col gap-1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.node-empty {
|
||||||
|
@apply py-1 text-[12px] italic text-muted;
|
||||||
|
}
|
||||||
|
|
||||||
|
.test-payload {
|
||||||
|
@apply font-mono text-[12px];
|
||||||
|
}
|
||||||
|
|
||||||
|
.test-result {
|
||||||
|
@apply mt-1 flex flex-col gap-1.5 rounded-sm border border-border bg-surface-2 p-2.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.test-result.ok {
|
||||||
|
@apply border-ok;
|
||||||
|
}
|
||||||
|
|
||||||
|
.test-badge {
|
||||||
|
@apply inline-flex w-fit items-center rounded-full bg-bad px-2 py-0.5 text-[11px] font-semibold text-white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.test-result.ok .test-badge {
|
||||||
|
@apply bg-ok;
|
||||||
|
}
|
||||||
|
|
||||||
|
.test-explanation {
|
||||||
|
@apply font-mono text-[12px] text-muted;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fragments-list {
|
||||||
|
@apply mb-2 flex flex-col gap-1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fragment-row {
|
||||||
|
@apply flex items-center gap-2 rounded-sm border border-border bg-surface-2 p-2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fragment-name {
|
||||||
|
@apply min-w-0 flex-1 truncate text-[13px] font-medium text-text;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fragment-action {
|
||||||
|
@apply shrink-0 px-2 py-1 text-[12px];
|
||||||
|
}
|
||||||
|
|
||||||
|
.fragment-save {
|
||||||
|
@apply flex items-center gap-2;
|
||||||
|
}
|
||||||
|
|
||||||
/* ---- Toasts ---- */
|
/* ---- Toasts ---- */
|
||||||
.toasts {
|
.toasts {
|
||||||
@apply pointer-events-none fixed bottom-7 left-1/2 z-[60] flex -translate-x-1/2 flex-col items-center gap-2;
|
@apply pointer-events-none fixed bottom-7 left-1/2 z-[60] flex -translate-x-1/2 flex-col items-center gap-2;
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import type { DeliveryMetrics, Group, Route, SendRecord } from "~/types";
|
import type { DeliveryMetrics, Group, Route, SendRecord } from "~/types";
|
||||||
|
import { collectEvents } from "~/composables/useFilterNode";
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
groups: Group[];
|
groups: Group[];
|
||||||
|
|
@ -107,9 +108,7 @@ function platformLabel(p?: string): string {
|
||||||
}
|
}
|
||||||
|
|
||||||
function routeEvents(r: Route): string[] {
|
function routeEvents(r: Route): string[] {
|
||||||
return r.filters
|
return collectEvents(r.ast ?? { all: r.filters });
|
||||||
.filter((f) => f.type === "event")
|
|
||||||
.flatMap((f) => (Array.isArray(f.match) ? f.match : [f.match]));
|
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -298,6 +298,7 @@
|
||||||
:open="editorOpen"
|
:open="editorOpen"
|
||||||
:route="editing"
|
:route="editing"
|
||||||
:saving="saving"
|
:saving="saving"
|
||||||
|
:group-id="selectedGroup?.id ?? null"
|
||||||
@close="editorOpen = false"
|
@close="editorOpen = false"
|
||||||
@save="onSave"
|
@save="onSave"
|
||||||
/>
|
/>
|
||||||
|
|
|
||||||
141
app/components/FilterNodeEditor.vue
Normal file
141
app/components/FilterNodeEditor.vue
Normal file
|
|
@ -0,0 +1,141 @@
|
||||||
|
<script setup lang="ts">
|
||||||
|
import type { NodeForm } from "~/composables/useFilterNode";
|
||||||
|
import { blankNode } from "~/composables/useFilterNode";
|
||||||
|
import { FILTER_TYPES, FILTER_OPS } from "~/types";
|
||||||
|
|
||||||
|
defineOptions({ name: "FilterNodeEditor" });
|
||||||
|
|
||||||
|
const props = withDefaults(
|
||||||
|
defineProps<{ node: NodeForm; depth?: number; deletable?: boolean }>(),
|
||||||
|
{ depth: 0, deletable: false },
|
||||||
|
);
|
||||||
|
|
||||||
|
const emit = defineEmits<{ (e: "remove"): void }>();
|
||||||
|
|
||||||
|
const { t } = useI18n();
|
||||||
|
|
||||||
|
const NODE_KINDS = ["all", "any"] as const;
|
||||||
|
|
||||||
|
function addChild(kind: "leaf" | "all" | "any" | "not"): void {
|
||||||
|
props.node.children.push(blankNode(kind));
|
||||||
|
}
|
||||||
|
|
||||||
|
function unwrapNot(): void {
|
||||||
|
const child = props.node.child;
|
||||||
|
if (!child) return;
|
||||||
|
props.node.kind = child.kind;
|
||||||
|
props.node.leaf = child.leaf;
|
||||||
|
props.node.children = child.children;
|
||||||
|
props.node.child = child.child;
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="node-editor" :class="{ 'node-nested': depth > 0 }">
|
||||||
|
<div v-if="node.kind === 'leaf'" class="leaf-row">
|
||||||
|
<select v-model="node.leaf.type" class="node-select">
|
||||||
|
<option v-for="ft in FILTER_TYPES" :key="ft" :value="ft">{{ t("filter." + ft) }}</option>
|
||||||
|
</select>
|
||||||
|
|
||||||
|
<input
|
||||||
|
v-if="node.leaf.type === 'field'"
|
||||||
|
v-model="node.leaf.path"
|
||||||
|
class="input node-path"
|
||||||
|
:placeholder="t('routeEditor.pathPlaceholder')"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<select
|
||||||
|
v-if="node.leaf.type !== 'keyword'"
|
||||||
|
v-model="node.leaf.op"
|
||||||
|
class="node-select"
|
||||||
|
:title="t('routeEditor.op')"
|
||||||
|
>
|
||||||
|
<option v-for="op in FILTER_OPS" :key="op" :value="op">{{ t("filterOp." + op) }}</option>
|
||||||
|
</select>
|
||||||
|
|
||||||
|
<TagInput
|
||||||
|
v-if="node.leaf.type === 'keyword' || node.leaf.op !== 'exists'"
|
||||||
|
v-model="node.leaf.values"
|
||||||
|
class="node-values"
|
||||||
|
:placeholder="t('routeEditor.valuesPlaceholder')"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<label class="inline node-exclude" :title="t('routeEditor.not')">
|
||||||
|
<input v-model="node.leaf.exclude" type="checkbox" />
|
||||||
|
<span>{{ t("routeEditor.not") }}</span>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<button
|
||||||
|
v-if="deletable"
|
||||||
|
type="button"
|
||||||
|
class="icon-btn danger"
|
||||||
|
:title="t('routeEditor.remove')"
|
||||||
|
@click="emit('remove')"
|
||||||
|
>
|
||||||
|
✕
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else-if="node.kind === 'all' || node.kind === 'any'" class="node-group">
|
||||||
|
<div class="node-group-head">
|
||||||
|
<select v-model="node.kind" class="node-combo">
|
||||||
|
<option v-for="k in NODE_KINDS" :key="k" :value="k">{{ t("filterNode." + k) }}</option>
|
||||||
|
</select>
|
||||||
|
<div class="node-actions">
|
||||||
|
<button type="button" class="btn btn-ghost node-add" @click="addChild('leaf')">
|
||||||
|
+ {{ t("routeEditor.addLeaf") }}
|
||||||
|
</button>
|
||||||
|
<button type="button" class="btn btn-ghost node-add" @click="addChild('all')">
|
||||||
|
+ {{ t("routeEditor.addGroup") }}
|
||||||
|
</button>
|
||||||
|
<button type="button" class="btn btn-ghost node-add" @click="addChild('not')">
|
||||||
|
+ {{ t("routeEditor.addNot") }}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
v-if="deletable"
|
||||||
|
type="button"
|
||||||
|
class="icon-btn danger"
|
||||||
|
:title="t('routeEditor.remove')"
|
||||||
|
@click="emit('remove')"
|
||||||
|
>
|
||||||
|
✕
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="node-children">
|
||||||
|
<FilterNodeEditor
|
||||||
|
v-for="(child, i) in node.children"
|
||||||
|
:key="i"
|
||||||
|
:node="child"
|
||||||
|
:depth="depth + 1"
|
||||||
|
deletable
|
||||||
|
@remove="node.children.splice(i, 1)"
|
||||||
|
/>
|
||||||
|
<div v-if="node.children.length === 0" class="node-empty">{{ t("filterNode.empty") }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else class="node-group node-not">
|
||||||
|
<div class="node-group-head">
|
||||||
|
<span class="node-combo node-combo-label">{{ t("filterNode.not") }}</span>
|
||||||
|
<div class="node-actions">
|
||||||
|
<button type="button" class="btn btn-ghost node-add" @click="unwrapNot">
|
||||||
|
{{ t("filterNode.unwrap") }}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
v-if="deletable"
|
||||||
|
type="button"
|
||||||
|
class="icon-btn danger"
|
||||||
|
:title="t('routeEditor.remove')"
|
||||||
|
@click="emit('remove')"
|
||||||
|
>
|
||||||
|
✕
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="node-children">
|
||||||
|
<FilterNodeEditor v-if="node.child" :node="node.child" :depth="depth + 1" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
@ -35,18 +35,10 @@
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="route-card-filters">
|
<div class="route-card-filters">
|
||||||
<span
|
<span v-if="summary" class="route-chip">
|
||||||
v-for="(f, i) in route.filters"
|
<span class="route-chip-val">{{ summary }}</span>
|
||||||
:key="i"
|
|
||||||
class="route-chip"
|
|
||||||
:class="{ exclude: f.exclude }"
|
|
||||||
>
|
|
||||||
<span class="route-chip-type"
|
|
||||||
>{{ f.exclude ? t("routeEditor.not") + " " : "" }}{{ t("filter." + f.type) }}</span
|
|
||||||
>
|
|
||||||
<span class="route-chip-val">{{ fmtMatch(f.match) }}</span>
|
|
||||||
</span>
|
</span>
|
||||||
<span v-if="!route.filters.length" class="route-chip route-chip-empty">
|
<span v-else class="route-chip route-chip-empty">
|
||||||
<span class="route-chip-type">{{ t("route.noFilters") }}</span>
|
<span class="route-chip-type">{{ t("route.noFilters") }}</span>
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -151,8 +143,8 @@
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import type { Route } from "~/types";
|
import type { FilterNode, Route } from "~/types";
|
||||||
import { fmtMatch } from "~/types";
|
import { describeNode } from "~/composables/useFilterNode";
|
||||||
|
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
|
|
||||||
|
|
@ -162,6 +154,11 @@ const props = defineProps<{
|
||||||
atLast?: boolean;
|
atLast?: boolean;
|
||||||
readonly?: boolean;
|
readonly?: boolean;
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
|
const summary = computed(() => {
|
||||||
|
const node: FilterNode = props.route.ast ?? { all: props.route.filters };
|
||||||
|
return describeNode(node, t);
|
||||||
|
});
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
(e: "toggle", route: Route): void;
|
(e: "toggle", route: Route): void;
|
||||||
(e: "edit", route: Route): void;
|
(e: "edit", route: Route): void;
|
||||||
|
|
|
||||||
|
|
@ -1,220 +1,39 @@
|
||||||
<template>
|
|
||||||
<Teleport to="body">
|
|
||||||
<Transition name="fade">
|
|
||||||
<div v-if="open" class="overlay" @click.self="close"></div>
|
|
||||||
</Transition>
|
|
||||||
<Transition name="slide">
|
|
||||||
<aside v-if="open" class="editor" role="dialog" aria-modal="true">
|
|
||||||
<div class="editor-head">
|
|
||||||
<div class="editor-heading">
|
|
||||||
<span class="editor-eyebrow">{{ t("routeEditor.eyebrow") }}</span>
|
|
||||||
<h2>{{ isEdit ? t("routeEditor.editTitle") : t("routeEditor.newTitle") }}</h2>
|
|
||||||
</div>
|
|
||||||
<button class="icon-btn" :title="t('routeEditor.close')" @click="close">✕</button>
|
|
||||||
</div>
|
|
||||||
<form class="editor-body" @submit.prevent="save">
|
|
||||||
<section class="editor-section">
|
|
||||||
<h3 class="editor-section-title">{{ t("routeEditor.sectionBasic") }}</h3>
|
|
||||||
<div v-if="!isEdit" class="field">
|
|
||||||
<label
|
|
||||||
>{{ t("routeEditor.templates") }}
|
|
||||||
<span class="lbl-note">{{ t("routeEditor.templatesNote") }}</span></label
|
|
||||||
>
|
|
||||||
<div class="templates">
|
|
||||||
<button
|
|
||||||
v-for="tmpl in ROUTE_TEMPLATES"
|
|
||||||
:key="tmpl.id"
|
|
||||||
type="button"
|
|
||||||
class="template-chip"
|
|
||||||
:class="{ active: form.id === tmpl.id }"
|
|
||||||
@click="applyTemplate(tmpl)"
|
|
||||||
>
|
|
||||||
{{ t(tmpl.nameKey) }}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="field">
|
|
||||||
<label>{{ t("routeEditor.name") }}</label>
|
|
||||||
<input
|
|
||||||
v-model="form.name"
|
|
||||||
type="text"
|
|
||||||
class="input"
|
|
||||||
:placeholder="t('routeEditor.namePlaceholder')"
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div class="field">
|
|
||||||
<label>{{ t("routeEditor.id") }}</label>
|
|
||||||
<input v-model="form.id" type="text" class="input" placeholder="my-route" required />
|
|
||||||
<div class="hint">{{ t("routeEditor.idHint") }}</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
<section class="editor-section">
|
|
||||||
<h3 class="editor-section-title">{{ t("routeEditor.sectionOptions") }}</h3>
|
|
||||||
<div class="field inline">
|
|
||||||
<input v-model="form.enabled" type="checkbox" />
|
|
||||||
<span>{{ t("routeEditor.enabled") }}</span>
|
|
||||||
</div>
|
|
||||||
<div class="field inline">
|
|
||||||
<input v-model="form.fallback" type="checkbox" />
|
|
||||||
<span
|
|
||||||
>{{ t("routeEditor.fallback") }}
|
|
||||||
<span class="lbl-note">{{ t("routeEditor.fallbackHint") }}</span></span
|
|
||||||
>
|
|
||||||
</div>
|
|
||||||
<div class="field inline">
|
|
||||||
<input v-model="form.stop" type="checkbox" />
|
|
||||||
<span
|
|
||||||
>{{ t("routeEditor.stop") }}
|
|
||||||
<span class="lbl-note">{{ t("routeEditor.stopHint") }}</span></span
|
|
||||||
>
|
|
||||||
</div>
|
|
||||||
<div class="field">
|
|
||||||
<label
|
|
||||||
>{{ t("routeEditor.discordRoles") }}
|
|
||||||
<span class="lbl-note">{{ t("routeEditor.discordRolesNote") }}</span></label
|
|
||||||
>
|
|
||||||
<input
|
|
||||||
v-model="form.discordRolesText"
|
|
||||||
type="text"
|
|
||||||
class="input"
|
|
||||||
:placeholder="t('routeEditor.discordRolesPlaceholder')"
|
|
||||||
/>
|
|
||||||
<div class="hint">{{ t("routeEditor.discordRolesHint") }}</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
<section class="editor-section">
|
|
||||||
<h3 class="editor-section-title">{{ t("routeEditor.sectionFilters") }}</h3>
|
|
||||||
<div class="field">
|
|
||||||
<label
|
|
||||||
>{{ t("routeEditor.filters") }}
|
|
||||||
<span class="lbl-note">{{ t("routeEditor.filtersNote") }}</span></label
|
|
||||||
>
|
|
||||||
<div v-for="(f, i) in form.filters" :key="i" class="filter-row">
|
|
||||||
<select v-model="f.type">
|
|
||||||
<option v-for="ft in FILTER_TYPES" :key="ft" :value="ft">
|
|
||||||
{{ t("filter." + ft) }}
|
|
||||||
</option>
|
|
||||||
</select>
|
|
||||||
<input
|
|
||||||
v-model="f.matchText"
|
|
||||||
type="text"
|
|
||||||
:placeholder="t('routeEditor.matchPlaceholder')"
|
|
||||||
/>
|
|
||||||
<label class="inline">
|
|
||||||
<input v-model="f.exclude" type="checkbox" /><span>{{
|
|
||||||
t("routeEditor.not")
|
|
||||||
}}</span>
|
|
||||||
</label>
|
|
||||||
<button type="button" class="icon-btn danger" @click="form.filters.splice(i, 1)">
|
|
||||||
✕
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<button type="button" class="btn btn-ghost add-filter" @click="addFilter">
|
|
||||||
{{ t("routeEditor.addFilter") }}
|
|
||||||
</button>
|
|
||||||
<div class="err">{{ filterError }}</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
<section class="editor-section">
|
|
||||||
<h3 class="editor-section-title">{{ t("routeEditor.sectionTargets") }}</h3>
|
|
||||||
<div class="field">
|
|
||||||
<label
|
|
||||||
>{{ t("routeEditor.targets") }}
|
|
||||||
<span class="lbl-note">{{ t("routeEditor.targetsNote") }}</span></label
|
|
||||||
>
|
|
||||||
<div v-for="(tg, i) in form.targets" :key="i" class="target-row">
|
|
||||||
<select v-model="tg.platform">
|
|
||||||
<option value="discord">Discord</option>
|
|
||||||
<option value="telegram">Telegram</option>
|
|
||||||
</select>
|
|
||||||
<template v-if="tg.platform === 'discord'">
|
|
||||||
<input
|
|
||||||
v-model="tg.channelId"
|
|
||||||
type="text"
|
|
||||||
class="tg-in1"
|
|
||||||
:placeholder="t('routeEditor.channelPlaceholder')"
|
|
||||||
/>
|
|
||||||
<input
|
|
||||||
v-model="tg.threadId"
|
|
||||||
type="text"
|
|
||||||
class="tg-in2"
|
|
||||||
:placeholder="t('routeEditor.threadPlaceholder')"
|
|
||||||
/>
|
|
||||||
</template>
|
|
||||||
<template v-else>
|
|
||||||
<input
|
|
||||||
v-model="tg.chatId"
|
|
||||||
type="text"
|
|
||||||
class="tg-in1"
|
|
||||||
:placeholder="t('routeEditor.chatPlaceholder')"
|
|
||||||
/>
|
|
||||||
<input
|
|
||||||
v-model="tg.topicId"
|
|
||||||
type="text"
|
|
||||||
class="tg-in2"
|
|
||||||
:placeholder="t('routeEditor.topicPlaceholder')"
|
|
||||||
/>
|
|
||||||
</template>
|
|
||||||
<button type="button" class="icon-btn danger" @click="form.targets.splice(i, 1)">
|
|
||||||
✕
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<button type="button" class="btn btn-ghost add-filter" @click="addTarget">
|
|
||||||
{{ t("routeEditor.addTarget") }}
|
|
||||||
</button>
|
|
||||||
<div class="err">{{ targetError }}</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
<div class="err">{{ formError }}</div>
|
|
||||||
</form>
|
|
||||||
<div class="editor-foot">
|
|
||||||
<button class="btn btn-ghost" type="button" @click="close">
|
|
||||||
{{ t("routeEditor.cancel") }}
|
|
||||||
</button>
|
|
||||||
<button class="btn btn-accent" type="button" :disabled="saving" @click="save">
|
|
||||||
{{ t("routeEditor.save") }}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</aside>
|
|
||||||
</Transition>
|
|
||||||
</Teleport>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { reactive, watch } from "vue";
|
import { computed, reactive, ref, watch } from "vue";
|
||||||
import type { Filter, Route, RouteTarget } from "~/types";
|
import type { Filter, FilterNode, NamedFragment, Route, RouteTarget, RouteTemplate } from "~/types";
|
||||||
import { FILTER_TYPES, ROUTE_TEMPLATES, fmtMatch } from "~/types";
|
import { ROUTE_TEMPLATES } from "~/types";
|
||||||
|
import type { NodeForm } from "~/composables/useFilterNode";
|
||||||
|
import { blankLeafForm, blankNode, nodeToForm, nodeFormToRouteFilters, formToNode } from "~/composables/useFilterNode";
|
||||||
|
|
||||||
interface FilterForm extends Filter {
|
interface TargetForm {
|
||||||
matchText: string;
|
platform: "discord" | "telegram";
|
||||||
|
channelId: string;
|
||||||
|
threadId: string;
|
||||||
|
chatId: string;
|
||||||
|
topicId: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const props = defineProps<{ open: boolean; route: Route | null; saving: boolean }>();
|
const props = withDefaults(
|
||||||
const emit = defineEmits<{
|
defineProps<{ open: boolean; route: Route | null; saving: boolean; groupId?: string | null }>(),
|
||||||
(e: "close"): void;
|
{ groupId: null },
|
||||||
(e: "save", route: Route): void;
|
);
|
||||||
}>();
|
|
||||||
|
const emit = defineEmits<{ (e: "close"): void; (e: "save", route: Route): void }>();
|
||||||
|
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
|
|
||||||
const isEdit = computed(() => props.route != null);
|
const isEdit = computed(() => props.route != null);
|
||||||
const filterError = ref("");
|
const filterError = ref("");
|
||||||
const targetError = ref("");
|
const targetError = ref("");
|
||||||
const formError = ref("");
|
const formError = ref("");
|
||||||
|
|
||||||
interface TargetForm extends RouteTarget {
|
const blankTarget = (): TargetForm => ({
|
||||||
platform: "discord" | "telegram";
|
platform: "discord",
|
||||||
}
|
channelId: "",
|
||||||
|
threadId: "",
|
||||||
function blankTarget(): TargetForm {
|
chatId: "",
|
||||||
return {
|
topicId: "",
|
||||||
platform: "discord",
|
});
|
||||||
channelId: "",
|
|
||||||
threadId: "",
|
|
||||||
chatId: "",
|
|
||||||
topicId: "",
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
const form = reactive({
|
const form = reactive({
|
||||||
id: "",
|
id: "",
|
||||||
|
|
@ -224,34 +43,206 @@ const form = reactive({
|
||||||
stop: false,
|
stop: false,
|
||||||
discordRolesText: "",
|
discordRolesText: "",
|
||||||
targets: [] as TargetForm[],
|
targets: [] as TargetForm[],
|
||||||
filters: [] as FilterForm[],
|
|
||||||
});
|
});
|
||||||
|
|
||||||
function blankFilter(): FilterForm {
|
const root = ref<NodeForm>(blankNode("all"));
|
||||||
return { type: "event", match: "", exclude: false, matchText: "" };
|
|
||||||
}
|
|
||||||
|
|
||||||
function addFilter(): void {
|
const { fragments, load: loadFragments, save: saveFragments } = useFragmentsApi();
|
||||||
form.filters.push(blankFilter());
|
const fragmentName = ref("");
|
||||||
filterError.value = "";
|
const fragmentError = ref("");
|
||||||
}
|
|
||||||
|
|
||||||
function applyTemplate(tmpl: (typeof ROUTE_TEMPLATES)[number]): void {
|
const testPayload = ref("");
|
||||||
|
const testEvent = ref("");
|
||||||
|
const testResult = ref<{ matched: boolean; explanation: string } | null>(null);
|
||||||
|
const testError = ref("");
|
||||||
|
const testing = ref(false);
|
||||||
|
|
||||||
|
function applyTemplate(tmpl: RouteTemplate): void {
|
||||||
form.id = tmpl.id;
|
form.id = tmpl.id;
|
||||||
form.name = t(tmpl.nameKey);
|
form.name = t(tmpl.nameKey);
|
||||||
form.filters = tmpl.filters.map((f) => ({
|
|
||||||
...f,
|
|
||||||
matchText: fmtMatch(f.match),
|
|
||||||
})) as FilterForm[];
|
|
||||||
form.targets = [blankTarget()];
|
form.targets = [blankTarget()];
|
||||||
filterError.value = "";
|
root.value = tmpl.filters.length ? nodeToForm({ all: tmpl.filters }) : blankNode("all");
|
||||||
targetError.value = "";
|
|
||||||
formError.value = "";
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function addTarget(): void {
|
function addTarget(): void {
|
||||||
form.targets.push(blankTarget());
|
form.targets.push(blankTarget());
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateNode(nf: NodeForm): string | null {
|
||||||
|
if (nf.kind === "leaf") {
|
||||||
|
if (nf.leaf.type === "field" && !nf.leaf.path.trim()) return t("routeEditor.errPath");
|
||||||
|
if (nf.leaf.op !== "exists" && nf.leaf.values.every((v) => !v.trim()))
|
||||||
|
return t("routeEditor.errValues");
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (nf.kind === "all" || nf.kind === "any") {
|
||||||
|
if (nf.children.length === 0) return t("routeEditor.errGroupEmpty");
|
||||||
|
for (const c of nf.children) {
|
||||||
|
const err = validateNode(c);
|
||||||
|
if (err) return err;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (nf.child) return validateNode(nf.child);
|
||||||
|
return t("routeEditor.errGroupEmpty");
|
||||||
|
}
|
||||||
|
|
||||||
|
function collect(): Route | null {
|
||||||
|
filterError.value = "";
|
||||||
targetError.value = "";
|
targetError.value = "";
|
||||||
|
formError.value = "";
|
||||||
|
|
||||||
|
let filters: Filter[] = [];
|
||||||
|
let ast: FilterNode | undefined;
|
||||||
|
if (!form.fallback) {
|
||||||
|
const err = validateNode(root.value);
|
||||||
|
if (err) {
|
||||||
|
filterError.value = err;
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const res = nodeFormToRouteFilters(root.value);
|
||||||
|
filters = res.filters;
|
||||||
|
ast = res.ast;
|
||||||
|
}
|
||||||
|
|
||||||
|
const targets: RouteTarget[] = [];
|
||||||
|
for (const tg of form.targets) {
|
||||||
|
if (tg.platform === "telegram") {
|
||||||
|
const chatId = tg.chatId.trim();
|
||||||
|
if (!chatId) continue;
|
||||||
|
targets.push({ platform: "telegram", chatId, topicId: tg.topicId.trim() || undefined });
|
||||||
|
} else {
|
||||||
|
const channelId = tg.channelId.trim();
|
||||||
|
if (!channelId) continue;
|
||||||
|
targets.push({ platform: "discord", channelId, threadId: tg.threadId.trim() || undefined });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const discordRoles = form.discordRolesText
|
||||||
|
.split(",")
|
||||||
|
.map((s) => s.trim())
|
||||||
|
.filter(Boolean);
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: form.id.trim(),
|
||||||
|
name: form.name.trim(),
|
||||||
|
enabled: form.enabled,
|
||||||
|
fallback: form.fallback || undefined,
|
||||||
|
stop: form.stop || undefined,
|
||||||
|
discordRoleIds: discordRoles.length ? discordRoles : undefined,
|
||||||
|
filters,
|
||||||
|
...(ast ? { ast } : {}),
|
||||||
|
targets,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function save(): void {
|
||||||
|
const route = collect();
|
||||||
|
if (!route) return;
|
||||||
|
if (!/^[a-z0-9][a-z0-9-]*$/.test(route.id)) {
|
||||||
|
formError.value = t("routeEditor.errIdFormat");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!route.name) {
|
||||||
|
formError.value = t("routeEditor.errName");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!route.targets.length) {
|
||||||
|
targetError.value = t("routeEditor.errTargets");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
form.targets.forEach((tg, i) => {
|
||||||
|
if (tg.platform === "telegram" && !tg.chatId.trim()) {
|
||||||
|
targetError.value = t("routeEditor.errChat", { n: i + 1 });
|
||||||
|
} else if (tg.platform === "discord" && !tg.channelId.trim()) {
|
||||||
|
targetError.value = t("routeEditor.errChannel", { n: i + 1 });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
if (targetError.value) return;
|
||||||
|
emit("save", route);
|
||||||
|
}
|
||||||
|
|
||||||
|
function close(): void {
|
||||||
|
emit("close");
|
||||||
|
}
|
||||||
|
|
||||||
|
async function runTest(): Promise<void> {
|
||||||
|
testError.value = "";
|
||||||
|
testResult.value = null;
|
||||||
|
let payload: unknown;
|
||||||
|
try {
|
||||||
|
payload = JSON.parse(testPayload.value);
|
||||||
|
} catch {
|
||||||
|
testError.value = t("routeEditor.testInvalidJson");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const node = formToNode(root.value);
|
||||||
|
if (!node) {
|
||||||
|
testError.value = t("routeEditor.errAddFilter");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
testing.value = true;
|
||||||
|
try {
|
||||||
|
testResult.value = await apiFetch<{ matched: boolean; explanation: string }>(
|
||||||
|
"/admin/api/test-match",
|
||||||
|
{
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify({ node, event: testEvent.value.trim() || undefined, payload }),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
} catch (err) {
|
||||||
|
testError.value = err instanceof Error ? err.message : String(err);
|
||||||
|
} finally {
|
||||||
|
testing.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function insertFragment(frag: NamedFragment): void {
|
||||||
|
const child = nodeToForm(frag.node);
|
||||||
|
if (root.value.kind === "all" || root.value.kind === "any") {
|
||||||
|
root.value.children.push(child);
|
||||||
|
} else {
|
||||||
|
root.value = {
|
||||||
|
kind: "all",
|
||||||
|
leaf: blankLeafForm(),
|
||||||
|
children: [root.value, child],
|
||||||
|
child: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveAsFragment(): Promise<void> {
|
||||||
|
fragmentError.value = "";
|
||||||
|
const name = fragmentName.value.trim();
|
||||||
|
if (!name) {
|
||||||
|
fragmentError.value = t("routeEditor.fragmentsNameRequired");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!props.groupId) return;
|
||||||
|
const node = formToNode(root.value);
|
||||||
|
if (!node) {
|
||||||
|
fragmentError.value = t("routeEditor.errAddFilter");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const id = `frag-${Math.random().toString(36).slice(2, 10)}`;
|
||||||
|
const next: NamedFragment[] = [
|
||||||
|
...fragments.value.filter((f) => f.id !== id),
|
||||||
|
{ id, groupId: props.groupId, name, node },
|
||||||
|
];
|
||||||
|
try {
|
||||||
|
await saveFragments(props.groupId, next);
|
||||||
|
fragmentName.value = "";
|
||||||
|
} catch (err) {
|
||||||
|
fragmentError.value = err instanceof Error ? err.message : String(err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function deleteFragment(frag: NamedFragment): void {
|
||||||
|
if (!props.groupId) return;
|
||||||
|
const next = fragments.value.filter((f) => f.id !== frag.id);
|
||||||
|
saveFragments(props.groupId, next).catch((err) => {
|
||||||
|
fragmentError.value = err instanceof Error ? err.message : String(err);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
watch(
|
watch(
|
||||||
|
|
@ -267,110 +258,245 @@ watch(
|
||||||
form.discordRolesText = r?.discordRoleIds?.length ? r.discordRoleIds.join(", ") : "";
|
form.discordRolesText = r?.discordRoleIds?.length ? r.discordRoleIds.join(", ") : "";
|
||||||
form.targets =
|
form.targets =
|
||||||
r && r.targets.length
|
r && r.targets.length
|
||||||
? r.targets.map((tg) => ({ ...blankTarget(), ...tg }))
|
? r.targets.map((tg) => ({
|
||||||
|
...blankTarget(),
|
||||||
|
...tg,
|
||||||
|
platform: tg.platform === "telegram" ? "telegram" : "discord",
|
||||||
|
}))
|
||||||
: [blankTarget()];
|
: [blankTarget()];
|
||||||
form.filters = (
|
if (r?.ast) {
|
||||||
r && r.filters.length
|
root.value = nodeToForm(r.ast);
|
||||||
? r.filters
|
} else if (r && r.filters.length) {
|
||||||
: form.fallback
|
root.value = nodeToForm({ all: r.filters });
|
||||||
? []
|
} else {
|
||||||
: [{ type: "event", match: "", exclude: false }]
|
root.value = blankNode("all");
|
||||||
).map((f) => ({ ...f, matchText: fmtMatch(f.match) })) as FilterForm[];
|
}
|
||||||
filterError.value = "";
|
filterError.value = "";
|
||||||
targetError.value = "";
|
targetError.value = "";
|
||||||
formError.value = "";
|
formError.value = "";
|
||||||
|
testPayload.value = "";
|
||||||
|
testEvent.value = "";
|
||||||
|
testResult.value = null;
|
||||||
|
testError.value = "";
|
||||||
|
fragmentName.value = "";
|
||||||
|
fragmentError.value = "";
|
||||||
|
if (props.groupId) loadFragments(props.groupId);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
watch(
|
|
||||||
() => form.fallback,
|
|
||||||
(v) => {
|
|
||||||
if (v && form.filters.every((f) => f.matchText.trim() === "")) {
|
|
||||||
form.filters = [];
|
|
||||||
filterError.value = "";
|
|
||||||
}
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
function close(): void {
|
|
||||||
emit("close");
|
|
||||||
}
|
|
||||||
|
|
||||||
function collect(): Route | null {
|
|
||||||
const filters: Filter[] = [];
|
|
||||||
for (let i = 0; i < form.filters.length; i++) {
|
|
||||||
const f = form.filters[i]!;
|
|
||||||
const match = parseMatch(f.matchText);
|
|
||||||
if (!match) {
|
|
||||||
filterError.value = t("routeEditor.errFilterMatch", { n: i + 1 });
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
filters.push({ type: f.type, match, exclude: f.exclude });
|
|
||||||
}
|
|
||||||
filterError.value = "";
|
|
||||||
if (!form.fallback && !filters.length) {
|
|
||||||
filterError.value = t("routeEditor.errAddFilter");
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const targets: RouteTarget[] = [];
|
|
||||||
for (let i = 0; i < form.targets.length; i++) {
|
|
||||||
const tg = form.targets[i]!;
|
|
||||||
targets.push({
|
|
||||||
platform: tg.platform,
|
|
||||||
channelId: (tg.channelId ?? "").trim() || undefined,
|
|
||||||
threadId: (tg.threadId ?? "").trim() || undefined,
|
|
||||||
chatId: (tg.chatId ?? "").trim() || undefined,
|
|
||||||
topicId: (tg.topicId ?? "").trim() || undefined,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
targetError.value = "";
|
|
||||||
|
|
||||||
const discordRoles = form.discordRolesText
|
|
||||||
.split(",")
|
|
||||||
.map((s) => s.trim())
|
|
||||||
.filter(Boolean);
|
|
||||||
|
|
||||||
return {
|
|
||||||
id: form.id.trim(),
|
|
||||||
name: form.name.trim(),
|
|
||||||
enabled: form.enabled,
|
|
||||||
fallback: form.fallback || undefined,
|
|
||||||
stop: form.stop || undefined,
|
|
||||||
discordRoleIds: discordRoles.length ? discordRoles : undefined,
|
|
||||||
filters,
|
|
||||||
targets,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function save(): void {
|
|
||||||
formError.value = "";
|
|
||||||
const route = collect();
|
|
||||||
if (!route) return;
|
|
||||||
if (!/^[a-z0-9][a-z0-9-]*$/.test(route.id)) {
|
|
||||||
formError.value = t("routeEditor.errIdFormat");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (!route.name) {
|
|
||||||
formError.value = t("routeEditor.errName");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (!route.targets.length) {
|
|
||||||
targetError.value = t("routeEditor.errTargets");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
for (let i = 0; i < route.targets.length; i++) {
|
|
||||||
const tg = route.targets[i]!;
|
|
||||||
if (tg.platform === "telegram") {
|
|
||||||
if (!tg.chatId) {
|
|
||||||
targetError.value = t("routeEditor.errChat", { n: i + 1 });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
} else if (!tg.channelId) {
|
|
||||||
targetError.value = t("routeEditor.errChannel", { n: i + 1 });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
emit("save", route);
|
|
||||||
}
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<Teleport to="body">
|
||||||
|
<Transition name="fade">
|
||||||
|
<div v-if="open" class="overlay" @click.self="close" />
|
||||||
|
</Transition>
|
||||||
|
<Transition name="slide">
|
||||||
|
<aside v-if="open" class="editor" role="dialog" aria-modal="true">
|
||||||
|
<div class="editor-head">
|
||||||
|
<div class="editor-heading">
|
||||||
|
<span class="editor-eyebrow">{{ t("routeEditor.eyebrow") }}</span>
|
||||||
|
<h2>{{ isEdit ? t("routeEditor.editTitle") : t("routeEditor.newTitle") }}</h2>
|
||||||
|
</div>
|
||||||
|
<button class="icon-btn" :title="t('routeEditor.close')" @click="close">✕</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form class="editor-body" @submit.prevent="save">
|
||||||
|
<section class="editor-section">
|
||||||
|
<h3 class="editor-section-title">{{ t("routeEditor.sectionBasic") }}</h3>
|
||||||
|
<div v-if="!isEdit" class="templates">
|
||||||
|
<button
|
||||||
|
v-for="tmpl in ROUTE_TEMPLATES"
|
||||||
|
:key="tmpl.id"
|
||||||
|
type="button"
|
||||||
|
class="template-chip"
|
||||||
|
:class="{ active: form.id === tmpl.id }"
|
||||||
|
@click="applyTemplate(tmpl)"
|
||||||
|
>
|
||||||
|
{{ t(tmpl.nameKey) }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label>{{ t("routeEditor.name") }}</label>
|
||||||
|
<input
|
||||||
|
v-model="form.name"
|
||||||
|
class="input"
|
||||||
|
:placeholder="t('routeEditor.namePlaceholder')"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label>{{ t("routeEditor.id") }}</label>
|
||||||
|
<input v-model="form.id" class="input" placeholder="my-route" required />
|
||||||
|
<div class="hint">{{ t("routeEditor.idHint") }}</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="editor-section">
|
||||||
|
<h3 class="editor-section-title">{{ t("routeEditor.sectionOptions") }}</h3>
|
||||||
|
<div class="field inline">
|
||||||
|
<input v-model="form.enabled" type="checkbox" />
|
||||||
|
<span>{{ t("routeEditor.enabled") }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="field inline">
|
||||||
|
<input v-model="form.fallback" type="checkbox" />
|
||||||
|
<span>
|
||||||
|
{{ t("routeEditor.fallback") }}
|
||||||
|
<span class="lbl-note">{{ t("routeEditor.fallbackHint") }}</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="field inline">
|
||||||
|
<input v-model="form.stop" type="checkbox" />
|
||||||
|
<span>
|
||||||
|
{{ t("routeEditor.stop") }}
|
||||||
|
<span class="lbl-note">{{ t("routeEditor.stopHint") }}</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label>{{ t("routeEditor.discordRoles") }}</label>
|
||||||
|
<input
|
||||||
|
v-model="form.discordRolesText"
|
||||||
|
class="input"
|
||||||
|
:placeholder="t('routeEditor.discordRolesPlaceholder')"
|
||||||
|
/>
|
||||||
|
<div class="hint">{{ t("routeEditor.discordRolesHint") }}</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="editor-section">
|
||||||
|
<h3 class="editor-section-title">{{ t("routeEditor.sectionFilters") }}</h3>
|
||||||
|
<div class="field">
|
||||||
|
<label>{{ t("routeEditor.filters") }}</label>
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<FilterNodeEditor v-if="!form.fallback" :node="root" />
|
||||||
|
<p v-else class="hint">{{ t("routeEditor.fallbackHint") }}</p>
|
||||||
|
</div>
|
||||||
|
<div v-if="filterError" class="err">{{ filterError }}</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="editor-section">
|
||||||
|
<h3 class="editor-section-title">{{ t("routeEditor.testMatch") }}</h3>
|
||||||
|
<div class="field">
|
||||||
|
<input
|
||||||
|
v-model="testEvent"
|
||||||
|
class="input"
|
||||||
|
:placeholder="t('routeEditor.testEvent')"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<textarea
|
||||||
|
v-model="testPayload"
|
||||||
|
class="input test-payload"
|
||||||
|
rows="6"
|
||||||
|
:placeholder="t('routeEditor.testPayloadPlaceholder')"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<button type="button" class="btn btn-ghost" :disabled="testing" @click="runTest">
|
||||||
|
{{ testing ? "…" : t("routeEditor.testRun") }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div v-if="testResult" class="test-result" :class="{ ok: testResult.matched }">
|
||||||
|
<span class="test-badge">
|
||||||
|
{{ testResult.matched ? t("routeEditor.testMatched") : t("routeEditor.testNotMatched") }}
|
||||||
|
</span>
|
||||||
|
<span class="test-explanation">{{ testResult.explanation }}</span>
|
||||||
|
</div>
|
||||||
|
<div v-if="testError" class="err">{{ testError }}</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section v-if="groupId" class="editor-section">
|
||||||
|
<h3 class="editor-section-title">{{ t("routeEditor.fragments") }}</h3>
|
||||||
|
<div class="fragments-list">
|
||||||
|
<div v-if="!fragments.length" class="hint">{{ t("routeEditor.fragmentsEmpty") }}</div>
|
||||||
|
<div v-for="frag in fragments" :key="frag.id" class="fragment-row">
|
||||||
|
<span class="fragment-name">{{ frag.name }}</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="btn btn-ghost fragment-action"
|
||||||
|
@click="insertFragment(frag)"
|
||||||
|
>
|
||||||
|
{{ t("routeEditor.fragmentsInsert") }}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="icon-btn danger"
|
||||||
|
:title="t('routeEditor.fragmentsDelete')"
|
||||||
|
@click="deleteFragment(frag)"
|
||||||
|
>
|
||||||
|
✕
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="field fragment-save">
|
||||||
|
<input
|
||||||
|
v-model="fragmentName"
|
||||||
|
class="input"
|
||||||
|
:placeholder="t('routeEditor.fragmentsNamePlaceholder')"
|
||||||
|
/>
|
||||||
|
<button type="button" class="btn btn-ghost" @click="saveAsFragment">
|
||||||
|
{{ t("routeEditor.fragmentsSave") }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div v-if="fragmentError" class="err">{{ fragmentError }}</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="editor-section">
|
||||||
|
<h3 class="editor-section-title">{{ t("routeEditor.sectionTargets") }}</h3>
|
||||||
|
<div v-for="(tg, i) in form.targets" :key="i" class="target-row">
|
||||||
|
<select v-model="tg.platform" class="tg-select">
|
||||||
|
<option value="discord">Discord</option>
|
||||||
|
<option value="telegram">Telegram</option>
|
||||||
|
</select>
|
||||||
|
<template v-if="tg.platform === 'discord'">
|
||||||
|
<input
|
||||||
|
v-model="tg.channelId"
|
||||||
|
class="input tg-in1"
|
||||||
|
:placeholder="t('routeEditor.channelPlaceholder')"
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
v-model="tg.threadId"
|
||||||
|
class="input tg-in2"
|
||||||
|
:placeholder="t('routeEditor.threadPlaceholder')"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
<template v-else>
|
||||||
|
<input
|
||||||
|
v-model="tg.chatId"
|
||||||
|
class="input tg-in1"
|
||||||
|
:placeholder="t('routeEditor.chatPlaceholder')"
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
v-model="tg.topicId"
|
||||||
|
class="input tg-in2"
|
||||||
|
:placeholder="t('routeEditor.topicPlaceholder')"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="icon-btn danger tg-del"
|
||||||
|
:title="t('routeEditor.remove')"
|
||||||
|
@click="form.targets.splice(i, 1)"
|
||||||
|
>
|
||||||
|
✕
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<button type="button" class="btn btn-ghost add-filter" @click="addTarget">
|
||||||
|
{{ t("routeEditor.addTarget") }}
|
||||||
|
</button>
|
||||||
|
<div v-if="targetError" class="err">{{ targetError }}</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<div v-if="formError" class="err">{{ formError }}</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<div class="editor-foot">
|
||||||
|
<button class="btn btn-ghost" @click="close">{{ t("routeEditor.cancel") }}</button>
|
||||||
|
<button class="btn btn-accent" :disabled="saving" @click="save">
|
||||||
|
{{ t("routeEditor.save") }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
</Transition>
|
||||||
|
</Teleport>
|
||||||
|
</template>
|
||||||
|
|
|
||||||
66
app/components/TagInput.vue
Normal file
66
app/components/TagInput.vue
Normal file
|
|
@ -0,0 +1,66 @@
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref } from "vue";
|
||||||
|
|
||||||
|
const props = defineProps<{ modelValue: string[]; placeholder?: string }>();
|
||||||
|
const emit = defineEmits<{ (e: "update:modelValue", value: string[]): void }>();
|
||||||
|
|
||||||
|
const text = ref("");
|
||||||
|
|
||||||
|
function add(value: string) {
|
||||||
|
const v = value.trim();
|
||||||
|
if (!v) return;
|
||||||
|
if (props.modelValue.includes(v)) return;
|
||||||
|
emit("update:modelValue", [...props.modelValue, v]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function onKeydown(e: KeyboardEvent) {
|
||||||
|
if (e.key === "Enter" || e.key === ",") {
|
||||||
|
e.preventDefault();
|
||||||
|
add(text.value);
|
||||||
|
text.value = "";
|
||||||
|
} else if (e.key === "Backspace" && text.value === "" && props.modelValue.length > 0) {
|
||||||
|
emit("update:modelValue", props.modelValue.slice(0, -1));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function onBlur() {
|
||||||
|
if (text.value) {
|
||||||
|
add(text.value);
|
||||||
|
text.value = "";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function remove(index: number) {
|
||||||
|
const next = [...props.modelValue];
|
||||||
|
next.splice(index, 1);
|
||||||
|
emit("update:modelValue", next);
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="flex flex-wrap items-center gap-1 rounded-md border border-border bg-surface/60 px-2 py-1.5 focus-within:border-accent">
|
||||||
|
<span
|
||||||
|
v-for="(v, i) in modelValue"
|
||||||
|
:key="`${v}-${i}`"
|
||||||
|
class="inline-flex items-center gap-1 rounded bg-accent/15 px-2 py-0.5 text-xs text-accent"
|
||||||
|
>
|
||||||
|
{{ v }}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="text-accent/70 hover:text-accent"
|
||||||
|
:aria-label="'remove'"
|
||||||
|
@click="remove(i)"
|
||||||
|
>
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
</span>
|
||||||
|
<input
|
||||||
|
v-model="text"
|
||||||
|
type="text"
|
||||||
|
class="min-w-[8rem] flex-1 bg-transparent text-sm text-text outline-none placeholder:text-text/40"
|
||||||
|
:placeholder="placeholder"
|
||||||
|
@keydown="onKeydown"
|
||||||
|
@blur="onBlur"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
181
app/composables/useFilterNode.ts
Normal file
181
app/composables/useFilterNode.ts
Normal file
|
|
@ -0,0 +1,181 @@
|
||||||
|
import type { Filter, FilterNode, FilterOp, FilterType } from "~/types";
|
||||||
|
|
||||||
|
export interface LeafForm {
|
||||||
|
type: FilterType;
|
||||||
|
path: string;
|
||||||
|
op: FilterOp;
|
||||||
|
values: string[];
|
||||||
|
exclude: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type NodeKind = "leaf" | "all" | "any" | "not";
|
||||||
|
|
||||||
|
export interface NodeForm {
|
||||||
|
kind: NodeKind;
|
||||||
|
leaf: LeafForm;
|
||||||
|
children: NodeForm[];
|
||||||
|
child: NodeForm | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function blankLeafForm(): LeafForm {
|
||||||
|
return { type: "event", path: "", op: "eq", values: [], exclude: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function blankNode(kind: NodeKind): NodeForm {
|
||||||
|
return {
|
||||||
|
kind,
|
||||||
|
leaf: blankLeafForm(),
|
||||||
|
children: kind === "all" || kind === "any" ? [blankNode("leaf")] : [],
|
||||||
|
child: kind === "not" ? blankNode("leaf") : null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function matchToValues(match: string | string[] | undefined): string[] {
|
||||||
|
if (match === undefined || match === null) return [];
|
||||||
|
return Array.isArray(match) ? [...match] : [match];
|
||||||
|
}
|
||||||
|
|
||||||
|
function leafToForm(f: Filter): LeafForm {
|
||||||
|
return {
|
||||||
|
type: f.type,
|
||||||
|
path: f.path ?? "",
|
||||||
|
op: f.op ?? "eq",
|
||||||
|
values: matchToValues(f.match),
|
||||||
|
exclude: !!f.exclude,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function nodeToForm(node: FilterNode): NodeForm {
|
||||||
|
if ("all" in node) {
|
||||||
|
return { kind: "all", leaf: blankLeafForm(), children: node.all.map(nodeToForm), child: null };
|
||||||
|
}
|
||||||
|
if ("any" in node) {
|
||||||
|
return { kind: "any", leaf: blankLeafForm(), children: node.any.map(nodeToForm), child: null };
|
||||||
|
}
|
||||||
|
if ("not" in node) {
|
||||||
|
return { kind: "not", leaf: blankLeafForm(), children: [], child: nodeToForm(node.not) };
|
||||||
|
}
|
||||||
|
return { kind: "leaf", leaf: leafToForm(node), children: [], child: null };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formToLeaf(lf: LeafForm): Filter | null {
|
||||||
|
const values = lf.values.map((v) => v.trim()).filter((v) => v.length > 0);
|
||||||
|
if (lf.op !== "exists" && values.length === 0) return null;
|
||||||
|
if (lf.type === "field" && lf.path.trim().length === 0) return null;
|
||||||
|
const filter: Filter = { type: lf.type };
|
||||||
|
if (lf.type === "field") filter.path = lf.path.trim();
|
||||||
|
if (lf.op !== "eq") filter.op = lf.op;
|
||||||
|
if (lf.op !== "exists") filter.match = values.length === 1 ? values[0] : values;
|
||||||
|
if (lf.exclude) filter.exclude = true;
|
||||||
|
return filter;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formToNode(nf: NodeForm): FilterNode | null {
|
||||||
|
if (nf.kind === "leaf") return formToLeaf(nf.leaf);
|
||||||
|
if (nf.kind === "all" || nf.kind === "any") {
|
||||||
|
const children = nf.children.map(formToNode).filter((c): c is FilterNode => c !== null);
|
||||||
|
if (children.length === 0) return null;
|
||||||
|
return nf.kind === "all" ? { all: children } : { any: children };
|
||||||
|
}
|
||||||
|
if (nf.kind === "not") {
|
||||||
|
const child = nf.child ? formToNode(nf.child) : null;
|
||||||
|
if (!child) return null;
|
||||||
|
return { not: child };
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isTrivialNode(nf: NodeForm): boolean {
|
||||||
|
if (nf.kind === "leaf") {
|
||||||
|
return nf.leaf.type !== "field" && nf.leaf.op === "eq" && !nf.leaf.exclude;
|
||||||
|
}
|
||||||
|
if (nf.kind === "all") return nf.children.every(isTrivialNode);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function flattenLeaves(nf: NodeForm): Filter[] {
|
||||||
|
const out: Filter[] = [];
|
||||||
|
const walk = (n: NodeForm): void => {
|
||||||
|
if (n.kind === "leaf") {
|
||||||
|
const f = formToLeaf(n.leaf);
|
||||||
|
if (f) out.push(f);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (n.kind === "all" || n.kind === "any") n.children.forEach(walk);
|
||||||
|
else if (n.kind === "not" && n.child) walk(n.child);
|
||||||
|
};
|
||||||
|
walk(nf);
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function nodeFormToRouteFilters(nf: NodeForm): { filters: Filter[]; ast?: FilterNode } {
|
||||||
|
if (isTrivialNode(nf)) {
|
||||||
|
return { filters: flattenLeaves(nf) };
|
||||||
|
}
|
||||||
|
const ast = formToNode(nf);
|
||||||
|
return { filters: [], ast: ast ?? undefined };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function collectEvents(node: FilterNode): string[] {
|
||||||
|
const out: string[] = [];
|
||||||
|
const walk = (n: FilterNode): void => {
|
||||||
|
if ("all" in n) {
|
||||||
|
n.all.forEach(walk);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if ("any" in n) {
|
||||||
|
n.any.forEach(walk);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if ("not" in n) {
|
||||||
|
walk(n.not);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (n.type === "event") {
|
||||||
|
const m = n.match;
|
||||||
|
if (Array.isArray(m)) out.push(...m);
|
||||||
|
else if (typeof m === "string" && m) out.push(m);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
walk(node);
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function describeLeaf(f: Filter, t: (key: string) => string): string {
|
||||||
|
const label = f.type === "field" ? (f.path ?? f.type) : t("filter." + f.type);
|
||||||
|
const op = f.op ?? "eq";
|
||||||
|
const match = f.match;
|
||||||
|
const values = match === undefined ? [] : Array.isArray(match) ? match : [match];
|
||||||
|
const value =
|
||||||
|
values.map((v) => JSON.stringify(v)).join(` ${t("filterNode.or")} `) || "\u2205";
|
||||||
|
let base: string;
|
||||||
|
if (f.type === "keyword") {
|
||||||
|
base = `${label} ${t("filterNode.matches")} ${value}`;
|
||||||
|
} else if (op === "exists") {
|
||||||
|
base = `${label} ${t("filterOp.exists")}`;
|
||||||
|
} else if (op === "eq") {
|
||||||
|
base = `${label} ${t("filterNode.is")} ${value}`;
|
||||||
|
} else {
|
||||||
|
base = `${label} ${t("filterOp." + op)} ${value}`;
|
||||||
|
}
|
||||||
|
return f.exclude ? `${t("filterNode.not")} (${base})` : base;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function describeNode(node: FilterNode, t: (key: string) => string): string {
|
||||||
|
if ("all" in node) {
|
||||||
|
return node.all
|
||||||
|
.map((c) => describeNode(c, t))
|
||||||
|
.filter((s) => s.length)
|
||||||
|
.join(` ${t("filterNode.and")} `);
|
||||||
|
}
|
||||||
|
if ("any" in node) {
|
||||||
|
const parts = node.any
|
||||||
|
.map((c) => describeNode(c, t))
|
||||||
|
.filter((s) => s.length);
|
||||||
|
return parts.length ? `(${parts.join(` ${t("filterNode.or")} `)})` : "";
|
||||||
|
}
|
||||||
|
if ("not" in node) {
|
||||||
|
return `${t("filterNode.not")} (${describeNode(node.not, t)})`;
|
||||||
|
}
|
||||||
|
return describeLeaf(node, t);
|
||||||
|
}
|
||||||
34
app/composables/useFragments.ts
Normal file
34
app/composables/useFragments.ts
Normal file
|
|
@ -0,0 +1,34 @@
|
||||||
|
import type { NamedFragment } from "~/types";
|
||||||
|
|
||||||
|
export function useFragmentsApi() {
|
||||||
|
const { needLogin } = useAuthState();
|
||||||
|
const fragments = ref<NamedFragment[]>([]);
|
||||||
|
const loading = ref(false);
|
||||||
|
const error = ref("");
|
||||||
|
|
||||||
|
async function load(groupId: string): Promise<void> {
|
||||||
|
loading.value = true;
|
||||||
|
error.value = "";
|
||||||
|
needLogin.value = false;
|
||||||
|
try {
|
||||||
|
const data = await apiFetch<{ fragments?: NamedFragment[] }>(
|
||||||
|
`/admin/api/groups/${encodeURIComponent(groupId)}/fragments`,
|
||||||
|
);
|
||||||
|
fragments.value = data.fragments ?? [];
|
||||||
|
} catch (err) {
|
||||||
|
if (!needLogin.value) error.value = err instanceof Error ? err.message : String(err);
|
||||||
|
} finally {
|
||||||
|
loading.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function save(groupId: string, next: NamedFragment[]): Promise<void> {
|
||||||
|
await apiFetch(`/admin/api/groups/${encodeURIComponent(groupId)}/fragments`, {
|
||||||
|
method: "PUT",
|
||||||
|
body: JSON.stringify({ fragments: next }),
|
||||||
|
});
|
||||||
|
fragments.value = next;
|
||||||
|
}
|
||||||
|
|
||||||
|
return { fragments, loading, needLogin, error, load, save };
|
||||||
|
}
|
||||||
|
|
@ -153,6 +153,28 @@ const en: Dict = {
|
||||||
"filter.action": "Action",
|
"filter.action": "Action",
|
||||||
"filter.branch": "Branch",
|
"filter.branch": "Branch",
|
||||||
"filter.keyword": "Keyword",
|
"filter.keyword": "Keyword",
|
||||||
|
"filter.field": "Field",
|
||||||
|
"filterOp.eq": "equals",
|
||||||
|
"filterOp.ne": "not equals",
|
||||||
|
"filterOp.contains": "contains",
|
||||||
|
"filterOp.startsWith": "starts with",
|
||||||
|
"filterOp.endsWith": "ends with",
|
||||||
|
"filterOp.regex": "matches regex",
|
||||||
|
"filterOp.gt": "greater than",
|
||||||
|
"filterOp.gte": "greater or equal",
|
||||||
|
"filterOp.lt": "less than",
|
||||||
|
"filterOp.lte": "less or equal",
|
||||||
|
"filterOp.in": "in",
|
||||||
|
"filterOp.exists": "exists",
|
||||||
|
"filterNode.all": "All of",
|
||||||
|
"filterNode.any": "Any of",
|
||||||
|
"filterNode.not": "not",
|
||||||
|
"filterNode.is": "is",
|
||||||
|
"filterNode.matches": "matches",
|
||||||
|
"filterNode.and": "and",
|
||||||
|
"filterNode.or": "or",
|
||||||
|
"filterNode.unwrap": "unwrap",
|
||||||
|
"filterNode.empty": "No conditions",
|
||||||
"routeEditor.editTitle": "Edit route",
|
"routeEditor.editTitle": "Edit route",
|
||||||
"routeEditor.newTitle": "New route",
|
"routeEditor.newTitle": "New route",
|
||||||
"routeEditor.eyebrow": "Route",
|
"routeEditor.eyebrow": "Route",
|
||||||
|
|
@ -219,6 +241,30 @@ const en: Dict = {
|
||||||
"routeEditor.errIdFormat": "ID must be a-z / 0-9 / dashes",
|
"routeEditor.errIdFormat": "ID must be a-z / 0-9 / dashes",
|
||||||
"routeEditor.errName": "Name is required",
|
"routeEditor.errName": "Name is required",
|
||||||
"routeEditor.errChannel": "Target {n} channel ID is required",
|
"routeEditor.errChannel": "Target {n} channel ID is required",
|
||||||
|
"routeEditor.addLeaf": "Add condition",
|
||||||
|
"routeEditor.addGroup": "Add group",
|
||||||
|
"routeEditor.addNot": "Add not",
|
||||||
|
"routeEditor.remove": "Remove",
|
||||||
|
"routeEditor.op": "Operator",
|
||||||
|
"routeEditor.pathPlaceholder": "payload.path",
|
||||||
|
"routeEditor.valuesPlaceholder": "Type a value and press Enter",
|
||||||
|
"routeEditor.testMatch": "Test match",
|
||||||
|
"routeEditor.testEvent": "Event (optional)",
|
||||||
|
"routeEditor.testPayloadPlaceholder": "Paste a JSON webhook payload",
|
||||||
|
"routeEditor.testRun": "Run test",
|
||||||
|
"routeEditor.testMatched": "Matches",
|
||||||
|
"routeEditor.testNotMatched": "Does not match",
|
||||||
|
"routeEditor.testInvalidJson": "Invalid JSON",
|
||||||
|
"routeEditor.fragments": "Fragments",
|
||||||
|
"routeEditor.fragmentsEmpty": "No fragments yet",
|
||||||
|
"routeEditor.fragmentsInsert": "Insert",
|
||||||
|
"routeEditor.fragmentsDelete": "Delete",
|
||||||
|
"routeEditor.fragmentsSave": "Save as fragment",
|
||||||
|
"routeEditor.fragmentsNamePlaceholder": "Fragment name",
|
||||||
|
"routeEditor.fragmentsNameRequired": "Fragment name is required",
|
||||||
|
"routeEditor.errPath": "Field filters need a path",
|
||||||
|
"routeEditor.errValues": "Add at least one value",
|
||||||
|
"routeEditor.errGroupEmpty": "Group needs at least one condition",
|
||||||
"groupEditor.editTitle": "Edit group",
|
"groupEditor.editTitle": "Edit group",
|
||||||
"groupEditor.newTitle": "New group",
|
"groupEditor.newTitle": "New group",
|
||||||
"groupEditor.eyebrow": "Group",
|
"groupEditor.eyebrow": "Group",
|
||||||
|
|
@ -479,6 +525,28 @@ const zh: Dict = {
|
||||||
"filter.action": "动作",
|
"filter.action": "动作",
|
||||||
"filter.branch": "分支",
|
"filter.branch": "分支",
|
||||||
"filter.keyword": "关键词",
|
"filter.keyword": "关键词",
|
||||||
|
"filter.field": "字段",
|
||||||
|
"filterOp.eq": "等于",
|
||||||
|
"filterOp.ne": "不等于",
|
||||||
|
"filterOp.contains": "包含",
|
||||||
|
"filterOp.startsWith": "开头是",
|
||||||
|
"filterOp.endsWith": "结尾是",
|
||||||
|
"filterOp.regex": "正则匹配",
|
||||||
|
"filterOp.gt": "大于",
|
||||||
|
"filterOp.gte": "大于等于",
|
||||||
|
"filterOp.lt": "小于",
|
||||||
|
"filterOp.lte": "小于等于",
|
||||||
|
"filterOp.in": "属于",
|
||||||
|
"filterOp.exists": "存在",
|
||||||
|
"filterNode.all": "全部满足",
|
||||||
|
"filterNode.any": "任一满足",
|
||||||
|
"filterNode.not": "非",
|
||||||
|
"filterNode.is": "是",
|
||||||
|
"filterNode.matches": "匹配",
|
||||||
|
"filterNode.and": "且",
|
||||||
|
"filterNode.or": "或",
|
||||||
|
"filterNode.unwrap": "取消非",
|
||||||
|
"filterNode.empty": "暂无条件",
|
||||||
"routeEditor.editTitle": "编辑路由",
|
"routeEditor.editTitle": "编辑路由",
|
||||||
"routeEditor.newTitle": "新建路由",
|
"routeEditor.newTitle": "新建路由",
|
||||||
"routeEditor.eyebrow": "路由",
|
"routeEditor.eyebrow": "路由",
|
||||||
|
|
@ -544,6 +612,30 @@ const zh: Dict = {
|
||||||
"routeEditor.errIdFormat": "ID 只能是 a-z / 0-9 / 短横线",
|
"routeEditor.errIdFormat": "ID 只能是 a-z / 0-9 / 短横线",
|
||||||
"routeEditor.errName": "名称为必填项",
|
"routeEditor.errName": "名称为必填项",
|
||||||
"routeEditor.errChannel": "第 {n} 个目标频道 ID 为必填项",
|
"routeEditor.errChannel": "第 {n} 个目标频道 ID 为必填项",
|
||||||
|
"routeEditor.addLeaf": "添加条件",
|
||||||
|
"routeEditor.addGroup": "添加分组",
|
||||||
|
"routeEditor.addNot": "添加非",
|
||||||
|
"routeEditor.remove": "删除",
|
||||||
|
"routeEditor.op": "操作符",
|
||||||
|
"routeEditor.pathPlaceholder": "payload.path",
|
||||||
|
"routeEditor.valuesPlaceholder": "输入值后回车",
|
||||||
|
"routeEditor.testMatch": "测试匹配",
|
||||||
|
"routeEditor.testEvent": "事件类型(可选)",
|
||||||
|
"routeEditor.testPayloadPlaceholder": "粘贴 JSON webhook 事件样本",
|
||||||
|
"routeEditor.testRun": "运行测试",
|
||||||
|
"routeEditor.testMatched": "匹配",
|
||||||
|
"routeEditor.testNotMatched": "不匹配",
|
||||||
|
"routeEditor.testInvalidJson": "无效的 JSON",
|
||||||
|
"routeEditor.fragments": "过滤器片段",
|
||||||
|
"routeEditor.fragmentsEmpty": "暂无片段",
|
||||||
|
"routeEditor.fragmentsInsert": "插入",
|
||||||
|
"routeEditor.fragmentsDelete": "删除",
|
||||||
|
"routeEditor.fragmentsSave": "存为片段",
|
||||||
|
"routeEditor.fragmentsNamePlaceholder": "片段名称",
|
||||||
|
"routeEditor.fragmentsNameRequired": "请填写片段名称",
|
||||||
|
"routeEditor.errPath": "字段过滤需要路径",
|
||||||
|
"routeEditor.errValues": "至少需要一个值",
|
||||||
|
"routeEditor.errGroupEmpty": "分组至少需要一个条件",
|
||||||
"groupEditor.editTitle": "编辑分组",
|
"groupEditor.editTitle": "编辑分组",
|
||||||
"groupEditor.newTitle": "新建分组",
|
"groupEditor.newTitle": "新建分组",
|
||||||
"groupEditor.eyebrow": "分组",
|
"groupEditor.eyebrow": "分组",
|
||||||
|
|
|
||||||
70
app/types.ts
70
app/types.ts
|
|
@ -1,7 +1,53 @@
|
||||||
|
export type FilterType =
|
||||||
|
| "event"
|
||||||
|
| "repo"
|
||||||
|
| "actor"
|
||||||
|
| "action"
|
||||||
|
| "branch"
|
||||||
|
| "keyword"
|
||||||
|
| "field";
|
||||||
|
|
||||||
|
export type FilterOp =
|
||||||
|
| "eq"
|
||||||
|
| "ne"
|
||||||
|
| "contains"
|
||||||
|
| "startsWith"
|
||||||
|
| "endsWith"
|
||||||
|
| "regex"
|
||||||
|
| "gt"
|
||||||
|
| "gte"
|
||||||
|
| "lt"
|
||||||
|
| "lte"
|
||||||
|
| "in"
|
||||||
|
| "exists";
|
||||||
|
|
||||||
export interface Filter {
|
export interface Filter {
|
||||||
type: "event" | "repo" | "actor" | "action" | "branch" | "keyword";
|
type: FilterType;
|
||||||
match: string | string[];
|
match?: string | string[];
|
||||||
exclude?: boolean;
|
exclude?: boolean;
|
||||||
|
path?: string;
|
||||||
|
op?: FilterOp;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FilterAll {
|
||||||
|
all: FilterNode[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FilterAny {
|
||||||
|
any: FilterNode[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FilterNot {
|
||||||
|
not: FilterNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type FilterNode = Filter | FilterAll | FilterAny | FilterNot;
|
||||||
|
|
||||||
|
export interface NamedFragment {
|
||||||
|
id: string;
|
||||||
|
groupId?: string;
|
||||||
|
name: string;
|
||||||
|
node: FilterNode;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface RouteTarget {
|
export interface RouteTarget {
|
||||||
|
|
@ -22,6 +68,7 @@ export interface Route {
|
||||||
fallback?: boolean;
|
fallback?: boolean;
|
||||||
stop?: boolean;
|
stop?: boolean;
|
||||||
discordRoleIds?: string[];
|
discordRoleIds?: string[];
|
||||||
|
ast?: FilterNode;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type GroupRole = "owner" | "admin" | "viewer";
|
export type GroupRole = "owner" | "admin" | "viewer";
|
||||||
|
|
@ -161,9 +208,24 @@ export const ROUTE_TEMPLATES: RouteTemplate[] = [
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
export const FILTER_TYPES = ["event", "repo", "actor", "action", "branch", "keyword"] as const;
|
export const FILTER_TYPES = ["event", "repo", "actor", "action", "branch", "keyword", "field"] as const;
|
||||||
|
|
||||||
export function fmtMatch(match: string | string[]): string {
|
export const FILTER_OPS: FilterOp[] = [
|
||||||
|
"eq",
|
||||||
|
"ne",
|
||||||
|
"contains",
|
||||||
|
"startsWith",
|
||||||
|
"endsWith",
|
||||||
|
"regex",
|
||||||
|
"gt",
|
||||||
|
"gte",
|
||||||
|
"lt",
|
||||||
|
"lte",
|
||||||
|
"in",
|
||||||
|
"exists",
|
||||||
|
];
|
||||||
|
|
||||||
|
export function fmtMatch(match: string | string[] | undefined): string {
|
||||||
if (Array.isArray(match)) return match.join(", ");
|
if (Array.isArray(match)) return match.join(", ");
|
||||||
return String(match ?? "");
|
return String(match ?? "");
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -116,3 +116,42 @@ routes:
|
||||||
# targets:
|
# targets:
|
||||||
# - platform: discord
|
# - platform: discord
|
||||||
# channelId: "CHANNEL_ID_HERE"
|
# channelId: "CHANNEL_ID_HERE"
|
||||||
|
|
||||||
|
# Field filters match any payload field via a JSONPath (dot-separated) `path`,
|
||||||
|
# with an optional `op` operator (eq/ne/contains/startsWith/endsWith/regex/
|
||||||
|
# gt/gte/lt/lte/in/exists). Arrays are expanded: any matching element wins.
|
||||||
|
# - id: big-pr
|
||||||
|
# name: "Large Pull Requests"
|
||||||
|
# enabled: true
|
||||||
|
# groupId: default
|
||||||
|
# filters:
|
||||||
|
# - type: event
|
||||||
|
# match: pull_request
|
||||||
|
# - type: field
|
||||||
|
# path: "pull_request.commits"
|
||||||
|
# op: gte
|
||||||
|
# match: "10"
|
||||||
|
# targets:
|
||||||
|
# - platform: discord
|
||||||
|
# channelId: "CHANNEL_ID_HERE"
|
||||||
|
|
||||||
|
# Nested `ast` groups (all / any / not) replace the flat filters list and take
|
||||||
|
# precedence over `filters` when both are present.
|
||||||
|
# - id: grouped-owners
|
||||||
|
# name: "PRs from either owner"
|
||||||
|
# enabled: true
|
||||||
|
# groupId: default
|
||||||
|
# ast:
|
||||||
|
# all:
|
||||||
|
# - type: event
|
||||||
|
# match: pull_request
|
||||||
|
# - any:
|
||||||
|
# - type: field
|
||||||
|
# path: "pull_request.user.login"
|
||||||
|
# match: alice
|
||||||
|
# - type: field
|
||||||
|
# path: "pull_request.user.login"
|
||||||
|
# match: bob
|
||||||
|
# targets:
|
||||||
|
# - platform: discord
|
||||||
|
# channelId: "CHANNEL_ID_HERE"
|
||||||
|
|
|
||||||
|
|
@ -19,6 +19,8 @@ The console itself is served at `/admin`; its tabs are deep-linkable via the URL
|
||||||
| `PUT /admin/api/groups` | Replace groups (super: all; owner: own only) |
|
| `PUT /admin/api/groups` | Replace groups (super: all; owner: own only) |
|
||||||
| `GET /admin/api/groups/:id/routes` | List a group's routes |
|
| `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/routes` | Replace a group's routes (owner/admin) |
|
||||||
|
| `GET /admin/api/groups/:id/fragments` | List a group's named filter fragments |
|
||||||
|
| `PUT /admin/api/groups/:id/fragments` | Replace a group's named filter fragments (owner/admin) |
|
||||||
| `PUT /admin/api/groups/:id/rename` | Rename a group (owner); routes, webhook secret and invites follow |
|
| `PUT /admin/api/groups/:id/rename` | Rename a group (owner); routes, webhook secret and invites follow |
|
||||||
| `GET /admin/api/groups/:id/invites` | List pending invites (owner) |
|
| `GET /admin/api/groups/:id/invites` | List pending invites (owner) |
|
||||||
| `POST /admin/api/groups/:id/invites` | Create an invite link (owner) |
|
| `POST /admin/api/groups/:id/invites` | Create an invite link (owner) |
|
||||||
|
|
@ -31,11 +33,14 @@ The console itself is served at `/admin`; its tabs are deep-linkable via the URL
|
||||||
| `GET /admin/api/audit` | Audit log (scoped to accessible groups) |
|
| `GET /admin/api/audit` | Audit log (scoped to accessible groups) |
|
||||||
| `GET /admin/api/metrics` | Delivery stats (totals, failure rate, per platform/event/status, recent failures); optional `?groupId=` scope; recent failures scoped to accessible groups for non-super |
|
| `GET /admin/api/metrics` | Delivery stats (totals, failure rate, per platform/event/status, recent failures); optional `?groupId=` scope; recent failures scoped to accessible groups for non-super |
|
||||||
| `GET /admin/api/delivery/:deliveryId` | All send-log attempts for one delivery (group-scoped) |
|
| `GET /admin/api/delivery/:deliveryId` | All send-log attempts for one delivery (group-scoped) |
|
||||||
|
| `POST /admin/api/test-match` | Stateless filter dry-run — evaluate a filter node against a pasted JSON payload (no event is stored) |
|
||||||
|
|
||||||
## Validation
|
## Validation
|
||||||
|
|
||||||
- `PUT /admin/api/routes` — Body `{ "routes": Route[] }`; validates each route (id pattern, unique id within its group, name, enabled, `groupId`, filters — empty only allowed for `fallback` routes — optional `discordRoleIds` (list of role id strings), and platform-aware targets: `target.channelId` for Discord, `target.chatId` for Telegram) and persists to D1 `d1_routes`. Returns `200 { ok, count }` or `400 { error }` / `401 { error }` / `403 { error }`. Unchanged routes skip the full validation.
|
- `PUT /admin/api/routes` — Body `{ "routes": Route[] }`; validates each route (id pattern, unique id within its group, name, enabled, `groupId`, filters — empty only allowed for `fallback` routes — optional `discordRoleIds` (list of role id strings), and platform-aware targets: `target.channelId` for Discord, `target.chatId` for Telegram) and persists to D1 `d1_routes`. Returns `200 { ok, count }` or `400 { error }` / `401 { error }` / `403 { error }`. Unchanged routes skip the full validation. Route filters support the `field` type (a JSONPath `path` into `payload`, arrays expand so any element matches) and 12 `op` operators (`eq` default / `ne` / `contains` / `startsWith` / `endsWith` / `regex` / `gt` / `gte` / `lt` / `lte` / `in` / `exists`); a nested `ast` (`all` / `any` / `not`) takes precedence over `filters` when present.
|
||||||
- `PUT /admin/api/groups` — Validates group ids, member roles (at least one `owner`), `providers` (`github` / `gitea`), and `installationId`.
|
- `PUT /admin/api/groups` — Validates group ids, member roles (at least one `owner`), `providers` (`github` / `gitea`), and `installationId`.
|
||||||
|
- `POST /admin/api/test-match` — Body `{ "node": FilterNode | "filters": Filter[], "event"?: string, "payload": object }`; evaluates in memory and returns `{ matched, explanation }`. Nothing is persisted.
|
||||||
|
- `GET/PUT /admin/api/groups/:id/fragments` — Named filter fragments (`{ id, groupId, name, node }`) stored in D1 `d1_fragments`; the editor inlines a fragment's `node` into a route's `ast` on insert (the matcher never resolves fragment references). `PUT` replaces the group's fragments and returns `200 { ok, count }`.
|
||||||
- Limits: at most 200 routes and 100 groups per instance.
|
- Limits: at most 200 routes and 100 groups per instance.
|
||||||
|
|
||||||
Schemas: [Routes & Targets](../guide/routes), [Groups & Access Control](../guide/groups).
|
Schemas: [Routes & Targets](../guide/routes), [Groups & Access Control](../guide/groups).
|
||||||
|
|
|
||||||
|
|
@ -73,6 +73,7 @@ See the [Filter Tutorial](./filters) for a hands-on guide with worked examples.
|
||||||
| `actor` | Sender login | `username`, `[bot]`, `*[bot]` |
|
| `actor` | Sender login | `username`, `[bot]`, `*[bot]` |
|
||||||
| `action` | Event action | `opened`, `closed`, `published` |
|
| `action` | Event action | `opened`, `closed`, `published` |
|
||||||
| `branch` | Branch name | `main`, `feature-?`, `/^release-/` |
|
| `branch` | Branch name | `main`, `feature-?`, `/^release-/` |
|
||||||
|
| `field` | Any payload field (JSONPath) | `path: "pull_request.user.login"` |
|
||||||
| `keyword` | Text in payload body | `deploy`, `/fix\s+\d+/` |
|
| `keyword` | Text in payload body | `deploy`, `/fix\s+\d+/` |
|
||||||
|
|
||||||
### Filter Behavior
|
### Filter Behavior
|
||||||
|
|
@ -80,7 +81,10 @@ See the [Filter Tutorial](./filters) for a hands-on guide with worked examples.
|
||||||
- All filters in a route must match for the route to trigger (AND logic)
|
- 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)
|
- 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
|
- 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
|
- Field filters (`event`/`repo`/`actor`/`action`/`branch`/`field`) glob-match the whole value; `keyword` globs and regexes search anywhere in the payload; plain `keyword` text is a substring search
|
||||||
|
- Every filter except `keyword` accepts an `op` operator — `eq` (default, classic behaviour), `ne`, `contains`, `startsWith`, `endsWith`, `regex`, `gt`, `gte`, `lt`, `lte`, `in`, `exists` — see the [Filter Tutorial](./filters#operators)
|
||||||
|
- `field` filters use a dot-separated JSONPath `path` into the payload; arrays are expanded so any matching element satisfies the filter
|
||||||
|
- Routes may nest filters in an `ast` node (`all` / `any` / `not`) instead of a flat `filters` list; `ast` takes precedence when present
|
||||||
- Patterns longer than 200 characters are not compiled as glob/regex; an invalid `//`-wrapped regex matches nothing
|
- 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
|
- `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
|
||||||
|
|
||||||
|
|
@ -91,4 +95,5 @@ Filters accept either a single string or an array of strings:
|
||||||
```json
|
```json
|
||||||
{ "type": "event", "match": "push" }
|
{ "type": "event", "match": "push" }
|
||||||
{ "type": "event", "match": ["push", "pull_request"] }
|
{ "type": "event", "match": ["push", "pull_request"] }
|
||||||
|
{ "type": "field", "path": "pull_request.commits", "op": "gt", "match": "1" }
|
||||||
```
|
```
|
||||||
|
|
|
||||||
|
|
@ -194,6 +194,70 @@ Just like the other filters, `exclude` inverts the keyword match:
|
||||||
|
|
||||||
Skips events whose payload mentions `wip` or `draft`.
|
Skips events whose payload mentions `wip` or `draft`.
|
||||||
|
|
||||||
|
### `field` — Any payload field (JSONPath)
|
||||||
|
|
||||||
|
Matches an arbitrary field of the webhook payload using a dot-separated path, e.g. `pull_request.user.login`, `repository.private`, or `check_run.conclusion`. Array fields are expanded automatically — the filter matches if **any** element matches.
|
||||||
|
|
||||||
|
```json
|
||||||
|
{ "type": "field", "path": "pull_request.user.login", "match": "dependabot[bot]" }
|
||||||
|
```
|
||||||
|
|
||||||
|
```json
|
||||||
|
{ "type": "field", "path": "labels.name", "match": "bug" }
|
||||||
|
```
|
||||||
|
|
||||||
|
### Operators
|
||||||
|
|
||||||
|
Field filters (and every filter type except `keyword`) accept an `op` to change how the value is compared. The default `eq` keeps the classic glob/regex/exact behaviour.
|
||||||
|
|
||||||
|
| Operator | Meaning |
|
||||||
|
| -------------- | -------------------------------------------------------------------- |
|
||||||
|
| `eq` (default) | Equal — globs, regexes and plain text, case-insensitive |
|
||||||
|
| `ne` | Not equal (inverse of `eq`) |
|
||||||
|
| `contains` | Value contains the pattern (substring) |
|
||||||
|
| `startsWith` | Value starts with the pattern |
|
||||||
|
| `endsWith` | Value ends with the pattern |
|
||||||
|
| `regex` | Explicit regular expression match |
|
||||||
|
| `gt` / `gte` | Numeric greater-than / greater-or-equal |
|
||||||
|
| `lt` / `lte` | Numeric less-than / less-or-equal |
|
||||||
|
| `in` | Value equals any of the listed patterns |
|
||||||
|
| `exists` | The field is present (non-null); `match` is ignored |
|
||||||
|
|
||||||
|
```json
|
||||||
|
{ "type": "field", "path": "pull_request.commits", "op": "gt", "match": "1" }
|
||||||
|
```
|
||||||
|
|
||||||
|
```json
|
||||||
|
{ "type": "field", "path": "label.name", "op": "startsWith", "match": "area/" }
|
||||||
|
```
|
||||||
|
|
||||||
|
### Grouping (all / any / not)
|
||||||
|
|
||||||
|
A route can use a nested `ast` to combine filters with explicit grouping instead of a flat AND list. The `ast` node is one of `{ "all": [...] }`, `{ "any": [...] }`, or `{ "not": {...} }`:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"id": "grouped",
|
||||||
|
"name": "Grouped",
|
||||||
|
"ast": {
|
||||||
|
"all": [
|
||||||
|
{ "type": "event", "match": "pull_request" },
|
||||||
|
{ "any": [
|
||||||
|
{ "type": "field", "path": "pull_request.user.login", "match": "alice" },
|
||||||
|
{ "type": "field", "path": "pull_request.user.login", "match": "bob" }
|
||||||
|
]}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"targets": [{ "channelId": "..." }]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
When `ast` is present it takes precedence over `filters`. The admin console's route editor builds `ast` visually (all/any/not groups), shows a live explanation of the tree, and can test it against a pasted JSON payload via the **Test match** panel.
|
||||||
|
|
||||||
|
### Named filter fragments
|
||||||
|
|
||||||
|
The route editor can save the current filter tree as a **named fragment** and insert it into other routes. Fragments are editor-side templates stored in D1 (`d1_fragments`); inserting a fragment inlines its node into the route's `ast`, so the matching engine itself never resolves fragment references.
|
||||||
|
|
||||||
## Worked Example 1: PR alerts that skip bots and drafts
|
## 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:
|
Forward pull request activity, but ignore bot authors and draft PRs, to a `#prs` channel:
|
||||||
|
|
|
||||||
|
|
@ -37,6 +37,7 @@ Each entry of `targets` is a push destination, so one route can forward to sever
|
||||||
| `fallback` | boolean | No | When `true`, fires only if no non-fallback route matched the event; its own filters are ignored |
|
| `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 |
|
| `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 |
|
| `discordRoleIds` | string[] | No | Discord role ids to ping when this route fires; applied to Discord targets only |
|
||||||
|
| `ast` | object | No | Boolean filter tree (`{all:[...]}` / `{any:[...]}` / `{not:{...}}`); takes precedence over `filters` when present |
|
||||||
|
|
||||||
## Discord Role Mentions
|
## Discord Role Mentions
|
||||||
|
|
||||||
|
|
@ -58,7 +59,7 @@ You can add role ids in the admin console under _Discord role mentions_.
|
||||||
|
|
||||||
## Filters
|
## Filters
|
||||||
|
|
||||||
Every route carries a `filters` array (all must match — AND logic). See the [Filter Types](./configuration#filter-types) reference and the [Filter Tutorial](./filters).
|
Every route carries a `filters` array (all must match — AND logic). When the route has an `ast` field (a nested `all`/`any`/`not` tree), it is evaluated instead of `filters`, so it can express arbitrary boolean combinations. See the [Filter Types](./configuration#filter-types) reference and the [Filter Tutorial](./filters).
|
||||||
|
|
||||||
## Custom Route Example
|
## Custom Route Example
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -19,6 +19,8 @@
|
||||||
| `PUT /admin/api/groups` | 替换分组(超级管理员全部;owner 仅自己的) |
|
| `PUT /admin/api/groups` | 替换分组(超级管理员全部;owner 仅自己的) |
|
||||||
| `GET /admin/api/groups/:id/routes` | 列出某分组的路由 |
|
| `GET /admin/api/groups/:id/routes` | 列出某分组的路由 |
|
||||||
| `PUT /admin/api/groups/:id/routes` | 替换某分组的路由(owner/admin) |
|
| `PUT /admin/api/groups/:id/routes` | 替换某分组的路由(owner/admin) |
|
||||||
|
| `GET /admin/api/groups/:id/fragments` | 列出某分组的命名过滤器片段 |
|
||||||
|
| `PUT /admin/api/groups/:id/fragments` | 替换某分组的命名过滤器片段(owner/admin) |
|
||||||
| `PUT /admin/api/groups/:id/rename` | 重命名分组(owner);路由、webhook secret 与邀请自动跟随 |
|
| `PUT /admin/api/groups/:id/rename` | 重命名分组(owner);路由、webhook secret 与邀请自动跟随 |
|
||||||
| `GET /admin/api/groups/:id/invites` | 列出待处理的邀请(owner) |
|
| `GET /admin/api/groups/:id/invites` | 列出待处理的邀请(owner) |
|
||||||
| `POST /admin/api/groups/:id/invites` | 创建邀请链接(owner) |
|
| `POST /admin/api/groups/:id/invites` | 创建邀请链接(owner) |
|
||||||
|
|
@ -31,11 +33,14 @@
|
||||||
| `GET /admin/api/audit` | 审计日志(按可访问的分组过滤) |
|
| `GET /admin/api/audit` | 审计日志(按可访问的分组过滤) |
|
||||||
| `GET /admin/api/metrics` | 投递统计(总计、失败率、按平台/事件/状态、最近失败);可选 `?groupId=` 按分组过滤;非超管按可访问分组过滤最近失败 |
|
| `GET /admin/api/metrics` | 投递统计(总计、失败率、按平台/事件/状态、最近失败);可选 `?groupId=` 按分组过滤;非超管按可访问分组过滤最近失败 |
|
||||||
| `GET /admin/api/delivery/:deliveryId` | 单次投递的全部发送日志(按分组过滤) |
|
| `GET /admin/api/delivery/:deliveryId` | 单次投递的全部发送日志(按分组过滤) |
|
||||||
|
| `POST /admin/api/test-match` | 无状态过滤器试匹配——将过滤器节点对粘贴的 JSON 载荷求值(不存储任何事件) |
|
||||||
|
|
||||||
## 校验
|
## 校验
|
||||||
|
|
||||||
- `PUT /admin/api/routes` — 请求体为 `{ "routes": Route[] }`;校验每条路由(id 格式、组内唯一 id、name、enabled、groupId、过滤器——**仅 `fallback` 路由允许空过滤器**——可选的 `discordRoleIds`(身份组 id 字符串列表)、平台感知的 targets:Discord 需 `target.channelId`,Telegram 需 `target.chatId`)并持久化到 D1 `d1_routes`。返回 `200 { ok, count }` 或 `400 { error }` / `401 { error }` / `403 { error }`。未变更的路由跳过完整校验。
|
- `PUT /admin/api/routes` — 请求体为 `{ "routes": Route[] }`;校验每条路由(id 格式、组内唯一 id、name、enabled、groupId、过滤器——**仅 `fallback` 路由允许空过滤器**——可选的 `discordRoleIds`(身份组 id 字符串列表)、平台感知的 targets:Discord 需 `target.channelId`,Telegram 需 `target.chatId`)并持久化到 D1 `d1_routes`。返回 `200 { ok, count }` 或 `400 { error }` / `401 { error }` / `403 { error }`。未变更的路由跳过完整校验。路由过滤器支持 `field` 类型(指向 `payload` 的 JSONPath `path`,数组展开后任一元素匹配)与 12 个 `op` 操作符(`eq` 默认 / `ne` / `contains` / `startsWith` / `endsWith` / `regex` / `gt` / `gte` / `lt` / `lte` / `in` / `exists`);嵌套 `ast`(`all` / `any` / `not`)存在时优先于 `filters`。
|
||||||
- `PUT /admin/api/groups` — 校验分组 id、成员角色(至少一个 `owner`)、`providers`(`github` / `gitea`)与 `installationId`。
|
- `PUT /admin/api/groups` — 校验分组 id、成员角色(至少一个 `owner`)、`providers`(`github` / `gitea`)与 `installationId`。
|
||||||
|
- `POST /admin/api/test-match` — 请求体 `{ "node": FilterNode | "filters": Filter[], "event"?: string, "payload": object }`;在内存中求值并返回 `{ matched, explanation }`,不持久化任何内容。
|
||||||
|
- `GET/PUT /admin/api/groups/:id/fragments` — 命名过滤器片段(`{ id, groupId, name, node }`)存储于 D1 `d1_fragments`;编辑器在插入时将片段的 `node` 内联进路由的 `ast`(匹配器从不解析片段引用)。`PUT` 全量替换该分组的片段并返回 `200 { ok, count }`。
|
||||||
- 上限:每个实例最多 200 条路由与 100 个分组。
|
- 上限:每个实例最多 200 条路由与 100 个分组。
|
||||||
|
|
||||||
模式:见[路由与目标](../guide/routes)、[分组与访问控制](../guide/groups)。
|
模式:见[路由与目标](../guide/routes)、[分组与访问控制](../guide/groups)。
|
||||||
|
|
|
||||||
|
|
@ -72,6 +72,7 @@ WebHooker 在 `/admin` 提供内置配置控制台,可在浏览器中管理路
|
||||||
| `actor` | 发送者登录名 | `username`, `[bot]`, `*[bot]` |
|
| `actor` | 发送者登录名 | `username`, `[bot]`, `*[bot]` |
|
||||||
| `action` | 事件操作 | `opened`, `closed`, `published` |
|
| `action` | 事件操作 | `opened`, `closed`, `published` |
|
||||||
| `branch` | 分支名称 | `main`, `feature-?`, `/^release-/` |
|
| `branch` | 分支名称 | `main`, `feature-?`, `/^release-/` |
|
||||||
|
| `field` | 任意载荷字段(JSONPath) | `path: "pull_request.user.login"` |
|
||||||
| `keyword` | 载荷正文中的文本 | `deploy`, `/fix\s+\d+/` |
|
| `keyword` | 载荷正文中的文本 | `deploy`, `/fix\s+\d+/` |
|
||||||
|
|
||||||
### 过滤器行为
|
### 过滤器行为
|
||||||
|
|
@ -79,7 +80,10 @@ WebHooker 在 `/admin` 提供内置配置控制台,可在浏览器中管理路
|
||||||
- 路由中的所有过滤器必须都匹配才触发路由(AND 逻辑)
|
- 路由中的所有过滤器必须都匹配才触发路由(AND 逻辑)
|
||||||
- 在任何过滤器上设置 `"exclude": true` 可反转匹配逻辑(NOT 逻辑)
|
- 在任何过滤器上设置 `"exclude": true` 可反转匹配逻辑(NOT 逻辑)
|
||||||
- 所有过滤器类型支持相同的模式形式:纯文本、`*`/`?` **通配符**(`*` 任意长度、`?` 单字符)以及 `/正则表达式/`——均不区分大小写
|
- 所有过滤器类型支持相同的模式形式:纯文本、`*`/`?` **通配符**(`*` 任意长度、`?` 单字符)以及 `/正则表达式/`——均不区分大小写
|
||||||
- 字段过滤器(`event`/`repo`/`actor`/`action`/`branch`)的通配符匹配整个值;`keyword` 的通配符和正则搜索载荷任意位置;`keyword` 的纯文本为子串搜索
|
- 字段过滤器(`event`/`repo`/`actor`/`action`/`branch`/`field`)的通配符匹配整个值;`keyword` 的通配符和正则搜索载荷任意位置;`keyword` 的纯文本为子串搜索
|
||||||
|
- 除 `keyword` 外的每个过滤器都可加 `op` 操作符——`eq`(默认,经典行为)、`ne`、`contains`、`startsWith`、`endsWith`、`regex`、`gt`、`gte`、`lt`、`lte`、`in`、`exists`——见[过滤器教程](./filters#操作符)
|
||||||
|
- `field` 过滤器使用点号分隔的 JSONPath `path` 定位载荷字段;数组会自动展开,任一元素匹配即满足过滤器
|
||||||
|
- 路由可将过滤器嵌套在 `ast` 节点(`all` / `any` / `not`)中,取代扁平的 `filters` 列表;存在 `ast` 时它优先
|
||||||
- 超过 200 个字符的模式不编译为通配符/正则;`//` 包裹的非法正则匹配不到任何内容
|
- 超过 200 个字符的模式不编译为通配符/正则;`//` 包裹的非法正则匹配不到任何内容
|
||||||
- `branch` 过滤器适用于 push、pull_request、pull_request_review、pull_request_review_comment、create/delete、workflow_run、workflow_job、check_suite、deployment 和 code_scanning_alert 事件
|
- `branch` 过滤器适用于 push、pull_request、pull_request_review、pull_request_review_comment、create/delete、workflow_run、workflow_job、check_suite、deployment 和 code_scanning_alert 事件
|
||||||
|
|
||||||
|
|
@ -90,4 +94,5 @@ WebHooker 在 `/admin` 提供内置配置控制台,可在浏览器中管理路
|
||||||
```json
|
```json
|
||||||
{ "type": "event", "match": "push" }
|
{ "type": "event", "match": "push" }
|
||||||
{ "type": "event", "match": ["push", "pull_request"] }
|
{ "type": "event", "match": ["push", "pull_request"] }
|
||||||
|
{ "type": "field", "path": "pull_request.commits", "op": "gt", "match": "1" }
|
||||||
```
|
```
|
||||||
|
|
|
||||||
|
|
@ -194,6 +194,70 @@
|
||||||
|
|
||||||
跳过载荷中提及 `wip` 或 `draft` 的事件。
|
跳过载荷中提及 `wip` 或 `draft` 的事件。
|
||||||
|
|
||||||
|
### `field` — 任意载荷字段(JSONPath)
|
||||||
|
|
||||||
|
使用点号分隔的路径匹配载荷的任意字段,例如 `pull_request.user.login`、`repository.private` 或 `check_run.conclusion`。数组字段会自动展开——只要**任意一个**元素匹配,过滤器即匹配。
|
||||||
|
|
||||||
|
```json
|
||||||
|
{ "type": "field", "path": "pull_request.user.login", "match": "dependabot[bot]" }
|
||||||
|
```
|
||||||
|
|
||||||
|
```json
|
||||||
|
{ "type": "field", "path": "labels.name", "match": "bug" }
|
||||||
|
```
|
||||||
|
|
||||||
|
### 操作符
|
||||||
|
|
||||||
|
字段过滤器(以及除 `keyword` 之外的所有过滤器类型)可通过 `op` 改变值的比较方式。默认的 `eq` 保持经典的 glob/正则/精确匹配行为。
|
||||||
|
|
||||||
|
| 操作符 | 含义 |
|
||||||
|
| -------------- | ------------------------------------------ |
|
||||||
|
| `eq`(默认) | 相等——通配符、正则与纯文本,不区分大小写 |
|
||||||
|
| `ne` | 不相等(`eq` 的反义) |
|
||||||
|
| `contains` | 值包含模式(子串) |
|
||||||
|
| `startsWith` | 值以模式开头 |
|
||||||
|
| `endsWith` | 值以模式结尾 |
|
||||||
|
| `regex` | 显式正则表达式匹配 |
|
||||||
|
| `gt` / `gte` | 数值大于 / 大于等于 |
|
||||||
|
| `lt` / `lte` | 数值小于 / 小于等于 |
|
||||||
|
| `in` | 值等于任一列出的模式 |
|
||||||
|
| `exists` | 字段存在(非 null);忽略 `match` |
|
||||||
|
|
||||||
|
```json
|
||||||
|
{ "type": "field", "path": "pull_request.commits", "op": "gt", "match": "1" }
|
||||||
|
```
|
||||||
|
|
||||||
|
```json
|
||||||
|
{ "type": "field", "path": "label.name", "op": "startsWith", "match": "area/" }
|
||||||
|
```
|
||||||
|
|
||||||
|
### 分组(all / any / not)
|
||||||
|
|
||||||
|
路由可以使用嵌套的 `ast` 以显式分组组合过滤器,而不是扁平的 AND 列表。`ast` 节点是 `{ "all": [...] }`、`{ "any": [...] }` 或 `{ "not": {...} }` 之一:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"id": "grouped",
|
||||||
|
"name": "Grouped",
|
||||||
|
"ast": {
|
||||||
|
"all": [
|
||||||
|
{ "type": "event", "match": "pull_request" },
|
||||||
|
{ "any": [
|
||||||
|
{ "type": "field", "path": "pull_request.user.login", "match": "alice" },
|
||||||
|
{ "type": "field", "path": "pull_request.user.login", "match": "bob" }
|
||||||
|
]}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"targets": [{ "channelId": "..." }]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
当 `ast` 存在时,它优先于 `filters`。管理后台的路由编辑器会以可视化方式构建 `ast`(all/any/not 分组)、实时展示树状解释,并可通过「测试匹配」面板粘贴 JSON 载荷进行试匹配。
|
||||||
|
|
||||||
|
### 命名过滤器片段
|
||||||
|
|
||||||
|
路由编辑器可将当前过滤器树保存为**命名片段**并插入其他路由。片段是编辑器侧的模板,存储在 D1(`d1_fragments`)中;插入片段会将其节点内联进路由的 `ast`,因此匹配引擎本身从不解析片段引用。
|
||||||
|
|
||||||
## 示例 1:PR 通知,跳过机器人和草稿
|
## 示例 1:PR 通知,跳过机器人和草稿
|
||||||
|
|
||||||
转发拉取请求动态,但忽略机器人作者和草稿 PR,发往 `#prs` 频道:
|
转发拉取请求动态,但忽略机器人作者和草稿 PR,发往 `#prs` 频道:
|
||||||
|
|
|
||||||
|
|
@ -37,6 +37,7 @@
|
||||||
| `fallback` | boolean | 否 | 为 `true` 时仅在没有其他非 fallback 路由匹配时才触发;其自身过滤器被忽略 |
|
| `fallback` | boolean | 否 | 为 `true` 时仅在没有其他非 fallback 路由匹配时才触发;其自身过滤器被忽略 |
|
||||||
| `stop` | boolean | 否 | 为 `true` 且该路由匹配时,不再评估后续路由 |
|
| `stop` | boolean | 否 | 为 `true` 且该路由匹配时,不再评估后续路由 |
|
||||||
| `discordRoleIds` | string[] | 否 | 路由触发时要提醒的 Discord 身份组 id;仅对 Discord 目标生效 |
|
| `discordRoleIds` | string[] | 否 | 路由触发时要提醒的 Discord 身份组 id;仅对 Discord 目标生效 |
|
||||||
|
| `ast` | object | 否 | 布尔过滤器树(`{all:[...]}` / `{any:[...]}` / `{not:{...}}`);存在时优先于 `filters` |
|
||||||
|
|
||||||
## Discord 身份组提醒
|
## Discord 身份组提醒
|
||||||
|
|
||||||
|
|
@ -58,7 +59,7 @@
|
||||||
|
|
||||||
## 过滤器
|
## 过滤器
|
||||||
|
|
||||||
每条路由携带 `filters` 数组(全部匹配才触发——AND 逻辑)。见[过滤器类型](./configuration#过滤器类型)参考与[过滤器教程](./filters)。
|
每条路由携带 `filters` 数组(全部匹配才触发——AND 逻辑)。当路由带 `ast` 字段(嵌套的 `all`/`any`/`not` 树)时,用它替代 `filters` 求值,从而表达任意的布尔组合。见[过滤器类型](./configuration#过滤器类型)参考与[过滤器教程](./filters)。
|
||||||
|
|
||||||
## 自定义路由示例
|
## 自定义路由示例
|
||||||
|
|
||||||
|
|
|
||||||
12
migrations/0010_fragments.sql
Normal file
12
migrations/0010_fragments.sql
Normal file
|
|
@ -0,0 +1,12 @@
|
||||||
|
CREATE TABLE IF NOT EXISTS d1_fragments (
|
||||||
|
id TEXT NOT NULL,
|
||||||
|
group_id TEXT NOT NULL DEFAULT '',
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
node TEXT NOT NULL,
|
||||||
|
version INTEGER NOT NULL DEFAULT 1,
|
||||||
|
created_at INTEGER NOT NULL,
|
||||||
|
updated_at INTEGER NOT NULL,
|
||||||
|
PRIMARY KEY (id, group_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_d1_fragments_group_id ON d1_fragments(group_id);
|
||||||
|
|
@ -1,14 +1,31 @@
|
||||||
import * as v from "valibot";
|
import * as v from "valibot";
|
||||||
import type { Group, Route } from "../types";
|
import type { FilterNode, Group, Route } from "../types";
|
||||||
import { log } from "../lib/log";
|
import { log } from "../lib/log";
|
||||||
import { explainFilterNode } from "../events/filter-ast";
|
import { explainFilterNode } from "../events/filter-ast";
|
||||||
|
|
||||||
export const CONFIG_SCHEMA_VERSION = 1;
|
export const CONFIG_SCHEMA_VERSION = 1;
|
||||||
|
|
||||||
export const filterSchema = v.object({
|
export const filterSchema = v.object({
|
||||||
type: v.picklist(["event", "repo", "actor", "action", "branch", "keyword"]),
|
type: v.picklist(["event", "repo", "actor", "action", "branch", "keyword", "field"]),
|
||||||
match: v.union([v.string(), v.array(v.string())]),
|
match: v.optional(v.union([v.string(), v.array(v.string())])),
|
||||||
exclude: v.optional(v.boolean()),
|
exclude: v.optional(v.boolean()),
|
||||||
|
path: v.optional(v.string()),
|
||||||
|
op: v.optional(
|
||||||
|
v.picklist([
|
||||||
|
"eq",
|
||||||
|
"ne",
|
||||||
|
"contains",
|
||||||
|
"startsWith",
|
||||||
|
"endsWith",
|
||||||
|
"regex",
|
||||||
|
"gt",
|
||||||
|
"gte",
|
||||||
|
"lt",
|
||||||
|
"lte",
|
||||||
|
"in",
|
||||||
|
"exists",
|
||||||
|
]),
|
||||||
|
),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const routeTargetSchema = v.object({
|
export const routeTargetSchema = v.object({
|
||||||
|
|
@ -19,7 +36,7 @@ export const routeTargetSchema = v.object({
|
||||||
topicId: v.optional(v.string()),
|
topicId: v.optional(v.string()),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const filterNodeSchema = v.lazy(() =>
|
export const filterNodeSchema: v.GenericSchema<FilterNode> = v.lazy(() =>
|
||||||
v.union([
|
v.union([
|
||||||
filterSchema,
|
filterSchema,
|
||||||
v.object({ all: v.array(filterNodeSchema) }),
|
v.object({ all: v.array(filterNodeSchema) }),
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import type { WebhookEvent, Filter, FilterNode } from "../types";
|
import type { WebhookEvent, Filter, FilterNode, FilterOp } from "../types";
|
||||||
|
|
||||||
const regexCache = new Map<string, RegExp>();
|
const regexCache = new Map<string, RegExp>();
|
||||||
const keywordBodyCache = new WeakMap<WebhookEvent, string>();
|
const keywordBodyCache = new WeakMap<WebhookEvent, string>();
|
||||||
|
|
@ -102,39 +102,124 @@ function extractBranch(event: WebhookEvent): string | undefined {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function toPatterns(filter: Filter): string[] {
|
||||||
|
if (filter.match === undefined || filter.match === null) return [];
|
||||||
|
return Array.isArray(filter.match) ? filter.match : [filter.match];
|
||||||
|
}
|
||||||
|
|
||||||
|
function stringify(value: unknown): string {
|
||||||
|
if (typeof value === "string") return value;
|
||||||
|
if (value === undefined || value === null) return "";
|
||||||
|
if (typeof value === "number" || typeof value === "boolean") return String(value);
|
||||||
|
return JSON.stringify(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function toNumber(value: unknown): number | null {
|
||||||
|
if (typeof value === "number") return value;
|
||||||
|
if (typeof value === "boolean") return value ? 1 : 0;
|
||||||
|
if (typeof value === "string") {
|
||||||
|
const n = Number(value.trim());
|
||||||
|
return Number.isFinite(n) ? n : null;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolvePath(value: unknown, path: string): unknown[] {
|
||||||
|
const parts = path.split(".").filter(Boolean);
|
||||||
|
if (parts.length === 0) return [value];
|
||||||
|
const out: unknown[] = [];
|
||||||
|
walkPath(value, parts, 0, out);
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
function walkPath(node: unknown, parts: string[], index: number, out: unknown[]): void {
|
||||||
|
if (index >= parts.length) {
|
||||||
|
out.push(node);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (Array.isArray(node)) {
|
||||||
|
for (const item of node) walkPath(item, parts, index, out);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (node === null || node === undefined || typeof node !== "object") return;
|
||||||
|
const next = (node as Record<string, unknown>)[parts[index]!];
|
||||||
|
walkPath(next, parts, index + 1, out);
|
||||||
|
}
|
||||||
|
|
||||||
|
function valueList(filter: Filter, event: WebhookEvent): unknown[] {
|
||||||
|
const p = event.payload;
|
||||||
|
switch (filter.type) {
|
||||||
|
case "event":
|
||||||
|
return [event.event];
|
||||||
|
case "repo":
|
||||||
|
return [(p.repository as { full_name?: string } | undefined)?.full_name];
|
||||||
|
case "actor":
|
||||||
|
return [(p.sender as { login?: string } | undefined)?.login];
|
||||||
|
case "action":
|
||||||
|
return [typeof p.action === "string" ? p.action : undefined];
|
||||||
|
case "branch":
|
||||||
|
return [extractBranch(event)];
|
||||||
|
case "field":
|
||||||
|
return resolvePath(p, filter.path ?? "");
|
||||||
|
default:
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function valueMatches(value: unknown, op: FilterOp, patterns: string[]): boolean {
|
||||||
|
const text = stringify(value);
|
||||||
|
switch (op) {
|
||||||
|
case "exists":
|
||||||
|
return value !== undefined && value !== null;
|
||||||
|
case "ne":
|
||||||
|
return !patterns.some((p) => matchField(p, text));
|
||||||
|
case "contains":
|
||||||
|
return patterns.some((p) => text.toLowerCase().includes(p.toLowerCase()));
|
||||||
|
case "startsWith":
|
||||||
|
return patterns.some((p) => text.toLowerCase().startsWith(p.toLowerCase()));
|
||||||
|
case "endsWith":
|
||||||
|
return patterns.some((p) => text.toLowerCase().endsWith(p.toLowerCase()));
|
||||||
|
case "regex":
|
||||||
|
return patterns.some((p) => {
|
||||||
|
const re = compileRegex(p);
|
||||||
|
return re ? re.test(text) : false;
|
||||||
|
});
|
||||||
|
case "gt":
|
||||||
|
case "gte":
|
||||||
|
case "lt":
|
||||||
|
case "lte": {
|
||||||
|
const n = toNumber(value);
|
||||||
|
if (n === null) return false;
|
||||||
|
return patterns.some((p) => {
|
||||||
|
const pn = toNumber(p);
|
||||||
|
if (pn === null) return false;
|
||||||
|
if (op === "gt") return n > pn;
|
||||||
|
if (op === "gte") return n >= pn;
|
||||||
|
if (op === "lt") return n < pn;
|
||||||
|
return n <= pn;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
case "eq":
|
||||||
|
case "in":
|
||||||
|
default:
|
||||||
|
return patterns.some((p) => matchField(p, text));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function matchFilter(filter: Filter, event: WebhookEvent, keywordBody?: string): boolean {
|
function matchFilter(filter: Filter, event: WebhookEvent, keywordBody?: string): boolean {
|
||||||
if (filter.type === "keyword") {
|
if (filter.type === "keyword") {
|
||||||
const body = keywordBody ?? getKeywordBody(event);
|
const body = keywordBody ?? getKeywordBody(event);
|
||||||
const patterns = Array.isArray(filter.match) ? filter.match : [filter.match];
|
const patterns = toPatterns(filter);
|
||||||
const matches = patterns.some((p) => matchKeyword(p, body));
|
const matches = patterns.some((p) => matchKeyword(p, body));
|
||||||
return filter.exclude ? !matches : matches;
|
return filter.exclude ? !matches : matches;
|
||||||
}
|
}
|
||||||
|
|
||||||
const value = valueFor(filter, event);
|
const op = filter.op ?? "eq";
|
||||||
if (!value) return false;
|
const patterns = toPatterns(filter);
|
||||||
const patterns = Array.isArray(filter.match) ? filter.match : [filter.match];
|
const matches = valueList(filter, event).some((v) => valueMatches(v, op, patterns));
|
||||||
const matches = patterns.some((p) => matchField(p, value));
|
|
||||||
return filter.exclude ? !matches : matches;
|
return filter.exclude ? !matches : matches;
|
||||||
}
|
}
|
||||||
|
|
||||||
function valueFor(filter: Filter, event: WebhookEvent): string | undefined {
|
|
||||||
const p = event.payload;
|
|
||||||
switch (filter.type) {
|
|
||||||
case "event":
|
|
||||||
return event.event;
|
|
||||||
case "repo":
|
|
||||||
return (p.repository as { full_name?: string } | undefined)?.full_name;
|
|
||||||
case "actor":
|
|
||||||
return (p.sender as { login?: string } | undefined)?.login;
|
|
||||||
case "action":
|
|
||||||
return typeof p.action === "string" ? p.action : undefined;
|
|
||||||
case "branch":
|
|
||||||
return extractBranch(event);
|
|
||||||
default:
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function containsKeyword(node: FilterNode): boolean {
|
export function containsKeyword(node: FilterNode): boolean {
|
||||||
if ("all" in node) return node.all.some(containsKeyword);
|
if ("all" in node) return node.all.some(containsKeyword);
|
||||||
if ("any" in node) return node.any.some(containsKeyword);
|
if ("any" in node) return node.any.some(containsKeyword);
|
||||||
|
|
@ -154,10 +239,20 @@ export function evaluateFilterNode(
|
||||||
}
|
}
|
||||||
|
|
||||||
export function explainFilter(filter: Filter): string {
|
export function explainFilter(filter: Filter): string {
|
||||||
const value = Array.isArray(filter.match)
|
const patterns = toPatterns(filter);
|
||||||
? filter.match.map((m) => JSON.stringify(m)).join(" or ")
|
const value = patterns.map((m) => JSON.stringify(m)).join(" or ");
|
||||||
: JSON.stringify(filter.match);
|
const label = filter.type === "field" ? filter.path ?? "field" : filter.type;
|
||||||
const base = `${filter.type} ${filter.type === "keyword" ? "matches" : "is"} ${value}`;
|
const op = filter.op ?? "eq";
|
||||||
|
let base: string;
|
||||||
|
if (filter.type === "keyword") {
|
||||||
|
base = `${label} matches ${value}`;
|
||||||
|
} else if (op === "exists") {
|
||||||
|
base = `${label} exists`;
|
||||||
|
} else if (op === "eq") {
|
||||||
|
base = `${label} is ${value}`;
|
||||||
|
} else {
|
||||||
|
base = `${label} ${op} ${value}`;
|
||||||
|
}
|
||||||
return filter.exclude ? `not (${base})` : base;
|
return filter.exclude ? `not (${base})` : base;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
55
server/lib/fragments.ts
Normal file
55
server/lib/fragments.ts
Normal file
|
|
@ -0,0 +1,55 @@
|
||||||
|
import type { FilterNode } from "./types";
|
||||||
|
import { log } from "./lib/log";
|
||||||
|
|
||||||
|
export interface NamedFragment {
|
||||||
|
id: string;
|
||||||
|
groupId?: string;
|
||||||
|
name: string;
|
||||||
|
node: FilterNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface D1FragmentRow {
|
||||||
|
id: string;
|
||||||
|
group_id: string;
|
||||||
|
name: string;
|
||||||
|
node: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function loadFragments(db: D1Database): Promise<NamedFragment[]> {
|
||||||
|
try {
|
||||||
|
const stmt = db.prepare(
|
||||||
|
"SELECT id, group_id, name, node FROM d1_fragments ORDER BY id",
|
||||||
|
);
|
||||||
|
if (typeof stmt.all !== "function") return [];
|
||||||
|
const { results } = await stmt.all<D1FragmentRow>();
|
||||||
|
if (!results || results.length === 0) return [];
|
||||||
|
return results.map((r) => ({
|
||||||
|
id: r.id,
|
||||||
|
groupId: r.group_id || undefined,
|
||||||
|
name: r.name,
|
||||||
|
node: JSON.parse(r.node) as FilterNode,
|
||||||
|
}));
|
||||||
|
} catch (err) {
|
||||||
|
log.warn({ err }, "Failed to load fragments from D1");
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function saveFragments(
|
||||||
|
db: D1Database,
|
||||||
|
fragments: NamedFragment[],
|
||||||
|
): Promise<void> {
|
||||||
|
const now = Date.now();
|
||||||
|
const statements: D1PreparedStatement[] = [
|
||||||
|
db.prepare("DELETE FROM d1_fragments"),
|
||||||
|
...fragments.map((f) =>
|
||||||
|
db
|
||||||
|
.prepare(
|
||||||
|
`INSERT INTO d1_fragments (id, group_id, name, node, version, created_at, updated_at)
|
||||||
|
VALUES (?, ?, ?, ?, 1, ?, ?)`,
|
||||||
|
)
|
||||||
|
.bind(f.id, f.groupId ?? "", f.name, JSON.stringify(f.node), now, now),
|
||||||
|
),
|
||||||
|
];
|
||||||
|
await db.batch(statements);
|
||||||
|
}
|
||||||
|
|
@ -151,10 +151,43 @@ export interface Group {
|
||||||
logTarget?: RouteTarget;
|
logTarget?: RouteTarget;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type FilterType =
|
||||||
|
| "event"
|
||||||
|
| "repo"
|
||||||
|
| "actor"
|
||||||
|
| "action"
|
||||||
|
| "branch"
|
||||||
|
| "keyword"
|
||||||
|
| "field";
|
||||||
|
|
||||||
|
export type FilterOp =
|
||||||
|
| "eq"
|
||||||
|
| "ne"
|
||||||
|
| "contains"
|
||||||
|
| "startsWith"
|
||||||
|
| "endsWith"
|
||||||
|
| "regex"
|
||||||
|
| "gt"
|
||||||
|
| "gte"
|
||||||
|
| "lt"
|
||||||
|
| "lte"
|
||||||
|
| "in"
|
||||||
|
| "exists";
|
||||||
|
|
||||||
export interface Filter {
|
export interface Filter {
|
||||||
type: "event" | "repo" | "actor" | "action" | "branch" | "keyword";
|
type: FilterType;
|
||||||
match: string | string[];
|
match?: string | string[];
|
||||||
exclude?: boolean;
|
exclude?: boolean;
|
||||||
|
/**
|
||||||
|
* JSONPath (dot notation, arrays expanded so any element matches) into
|
||||||
|
* `payload` used by `type: "field"` filters. E.g. `pull_request.user.login`.
|
||||||
|
*/
|
||||||
|
path?: string;
|
||||||
|
/**
|
||||||
|
* Comparison operator. Defaults to `eq`, which keeps the legacy glob/regex/
|
||||||
|
* case-insensitive-exact semantics. `exists` ignores `match`.
|
||||||
|
*/
|
||||||
|
op?: FilterOp;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
|
|
@ -7,8 +7,10 @@ import {
|
||||||
setResponseHeader,
|
setResponseHeader,
|
||||||
setResponseStatus,
|
setResponseStatus,
|
||||||
} from "h3";
|
} from "h3";
|
||||||
import type { Route, Group, GroupMember, GroupRole, ForgeSource } from "../types";
|
import type { Route, Group, GroupMember, GroupRole, ForgeSource, FilterNode } from "../types";
|
||||||
import { loadRoutes, saveRoutes } from "../config";
|
import { loadRoutes, saveRoutes } from "../config";
|
||||||
|
import { loadFragments, saveFragments, type NamedFragment } from "../fragments";
|
||||||
|
import { evaluateFilterNode, explainFilterNode } from "../events/filter-ast";
|
||||||
import { getAdminSession, destroyAdminSession, clearAdminCookie } from "./session";
|
import { getAdminSession, destroyAdminSession, clearAdminCookie } from "./session";
|
||||||
import { saveGroups, loadGroups, identityMatches, normalizeGroupMembers } from "./groups";
|
import { saveGroups, loadGroups, identityMatches, normalizeGroupMembers } from "./groups";
|
||||||
import {
|
import {
|
||||||
|
|
@ -35,7 +37,8 @@ import { getTenantSecret, setTenantSecret, deleteTenantSecret } from "./tenants"
|
||||||
import { cfEnv } from "../cf";
|
import { cfEnv } from "../cf";
|
||||||
import { log } from "../lib/log";
|
import { log } from "../lib/log";
|
||||||
|
|
||||||
const VALID_FILTER_TYPES = new Set(["event", "repo", "actor", "action", "branch", "keyword"]);
|
const VALID_FILTER_TYPES = new Set(["event", "repo", "actor", "action", "branch", "keyword", "field"]);
|
||||||
|
const VALID_OPS = new Set(["eq", "ne", "contains", "startsWith", "endsWith", "regex", "gt", "gte", "lt", "lte", "in", "exists"]);
|
||||||
const ID_RE = /^[a-z0-9][a-z0-9-]*$/;
|
const ID_RE = /^[a-z0-9][a-z0-9-]*$/;
|
||||||
const HOST_RE =
|
const HOST_RE =
|
||||||
/^[a-zA-Z0-9]([a-zA-Z0-9-]*[a-zA-Z0-9])?(\.[a-zA-Z0-9]([a-zA-Z0-9-]*[a-zA-Z0-9])?)*$/;
|
/^[a-zA-Z0-9]([a-zA-Z0-9-]*[a-zA-Z0-9])?(\.[a-zA-Z0-9]([a-zA-Z0-9-]*[a-zA-Z0-9])?)*$/;
|
||||||
|
|
@ -65,6 +68,49 @@ function deepEqual(a: unknown, b: unknown): boolean {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function validateFilterNode(node: unknown, label: string): string | null {
|
||||||
|
if (!node || typeof node !== "object" || Array.isArray(node)) {
|
||||||
|
return `${label} must be an object`;
|
||||||
|
}
|
||||||
|
const n = node as Record<string, unknown>;
|
||||||
|
if (Array.isArray(n.all)) {
|
||||||
|
if (n.all.length === 0) return `${label}.all must not be empty`;
|
||||||
|
for (let i = 0; i < n.all.length; i++) {
|
||||||
|
const err = validateFilterNode(n.all[i], `${label}.all[${i}]`);
|
||||||
|
if (err) return err;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (Array.isArray(n.any)) {
|
||||||
|
if (n.any.length === 0) return `${label}.any must not be empty`;
|
||||||
|
for (let i = 0; i < n.any.length; i++) {
|
||||||
|
const err = validateFilterNode(n.any[i], `${label}.any[${i}]`);
|
||||||
|
if (err) return err;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (n.not !== undefined) return validateFilterNode(n.not, `${label}.not`);
|
||||||
|
if (!VALID_FILTER_TYPES.has(n.type as string)) {
|
||||||
|
return `${label} has unknown type`;
|
||||||
|
}
|
||||||
|
if (n.type === "field" && (typeof n.path !== "string" || n.path.trim().length === 0)) {
|
||||||
|
return `${label}.path is required for field filters`;
|
||||||
|
}
|
||||||
|
if (n.path !== undefined && typeof n.path !== "string") {
|
||||||
|
return `${label}.path must be a string`;
|
||||||
|
}
|
||||||
|
if (n.op !== undefined && !VALID_OPS.has(n.op as string)) {
|
||||||
|
return `${label}.op is invalid`;
|
||||||
|
}
|
||||||
|
if ((n.op as string) !== "exists" && !isValidMatch(n.match)) {
|
||||||
|
return `${label} needs a match value`;
|
||||||
|
}
|
||||||
|
if (n.exclude !== undefined && typeof n.exclude !== "boolean") {
|
||||||
|
return `${label}.exclude must be a boolean`;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Validates the submitted routes. Routes that are byte-for-byte identical to an
|
* Validates the submitted routes. Routes that are byte-for-byte identical to an
|
||||||
* entry in `unchanged` (keyed by id) skip the full content check, so a pre-existing
|
* entry in `unchanged` (keyed by id) skip the full content check, so a pre-existing
|
||||||
|
|
@ -122,22 +168,16 @@ function validateRoutes(
|
||||||
if (!Array.isArray(r.filters)) {
|
if (!Array.isArray(r.filters)) {
|
||||||
return { ok: false, error: `route "${r.id}".filters must be an array` };
|
return { ok: false, error: `route "${r.id}".filters must be an array` };
|
||||||
}
|
}
|
||||||
if (r.fallback !== true && r.filters.length === 0) {
|
if (r.fallback !== true && r.filters.length === 0 && r.ast === undefined) {
|
||||||
return { ok: false, error: `route "${r.id}" needs at least one filter` };
|
return { ok: false, error: `route "${r.id}" needs at least one filter` };
|
||||||
}
|
}
|
||||||
for (let j = 0; j < r.filters.length; j++) {
|
for (let j = 0; j < r.filters.length; j++) {
|
||||||
const f = r.filters[j] as Record<string, unknown>;
|
const err = validateFilterNode(r.filters[j], `route "${r.id}" filter[${j}]`);
|
||||||
if (!f || typeof f !== "object")
|
if (err) return { ok: false, error: err };
|
||||||
return { ok: false, error: `route "${r.id}" filter[${j}] invalid` };
|
}
|
||||||
if (!VALID_FILTER_TYPES.has(f.type as string)) {
|
if (r.ast !== undefined) {
|
||||||
return { ok: false, error: `route "${r.id}" filter[${j}] has unknown type` };
|
const err = validateFilterNode(r.ast, `route "${r.id}".ast`);
|
||||||
}
|
if (err) return { ok: false, error: err };
|
||||||
if (!isValidMatch(f.match)) {
|
|
||||||
return { ok: false, error: `route "${r.id}" filter[${j}] needs a match value` };
|
|
||||||
}
|
|
||||||
if (f.exclude !== undefined && typeof f.exclude !== "boolean") {
|
|
||||||
return { ok: false, error: `route "${r.id}" filter[${j}].exclude must be boolean` };
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
const rawTarget = r.target as Record<string, unknown> | undefined;
|
const rawTarget = r.target as Record<string, unknown> | undefined;
|
||||||
const rawTargets = r.targets as unknown;
|
const rawTargets = r.targets as unknown;
|
||||||
|
|
@ -1052,3 +1092,114 @@ export async function adminApiDelivery(
|
||||||
}
|
}
|
||||||
return { deliveryId, attempts: rows };
|
return { deliveryId, attempts: rows };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function validateFragments(
|
||||||
|
fragments: unknown,
|
||||||
|
groupId: string,
|
||||||
|
): { ok: true; fragments: NamedFragment[] } | { ok: false; error: string } {
|
||||||
|
if (!Array.isArray(fragments)) return { ok: false, error: "fragments must be an array" };
|
||||||
|
if (fragments.length > 200) return { ok: false, error: "too many fragments" };
|
||||||
|
const seen = new Set<string>();
|
||||||
|
const out: NamedFragment[] = [];
|
||||||
|
for (let i = 0; i < fragments.length; i++) {
|
||||||
|
const f = fragments[i] as Record<string, unknown>;
|
||||||
|
if (!f || typeof f !== "object") return { ok: false, error: `fragment[${i}] is not an object` };
|
||||||
|
if (typeof f.id !== "string" || !ID_RE.test(f.id)) {
|
||||||
|
return { ok: false, error: `fragment[${i}].id is invalid` };
|
||||||
|
}
|
||||||
|
if (seen.has(f.id)) return { ok: false, error: `duplicate fragment id "${f.id}"` };
|
||||||
|
seen.add(f.id);
|
||||||
|
if (typeof f.name !== "string" || f.name.trim().length === 0) {
|
||||||
|
return { ok: false, error: `fragment "${f.id}" needs a name` };
|
||||||
|
}
|
||||||
|
const err = validateFilterNode(f.node, `fragment "${f.id}".node`);
|
||||||
|
if (err) return { ok: false, error: err };
|
||||||
|
out.push({ id: f.id, groupId, name: f.name, node: f.node as FilterNode });
|
||||||
|
}
|
||||||
|
return { ok: true, fragments: out };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** GET /admin/api/groups/:groupId/fragments */
|
||||||
|
export async function adminGroupFragmentsGet(
|
||||||
|
event: H3Event,
|
||||||
|
groupId: string,
|
||||||
|
): Promise<Record<string, unknown>> {
|
||||||
|
await requireAnyAccess(event);
|
||||||
|
const env = cfEnv(event);
|
||||||
|
const access = requireGroup(event, groupId);
|
||||||
|
if (!access.ok) return accessError(event, access);
|
||||||
|
const all = await loadFragments(env.DB);
|
||||||
|
return { group: access.group, fragments: all.filter((f) => f.groupId === groupId) };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** PUT /admin/api/groups/:groupId/fragments */
|
||||||
|
export async function adminGroupFragmentsPut(
|
||||||
|
event: H3Event,
|
||||||
|
groupId: string,
|
||||||
|
): Promise<Record<string, unknown>> {
|
||||||
|
await requireAnyAccess(event);
|
||||||
|
const env = cfEnv(event);
|
||||||
|
const access = requireGroupRole(event, groupId, "admin");
|
||||||
|
if (!access.ok) return accessError(event, access);
|
||||||
|
|
||||||
|
const body = await readJsonBody(event);
|
||||||
|
if (!body) return respondError(event, 400, "Invalid JSON body");
|
||||||
|
|
||||||
|
const submitted = body["fragments"];
|
||||||
|
const result = validateFragments(submitted, groupId);
|
||||||
|
if (!result.ok) return respondError(event, 400, result.error);
|
||||||
|
|
||||||
|
const existing = await loadFragments(env.DB);
|
||||||
|
const others = existing.filter((f) => f.groupId !== groupId);
|
||||||
|
const nextAll = [...others, ...result.fragments];
|
||||||
|
|
||||||
|
try {
|
||||||
|
await saveFragments(env.DB, nextAll);
|
||||||
|
} catch (err) {
|
||||||
|
log.error({ err }, "Failed to save fragments");
|
||||||
|
return respondError(event, 500, "Failed to save fragments");
|
||||||
|
}
|
||||||
|
const auth = currentAuth(event);
|
||||||
|
await recordAudit(env.DB, {
|
||||||
|
ts: Date.now(),
|
||||||
|
actorId: auth.session.userId,
|
||||||
|
actorLogin: auth.session.login,
|
||||||
|
action: "group.fragments.update",
|
||||||
|
targetType: "group",
|
||||||
|
targetId: groupId,
|
||||||
|
groupId,
|
||||||
|
detail: { count: result.fragments.length },
|
||||||
|
ip: clientIp(event),
|
||||||
|
});
|
||||||
|
return { ok: true, count: result.fragments.length };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** POST /admin/api/test-match */
|
||||||
|
export async function adminApiTestMatch(
|
||||||
|
event: H3Event,
|
||||||
|
): Promise<Record<string, unknown>> {
|
||||||
|
await requireAnyAccess(event);
|
||||||
|
const body = await readJsonBody(event);
|
||||||
|
if (!body) return respondError(event, 400, "Invalid JSON body");
|
||||||
|
|
||||||
|
const rawNode =
|
||||||
|
body["node"] ?? (Array.isArray(body["filters"]) ? { all: body["filters"] } : undefined);
|
||||||
|
if (rawNode === undefined) return respondError(event, 400, "Missing filter node");
|
||||||
|
const err = validateFilterNode(rawNode, "node");
|
||||||
|
if (err) return respondError(event, 400, err);
|
||||||
|
|
||||||
|
const payload = body["payload"];
|
||||||
|
if (!payload || typeof payload !== "object" || Array.isArray(payload)) {
|
||||||
|
return respondError(event, 400, "Missing payload object");
|
||||||
|
}
|
||||||
|
const eventName =
|
||||||
|
typeof body["event"] === "string" && body["event"].trim() ? body["event"].trim() : "custom";
|
||||||
|
const webhookEvent = { event: eventName, payload: payload as Record<string, unknown> };
|
||||||
|
let matched = false;
|
||||||
|
try {
|
||||||
|
matched = evaluateFilterNode(rawNode as FilterNode, webhookEvent);
|
||||||
|
} catch (e) {
|
||||||
|
return respondError(event, 400, `Failed to evaluate: ${String(e)}`);
|
||||||
|
}
|
||||||
|
return { matched, explanation: explainFilterNode(rawNode as FilterNode) };
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -19,6 +19,9 @@ import {
|
||||||
adminAudit,
|
adminAudit,
|
||||||
adminApiMetrics,
|
adminApiMetrics,
|
||||||
adminApiDelivery,
|
adminApiDelivery,
|
||||||
|
adminApiTestMatch,
|
||||||
|
adminGroupFragmentsGet,
|
||||||
|
adminGroupFragmentsPut,
|
||||||
} from "../../../lib/web/admin";
|
} from "../../../lib/web/admin";
|
||||||
|
|
||||||
export default defineEventHandler((event) => {
|
export default defineEventHandler((event) => {
|
||||||
|
|
@ -36,24 +39,24 @@ export default defineEventHandler((event) => {
|
||||||
}
|
}
|
||||||
if (seg[0] === "logs" && seg.length === 1 && method === "GET") return adminApiLogs(event);
|
if (seg[0] === "logs" && seg.length === 1 && method === "GET") return adminApiLogs(event);
|
||||||
if (seg[0] === "logs" && seg.length === 2 && method === "GET")
|
if (seg[0] === "logs" && seg.length === 2 && method === "GET")
|
||||||
return adminApiLogsById(event, Number(seg[1]));
|
return adminApiLogsById(event, Number(seg[1]!));
|
||||||
if (seg[0] === "groups" && seg[2] === "routes" && seg.length === 3) {
|
if (seg[0] === "groups" && seg[2] === "routes" && seg.length === 3) {
|
||||||
if (method === "GET") return adminGroupRoutesGet(event, seg[1]);
|
if (method === "GET") return adminGroupRoutesGet(event, seg[1]!);
|
||||||
if (method === "PUT") return adminGroupRoutesPut(event, seg[1]);
|
if (method === "PUT") return adminGroupRoutesPut(event, seg[1]!);
|
||||||
}
|
}
|
||||||
if (seg[0] === "groups" && seg[2] === "invites" && seg.length === 3) {
|
if (seg[0] === "groups" && seg[2] === "invites" && seg.length === 3) {
|
||||||
if (method === "GET") return adminGroupInvitesGet(event, seg[1]);
|
if (method === "GET") return adminGroupInvitesGet(event, seg[1]!);
|
||||||
if (method === "POST") return adminGroupInvitesPost(event, seg[1]);
|
if (method === "POST") return adminGroupInvitesPost(event, seg[1]!);
|
||||||
}
|
}
|
||||||
if (seg[0] === "invites" && seg.length === 2 && method === "DELETE")
|
if (seg[0] === "invites" && seg.length === 2 && method === "DELETE")
|
||||||
return adminInviteDelete(event, seg[1]);
|
return adminInviteDelete(event, seg[1]!);
|
||||||
if (
|
if (
|
||||||
seg[0] === "groups" &&
|
seg[0] === "groups" &&
|
||||||
seg[2] === "rename" &&
|
seg[2] === "rename" &&
|
||||||
seg.length === 3 &&
|
seg.length === 3 &&
|
||||||
(method === "POST" || method === "PUT")
|
(method === "POST" || method === "PUT")
|
||||||
)
|
)
|
||||||
return adminGroupRename(event, seg[1]);
|
return adminGroupRename(event, seg[1]!);
|
||||||
if (
|
if (
|
||||||
seg[0] === "groups" &&
|
seg[0] === "groups" &&
|
||||||
seg[2] === "webhook" &&
|
seg[2] === "webhook" &&
|
||||||
|
|
@ -61,15 +64,21 @@ export default defineEventHandler((event) => {
|
||||||
seg.length === 4 &&
|
seg.length === 4 &&
|
||||||
method === "POST"
|
method === "POST"
|
||||||
)
|
)
|
||||||
return adminGroupWebhookRegenerate(event, seg[1]);
|
return adminGroupWebhookRegenerate(event, seg[1]!);
|
||||||
if (seg[0] === "groups" && seg[2] === "webhook" && seg.length === 3) {
|
if (seg[0] === "groups" && seg[2] === "webhook" && seg.length === 3) {
|
||||||
if (method === "GET") return adminGroupWebhookGet(event, seg[1]);
|
if (method === "GET") return adminGroupWebhookGet(event, seg[1]!);
|
||||||
if (method === "DELETE") return adminGroupWebhookDelete(event, seg[1]);
|
if (method === "DELETE") return adminGroupWebhookDelete(event, seg[1]!);
|
||||||
}
|
}
|
||||||
if (seg[0] === "audit" && seg.length === 1 && method === "GET") return adminAudit(event);
|
if (seg[0] === "audit" && seg.length === 1 && method === "GET") return adminAudit(event);
|
||||||
if (seg[0] === "metrics" && seg.length === 1 && method === "GET") return adminApiMetrics(event);
|
if (seg[0] === "metrics" && seg.length === 1 && method === "GET") return adminApiMetrics(event);
|
||||||
if (seg[0] === "delivery" && seg.length === 2 && method === "GET")
|
if (seg[0] === "delivery" && seg.length === 2 && method === "GET")
|
||||||
return adminApiDelivery(event, seg[1]);
|
return adminApiDelivery(event, seg[1]!);
|
||||||
|
if (seg[0] === "test-match" && seg.length === 1 && method === "POST")
|
||||||
|
return adminApiTestMatch(event);
|
||||||
|
if (seg[0] === "groups" && seg[2] === "fragments" && seg.length === 3) {
|
||||||
|
if (method === "GET") return adminGroupFragmentsGet(event, seg[1]!);
|
||||||
|
if (method === "PUT") return adminGroupFragmentsPut(event, seg[1]!);
|
||||||
|
}
|
||||||
|
|
||||||
setResponseStatus(event, 404);
|
setResponseStatus(event, 404);
|
||||||
return { error: "Not found" };
|
return { error: "Not found" };
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue