mirror of
https://github.com/ReCloudStudio/WebHooker.git
synced 2026-09-23 00:21:28 +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
|
|
@ -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) |
|
||||
| `GET /admin/api/groups/:id/routes` | List a group's routes |
|
||||
| `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 |
|
||||
| `GET /admin/api/groups/:id/invites` | List pending invites (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/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) |
|
||||
| `POST /admin/api/test-match` | Stateless filter dry-run — evaluate a filter node against a pasted JSON payload (no event is stored) |
|
||||
|
||||
## 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`.
|
||||
- `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.
|
||||
|
||||
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]` |
|
||||
| `action` | Event action | `opened`, `closed`, `published` |
|
||||
| `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+/` |
|
||||
|
||||
### 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)
|
||||
- Set `"exclude": true` on any filter to invert it (NOT logic)
|
||||
- Every filter type supports the same pattern forms: plain text, `*`/`?` **globs** (`*` = any run, `?` = one character), and `/regular expression/` — all case-insensitive
|
||||
- Field filters (`event`/`repo`/`actor`/`action`/`branch`) glob-match the whole value; `keyword` globs and regexes search anywhere in the payload; plain `keyword` text is a substring search
|
||||
- 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
|
||||
- `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
|
||||
{ "type": "event", "match": "push" }
|
||||
{ "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`.
|
||||
|
||||
### `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
|
||||
|
||||
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 |
|
||||
| `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 |
|
||||
| `ast` | object | No | Boolean filter tree (`{all:[...]}` / `{any:[...]}` / `{not:{...}}`); takes precedence over `filters` when present |
|
||||
|
||||
## Discord Role Mentions
|
||||
|
||||
|
|
@ -58,7 +59,7 @@ You can add role ids in the admin console under _Discord role mentions_.
|
|||
|
||||
## 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
|
||||
|
||||
|
|
|
|||
|
|
@ -19,6 +19,8 @@
|
|||
| `PUT /admin/api/groups` | 替换分组(超级管理员全部;owner 仅自己的) |
|
||||
| `GET /admin/api/groups/:id/routes` | 列出某分组的路由 |
|
||||
| `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 与邀请自动跟随 |
|
||||
| `GET /admin/api/groups/:id/invites` | 列出待处理的邀请(owner) |
|
||||
| `POST /admin/api/groups/:id/invites` | 创建邀请链接(owner) |
|
||||
|
|
@ -31,11 +33,14 @@
|
|||
| `GET /admin/api/audit` | 审计日志(按可访问的分组过滤) |
|
||||
| `GET /admin/api/metrics` | 投递统计(总计、失败率、按平台/事件/状态、最近失败);可选 `?groupId=` 按分组过滤;非超管按可访问分组过滤最近失败 |
|
||||
| `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`。
|
||||
- `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 个分组。
|
||||
|
||||
模式:见[路由与目标](../guide/routes)、[分组与访问控制](../guide/groups)。
|
||||
|
|
|
|||
|
|
@ -72,6 +72,7 @@ WebHooker 在 `/admin` 提供内置配置控制台,可在浏览器中管理路
|
|||
| `actor` | 发送者登录名 | `username`, `[bot]`, `*[bot]` |
|
||||
| `action` | 事件操作 | `opened`, `closed`, `published` |
|
||||
| `branch` | 分支名称 | `main`, `feature-?`, `/^release-/` |
|
||||
| `field` | 任意载荷字段(JSONPath) | `path: "pull_request.user.login"` |
|
||||
| `keyword` | 载荷正文中的文本 | `deploy`, `/fix\s+\d+/` |
|
||||
|
||||
### 过滤器行为
|
||||
|
|
@ -79,7 +80,10 @@ WebHooker 在 `/admin` 提供内置配置控制台,可在浏览器中管理路
|
|||
- 路由中的所有过滤器必须都匹配才触发路由(AND 逻辑)
|
||||
- 在任何过滤器上设置 `"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 个字符的模式不编译为通配符/正则;`//` 包裹的非法正则匹配不到任何内容
|
||||
- `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
|
||||
{ "type": "event", "match": "push" }
|
||||
{ "type": "event", "match": ["push", "pull_request"] }
|
||||
{ "type": "field", "path": "pull_request.commits", "op": "gt", "match": "1" }
|
||||
```
|
||||
|
|
|
|||
|
|
@ -194,6 +194,70 @@
|
|||
|
||||
跳过载荷中提及 `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 通知,跳过机器人和草稿
|
||||
|
||||
转发拉取请求动态,但忽略机器人作者和草稿 PR,发往 `#prs` 频道:
|
||||
|
|
|
|||
|
|
@ -37,6 +37,7 @@
|
|||
| `fallback` | boolean | 否 | 为 `true` 时仅在没有其他非 fallback 路由匹配时才触发;其自身过滤器被忽略 |
|
||||
| `stop` | boolean | 否 | 为 `true` 且该路由匹配时,不再评估后续路由 |
|
||||
| `discordRoleIds` | string[] | 否 | 路由触发时要提醒的 Discord 身份组 id;仅对 Discord 目标生效 |
|
||||
| `ast` | object | 否 | 布尔过滤器树(`{all:[...]}` / `{any:[...]}` / `{not:{...}}`);存在时优先于 `filters` |
|
||||
|
||||
## Discord 身份组提醒
|
||||
|
||||
|
|
@ -58,7 +59,7 @@
|
|||
|
||||
## 过滤器
|
||||
|
||||
每条路由携带 `filters` 数组(全部匹配才触发——AND 逻辑)。见[过滤器类型](./configuration#过滤器类型)参考与[过滤器教程](./filters)。
|
||||
每条路由携带 `filters` 数组(全部匹配才触发——AND 逻辑)。当路由带 `ast` 字段(嵌套的 `all`/`any`/`not` 树)时,用它替代 `filters` 求值,从而表达任意的布尔组合。见[过滤器类型](./configuration#过滤器类型)参考与[过滤器教程](./filters)。
|
||||
|
||||
## 自定义路由示例
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue