From feada32e83fefbfbdb5359cb1afab4f7cbd8a8df Mon Sep 17 00:00:00 2001 From: RhenCloud Date: Sat, 8 Aug 2026 00:40:45 +0800 Subject: [PATCH] feat: add stop property to route for exclusive matching --- AGENTS.md | 2 +- README.md | 1 + README.zh.md | 1 + config.example.yaml | 13 ++ docs/guide/configuration.md | 2 + docs/zh/guide/configuration.md | 2 + src/core/dispatch.ts | 234 +++++++++++++++++---------------- src/types.ts | 6 + src/web/admin-routes.ts | 3 + 9 files changed, 150 insertions(+), 114 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 8673d68..8f3b88f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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) diff --git a/README.md b/README.md index a0e93cf..1e359a9 100644 --- a/README.md +++ b/README.md @@ -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" } diff --git a/README.zh.md b/README.zh.md index 128625a..6722c33 100644 --- a/README.zh.md +++ b/README.zh.md @@ -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" } diff --git a/config.example.yaml b/config.example.yaml index c4c4ae1..6a8c4c3 100644 --- a/config.example.yaml +++ b/config.example.yaml @@ -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" diff --git a/docs/guide/configuration.md b/docs/guide/configuration.md index 3955c9c..8eb9883 100644 --- a/docs/guide/configuration.md +++ b/docs/guide/configuration.md @@ -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 diff --git a/docs/zh/guide/configuration.md b/docs/zh/guide/configuration.md index 4a06e44..79acee9 100644 --- a/docs/zh/guide/configuration.md +++ b/docs/zh/guide/configuration.md @@ -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`),默认跟随全局设置 | ### 自定义路由示例 diff --git a/src/core/dispatch.ts b/src/core/dispatch.ts index d4b7c1f..ac3c51a 100644 --- a/src/core/dispatch.ts +++ b/src/core/dispatch.ts @@ -31,119 +31,127 @@ 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 targets = route.targets && route.targets.length > 0 ? route.targets : []; - if (targets.length === 0) return; - - const tr = trMap.get(route.lang ?? "en")!; - const group = route.groupId ? groupById.get(route.groupId) : undefined; - const showEmoji = group?.emoji !== false; - const message = formatEvent(route, event, tr, showEmoji); - - for (const target of targets) { - const targetStr = - target.platform === "telegram" - ? target.topicId - ? `${target.chatId}/${target.topicId}` - : (target.chatId ?? "") - : target.threadId - ? `${target.channelId}/${target.threadId}` - : (target.channelId ?? ""); - - const base: { - ts: number; - routeId: string; - event: string; - repo: string | undefined; - target: string; - deliveryId: string | undefined; - actor: string | undefined; - action: string | undefined; - } = { - ts: Date.now(), - routeId: route.id, - event: event.event, - repo: (event.payload.repository as { full_name?: string } | undefined)?.full_name, - target: targetStr, - deliveryId: event.deliveryId, - actor: (event.payload.sender as { login?: string } | undefined)?.login, - action: event.payload.action as string | undefined, - }; - - const started = Date.now(); - try { - const driver = getDriver(target); - let result: SendResult; - if (message.updateKey) { - const kvKey = `msg:${route.id}:${message.updateKey}:${targetStr}`; - const existingId = await env.KV.get(kvKey); - if (existingId) { - result = await driver.edit(message, target, env, existingId); - if (result.ok) { - await recordSend(env.DB, { - ...base, - ok: true, - status: result.status, - messageId: existingId, - platform: driver.id, - attempts: result.attempts, - durationMs: Date.now() - started, - errorCode: result.errorCode, - }); - continue; - } - if (/not modified/i.test(result.error ?? "")) { - await recordSend(env.DB, { - ...base, - ok: true, - status: result.status, - messageId: existingId, - platform: driver.id, - attempts: result.attempts, - durationMs: Date.now() - started, - errorCode: result.errorCode, - }); - continue; - } - await env.KV.delete(kvKey); - } - result = await driver.send(message, target, env); - if (result.ok && result.messageId) { - await env.KV.put(kvKey, result.messageId, { expirationTtl: 604800 }); - } - } else { - result = await driver.send(message, target, env); - } - const durationMs = Date.now() - started; - if (!result.ok) throw new Error(result.error ?? "Send failed"); - await recordSend(env.DB, { - ...base, - ok: true, - status: result.status, - messageId: result.messageId, - platform: driver.id, - attempts: result.attempts, - durationMs, - errorCode: result.errorCode, - }); - } catch (err) { - const durationMs = Date.now() - started; - await recordSend(env.DB, { - ...base, - ok: false, - error: err instanceof Error ? err.message : String(err), - durationMs, - }); - log.error({ routeId: route.id, target: targetStr, err }, "Route failed"); - } + const tasks: Promise[] = []; + 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 { + const targets = route.targets && route.targets.length > 0 ? route.targets : []; + if (targets.length === 0) return; + + const tr = trMap.get(route.lang ?? "en")!; + const group = route.groupId ? groupById.get(route.groupId) : undefined; + const showEmoji = group?.emoji !== false; + const message = formatEvent(route, event, tr, showEmoji); + + for (const target of targets) { + const targetStr = + target.platform === "telegram" + ? target.topicId + ? `${target.chatId}/${target.topicId}` + : (target.chatId ?? "") + : target.threadId + ? `${target.channelId}/${target.threadId}` + : (target.channelId ?? ""); + + const base: { + ts: number; + routeId: string; + event: string; + repo: string | undefined; + target: string; + deliveryId: string | undefined; + actor: string | undefined; + action: string | undefined; + } = { + ts: Date.now(), + routeId: route.id, + event: event.event, + repo: (event.payload.repository as { full_name?: string } | undefined)?.full_name, + target: targetStr, + deliveryId: event.deliveryId, + actor: (event.payload.sender as { login?: string } | undefined)?.login, + action: event.payload.action as string | undefined, + }; + + const started = Date.now(); + try { + const driver = getDriver(target); + let result: SendResult; + if (message.updateKey) { + const kvKey = `msg:${route.id}:${message.updateKey}:${targetStr}`; + const existingId = await env.KV.get(kvKey); + if (existingId) { + result = await driver.edit(message, target, env, existingId); + if (result.ok) { + await recordSend(env.DB, { + ...base, + ok: true, + status: result.status, + messageId: existingId, + platform: driver.id, + attempts: result.attempts, + durationMs: Date.now() - started, + errorCode: result.errorCode, + }); + continue; + } + if (/not modified/i.test(result.error ?? "")) { + await recordSend(env.DB, { + ...base, + ok: true, + status: result.status, + messageId: existingId, + platform: driver.id, + attempts: result.attempts, + durationMs: Date.now() - started, + errorCode: result.errorCode, + }); + continue; + } + await env.KV.delete(kvKey); + } + result = await driver.send(message, target, env); + if (result.ok && result.messageId) { + await env.KV.put(kvKey, result.messageId, { expirationTtl: 604800 }); + } + } else { + result = await driver.send(message, target, env); + } + const durationMs = Date.now() - started; + if (!result.ok) throw new Error(result.error ?? "Send failed"); + await recordSend(env.DB, { + ...base, + ok: true, + status: result.status, + messageId: result.messageId, + platform: driver.id, + attempts: result.attempts, + durationMs, + errorCode: result.errorCode, + }); + } catch (err) { + const durationMs = Date.now() - started; + await recordSend(env.DB, { + ...base, + ok: false, + error: err instanceof Error ? err.message : String(err), + durationMs, + }); + log.error({ routeId: route.id, target: targetStr, err }, "Route failed"); + } + } + } } diff --git a/src/types.ts b/src/types.ts index 1e57ca6..ae07359 100644 --- a/src/types.ts +++ b/src/types.ts @@ -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 { diff --git a/src/web/admin-routes.ts b/src/web/admin-routes.ts index 66b7632..3565f9c 100644 --- a/src/web/admin-routes.ts +++ b/src/web/admin-routes.ts @@ -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` }; }