From 38daf7bc8322d17a21dd3b50fbae69920cc1be3e Mon Sep 17 00:00:00 2001 From: RhenCloud Date: Mon, 3 Aug 2026 00:02:29 +0800 Subject: [PATCH] feat: ignore filters on fallback routes --- admin/components/RouteEditor.vue | 16 +++++- admin/composables/useI18n.ts | 4 +- src/__tests__/discord.test.ts | 96 +++++++++++++++++++++++++++++++- src/admin-routes.ts | 5 +- src/discord.ts | 21 ++++--- src/types.ts | 2 +- 6 files changed, 129 insertions(+), 15 deletions(-) diff --git a/admin/components/RouteEditor.vue b/admin/components/RouteEditor.vue index d94eefd..f66dc2f 100644 --- a/admin/components/RouteEditor.vue +++ b/admin/components/RouteEditor.vue @@ -133,13 +133,25 @@ watch( form.threadId = r?.target.threadId ?? ""; form.filters = (r && r.filters.length ? r.filters - : [{ type: "event", match: "", exclude: false }] + : form.fallback + ? [] + : [{ type: "event", match: "", exclude: false }] ).map((f) => ({ ...f, matchText: fmtMatch(f.match) })) as FilterForm[]; filterError.value = ""; formError.value = ""; }, ); +watch( + () => form.fallback, + (v) => { + if (v && form.filters.every((f) => f.matchText.trim() === "")) { + form.filters = []; + filterError.value = ""; + } + }, +); + function close(): void { emit("close"); } @@ -156,7 +168,7 @@ function collect(): Route | null { filters.push({ type: f.type, match, exclude: f.exclude }); } filterError.value = ""; - if (!filters.length) { + if (!form.fallback && !filters.length) { filterError.value = t("routeEditor.errAddFilter"); return null; } diff --git a/admin/composables/useI18n.ts b/admin/composables/useI18n.ts index 882accb..d1094de 100644 --- a/admin/composables/useI18n.ts +++ b/admin/composables/useI18n.ts @@ -53,7 +53,7 @@ const en: Dict = { "routeEditor.langHint": "en or zh; custom via KV i18n:", "routeEditor.enabled": "Route enabled", "routeEditor.fallback": "Fallback route", - "routeEditor.fallbackHint": "Only fires when no other route matched", + "routeEditor.fallbackHint": "Only fires when no other route matched; its filters are ignored", "routeEditor.filters": "Filters", "routeEditor.filtersNote": "(all must match · AND)", "routeEditor.matchPlaceholder": "match value", @@ -159,7 +159,7 @@ const zh: Dict = { "routeEditor.langHint": "en 或 zh;可通过 KV i18n: 自定义", "routeEditor.enabled": "启用路由", "routeEditor.fallback": "兜底路由", - "routeEditor.fallbackHint": "仅当没有其它路由匹配该事件时才发送", + "routeEditor.fallbackHint": "仅当没有其它路由匹配该事件时才发送,其过滤器会被忽略", "routeEditor.filters": "过滤器", "routeEditor.filtersNote": "(全部匹配 · AND)", "routeEditor.matchPlaceholder": "匹配值", diff --git a/src/__tests__/discord.test.ts b/src/__tests__/discord.test.ts index 193f0b1..b70538f 100644 --- a/src/__tests__/discord.test.ts +++ b/src/__tests__/discord.test.ts @@ -1,7 +1,7 @@ import { describe, it, expect, beforeEach, afterEach } from "bun:test"; import { sendMessage } from "../discord-rest"; -import { isGatewayEnabled } from "../discord"; -import type { Env } from "../types"; +import { dispatchEvent, isGatewayEnabled } from "../discord"; +import type { Env, Route } from "../types"; function mockFetch( handler: (url: string, init?: RequestInit) => Response, @@ -77,3 +77,95 @@ describe("isGatewayEnabled", () => { expect(isGatewayEnabled(createEnv())).toBe(false); }); }); + +describe("dispatchEvent fallback routing", () => { + function createMockKV(): KVNamespace { + const store = new Map(); + return { + get: async (key: string, type?: string) => { + const v = store.get(key); + if (v == null) return null; + if (type === "json") return JSON.parse(v); + return v; + }, + put: async (key: string, value: string) => { + store.set(key, value); + }, + delete: async (key: string) => { + store.delete(key); + }, + list: async () => ({ + keys: [...store.keys()].map((k) => ({ name: k })), + list_complete: true, + cacheStatus: null, + }), + } as unknown as KVNamespace; + } + + const baseConfig = { + baseUrl: "https://example.com", + github: { + webhookSecret: "s", + appId: 1, + privateKey: "", + clientId: "", + clientSecret: "", + }, + discord: { token: "t" }, + routes: [] as Route[], + }; + + it("fires a filter-less fallback route only when no regular route matched", async () => { + const sent: string[] = []; + mockFetch((url) => { + sent.push(url); + return new Response("{}", { status: 200 }); + }); + const env = createEnv({ KV: createMockKV() }); + const routes: Route[] = [ + { + id: "regular-push", + name: "Regular Push", + enabled: true, + filters: [{ type: "event", match: "push" }], + target: { channelId: "111" }, + }, + { + id: "catch-all", + name: "Catch all", + enabled: true, + filters: [], + fallback: true, + target: { channelId: "222" }, + }, + ]; + + await dispatchEvent({ ...baseConfig, routes }, { event: "push", payload: {} }, env); + expect(sent.filter((u) => u.includes("/111/"))).toHaveLength(1); + expect(sent.filter((u) => u.includes("/222/"))).toHaveLength(0); + + sent.length = 0; + await dispatchEvent( + { ...baseConfig, routes }, + { + event: "issues", + payload: { + action: "opened", + issue: { + number: 1, + title: "Test issue", + body: "body", + state: "open", + html_url: "https://example.com/i/1", + user: { login: "octocat" }, + }, + repository: { full_name: "owner/repo" }, + sender: { login: "octocat" }, + }, + }, + env, + ); + expect(sent.filter((u) => u.includes("/111/"))).toHaveLength(0); + expect(sent.filter((u) => u.includes("/222/"))).toHaveLength(1); + }); +}); diff --git a/src/admin-routes.ts b/src/admin-routes.ts index b0fd220..44f87d0 100644 --- a/src/admin-routes.ts +++ b/src/admin-routes.ts @@ -86,7 +86,10 @@ function validateRoutes( if (r.fallback !== undefined && typeof r.fallback !== "boolean") { return { ok: false, error: `route "${r.id}".fallback must be a boolean` }; } - if (!Array.isArray(r.filters) || r.filters.length === 0) { + if (!Array.isArray(r.filters)) { + return { ok: false, error: `route "${r.id}".filters must be an array` }; + } + if (r.fallback !== true && r.filters.length === 0) { return { ok: false, error: `route "${r.id}" needs at least one filter` }; } for (let j = 0; j < r.filters.length; j++) { diff --git a/src/discord.ts b/src/discord.ts index 37ab3b2..9088be1 100644 --- a/src/discord.ts +++ b/src/discord.ts @@ -42,19 +42,26 @@ export async function dispatchEvent(config: Config, event: WebhookEvent, env: En const groupById = new Map(groups.map((g) => [g.id, g])); const owners = eventOwners(event); - // Only routes that pass both their filters and their group's owner - // restriction count as "matched", so a fallback route still fires when a - // regular route was suppressed by its group. + // A regular route counts as "matched" only when it passes both its filters + // and its group's owner restriction. Fallback routes ignore their own filters + // and fire whenever no regular route matched, so they still catch events that + // a regular route's group suppressed. const accepted = (route: Route): boolean => { if (!route.groupId) return true; const group = groupById.get(route.groupId); return !group || groupAcceptsOwners(group, owners); }; - const matched = config.routes.filter((route) => matchRoute(route, event) && accepted(route)); - const anyRegularMatched = matched.some((route) => !route.fallback); + const matched = config.routes.filter( + (route) => !route.fallback && matchRoute(route, event) && accepted(route), + ); + const anyRegularMatched = matched.length > 0; - const tasks = matched - .filter((route) => !(route.fallback && anyRegularMatched)) + const tasks = config.routes + .filter((route) => { + if (!accepted(route)) return false; + if (route.fallback) return !anyRegularMatched; + return matchRoute(route, event); + }) .map(async (route) => { const target = route.target.threadId ? `${route.target.channelId}/${route.target.threadId}` diff --git a/src/types.ts b/src/types.ts index fc001b1..181f599 100644 --- a/src/types.ts +++ b/src/types.ts @@ -46,7 +46,7 @@ export interface Route { /** * Fallback route: only fires when no other (non-fallback) route matched the * event. Multiple fallback routes may exist; they are all skipped whenever at - * least one regular route matches. + * least one regular route matches. Its own filters are ignored. */ fallback?: boolean; }