feat: add stop property to route for exclusive matching

This commit is contained in:
RhenCloud 2026-08-08 00:40:45 +08:00
parent cb18683fb8
commit feada32e83
9 changed files with 150 additions and 114 deletions

View file

@ -80,7 +80,7 @@ src/__tests__/ # bun test unit tests (webhook, formatter, discord, te
- Verify Discord interactions (Web Crypto Ed25519, X-Signature-Ed25519 over timestamp + body)
- Verify Telegram webhook calls (X-Telegram-Bot-Api-Secret-Token when configured)
- Filter events by: event type, repo name, actor, action, branch, keyword (regex supported)
- Filter routes by group owner restriction (`Group.owners`) and skip fallback routes whenever a regular route matched
- Filter routes by group owner restriction (`Group.owners`) and skip fallback routes whenever a regular route matched; stop evaluating further routes when a matched route has `stop: true`
- Format 28 event types as platform-neutral messages (Discord embeds + Telegram HTML)
- Route messages to Discord channels/threads and Telegram chats/topics via REST
- Edit already-sent messages in place for `workflow_run` progress (stable `updateKey`, KV `msg:*` tracking)

View file

@ -79,6 +79,7 @@ Routes are stored in KV (`config:routes` as JSON). There are **no default routes
"enabled": true,
"groupId": "default",
"filters": [{ "type": "event", "match": "push" }],
"stop": true,
"targets": [
{ "platform": "discord", "channelId": "CHANNEL_ID" },
{ "platform": "telegram", "chatId": "-1001234567890" }

View file

@ -79,6 +79,7 @@ npx wrangler dev # 启动本地开发服务器
"enabled": true,
"groupId": "default",
"filters": [{ "type": "event", "match": "push" }],
"stop": true,
"targets": [
{ "platform": "discord", "channelId": "频道ID" },
{ "platform": "telegram", "chatId": "-1001234567890" }

View file

@ -61,3 +61,16 @@ routes:
# targets:
# - platform: telegram
# chatId: "-1001234567890"
# Stop routes stop processing further routes when matched.
# - id: exclusive-push
# name: "Exclusive Push"
# enabled: true
# groupId: default
# stop: true
# filters:
# - type: event
# match: push
# targets:
# - platform: discord
# channelId: "CHANNEL_ID_HERE"

View file

@ -74,6 +74,7 @@ There are **no default routes** — each route must define its own target. If no
"enabled": true,
"groupId": "my-group",
"fallback": false,
"stop": false,
"filters": [
{ "type": "event", "match": "push" },
{ "type": "repo", "match": "org/repo", "exclude": false }
@ -96,6 +97,7 @@ Other route fields:
| ---------- | ------- | -------- | ----------------------------------------------------------------------------------------------- |
| `groupId` | string | Yes | Id of the [group](#groups) this route belongs to |
| `fallback` | boolean | No | When `true`, fires only if no non-fallback route matched the event; its own filters are ignored |
| `stop` | boolean | No | When `true` and this route matches, no further routes are evaluated for this event |
| `lang` | string | No | Message language override for this route (e.g. `en`, `zh`); defaults to the global setting |
### Custom Route Example

View file

@ -74,6 +74,7 @@ WebHooker 内置了位于 `/admin` 的配置控制台,可在浏览器中管理
"enabled": true,
"groupId": "my-group",
"fallback": false,
"stop": false,
"filters": [
{ "type": "event", "match": "push" },
{ "type": "repo", "match": "org/repo", "exclude": false }
@ -96,6 +97,7 @@ WebHooker 内置了位于 `/admin` 的配置控制台,可在浏览器中管理
| ---------- | ------- | ---- | ---------------------------------------------------------------------- |
| `groupId` | string | 是 | 该路由所属[分组](#分组)的 id |
| `fallback` | boolean | 否 | 为 `true` 时,仅当没有其它路由匹配该事件时才发送,其自身过滤器会被忽略 |
| `stop` | boolean | 否 | 为 `true` 且该路由匹配时,停止评估后续路由 |
| `lang` | string | 否 | 该路由的消息语言覆盖(如 `en``zh`),默认跟随全局设置 |
### 自定义路由示例

View file

@ -31,13 +31,23 @@ export async function dispatchEvent(config: Config, event: WebhookEvent, env: En
);
const anyRegularMatched = matched.length > 0;
const tasks = config.routes
.filter((route) => {
if (!accepted(route)) return false;
if (route.fallback) return !anyRegularMatched && matchRoute(route, event);
return matchRoute(route, event);
})
.map(async (route) => {
const tasks: Promise<void>[] = [];
for (const route of config.routes) {
if (!accepted(route)) continue;
if (route.fallback) {
if (!anyRegularMatched && matchRoute(route, event)) {
tasks.push(processRoute(route));
}
continue;
}
if (matchRoute(route, event)) {
tasks.push(processRoute(route));
if (route.stop) break;
}
}
await Promise.allSettled(tasks);
async function processRoute(route: Route): Promise<void> {
const targets = route.targets && route.targets.length > 0 ? route.targets : [];
if (targets.length === 0) return;
@ -143,7 +153,5 @@ export async function dispatchEvent(config: Config, event: WebhookEvent, env: En
log.error({ routeId: route.id, target: targetStr, err }, "Route failed");
}
}
});
await Promise.allSettled(tasks);
}
}

View file

@ -58,6 +58,12 @@ export interface Route {
* least one regular route matches. Its own filters are ignored.
*/
fallback?: boolean;
/**
* Stop: when true and this route matches, no further routes are evaluated
* for this event. Useful for exclusive routing where a match should prevent
* fallthrough to subsequent routes.
*/
stop?: boolean;
}
export interface Group {

View file

@ -78,6 +78,9 @@ function validateRoutes(
if (r.fallback !== undefined && typeof r.fallback !== "boolean") {
return { ok: false, error: `route "${r.id}".fallback must be a boolean` };
}
if (r.stop !== undefined && typeof r.stop !== "boolean") {
return { ok: false, error: `route "${r.id}".stop must be a boolean` };
}
if (!Array.isArray(r.filters)) {
return { ok: false, error: `route "${r.id}".filters must be an array` };
}