feat: ignore filters on fallback routes

This commit is contained in:
RhenCloud 2026-08-03 00:02:29 +08:00
parent bd33fa6835
commit 38daf7bc83
No known key found for this signature in database
GPG key ID: A574A617378C4E0B
6 changed files with 129 additions and 15 deletions

View file

@ -133,13 +133,25 @@ watch(
form.threadId = r?.target.threadId ?? ""; form.threadId = r?.target.threadId ?? "";
form.filters = (r && r.filters.length form.filters = (r && r.filters.length
? r.filters ? r.filters
: [{ type: "event", match: "", exclude: false }] : form.fallback
? []
: [{ type: "event", match: "", exclude: false }]
).map((f) => ({ ...f, matchText: fmtMatch(f.match) })) as FilterForm[]; ).map((f) => ({ ...f, matchText: fmtMatch(f.match) })) as FilterForm[];
filterError.value = ""; filterError.value = "";
formError.value = ""; formError.value = "";
}, },
); );
watch(
() => form.fallback,
(v) => {
if (v && form.filters.every((f) => f.matchText.trim() === "")) {
form.filters = [];
filterError.value = "";
}
},
);
function close(): void { function close(): void {
emit("close"); emit("close");
} }
@ -156,7 +168,7 @@ function collect(): Route | null {
filters.push({ type: f.type, match, exclude: f.exclude }); filters.push({ type: f.type, match, exclude: f.exclude });
} }
filterError.value = ""; filterError.value = "";
if (!filters.length) { if (!form.fallback && !filters.length) {
filterError.value = t("routeEditor.errAddFilter"); filterError.value = t("routeEditor.errAddFilter");
return null; return null;
} }

View file

@ -53,7 +53,7 @@ const en: Dict = {
"routeEditor.langHint": "en or zh; custom via KV i18n:<lang>", "routeEditor.langHint": "en or zh; custom via KV i18n:<lang>",
"routeEditor.enabled": "Route enabled", "routeEditor.enabled": "Route enabled",
"routeEditor.fallback": "Fallback route", "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.filters": "Filters",
"routeEditor.filtersNote": "(all must match · AND)", "routeEditor.filtersNote": "(all must match · AND)",
"routeEditor.matchPlaceholder": "match value", "routeEditor.matchPlaceholder": "match value",
@ -159,7 +159,7 @@ const zh: Dict = {
"routeEditor.langHint": "en 或 zh可通过 KV i18n:<lang> 自定义", "routeEditor.langHint": "en 或 zh可通过 KV i18n:<lang> 自定义",
"routeEditor.enabled": "启用路由", "routeEditor.enabled": "启用路由",
"routeEditor.fallback": "兜底路由", "routeEditor.fallback": "兜底路由",
"routeEditor.fallbackHint": "仅当没有其它路由匹配该事件时才发送", "routeEditor.fallbackHint": "仅当没有其它路由匹配该事件时才发送,其过滤器会被忽略",
"routeEditor.filters": "过滤器", "routeEditor.filters": "过滤器",
"routeEditor.filtersNote": "(全部匹配 · AND", "routeEditor.filtersNote": "(全部匹配 · AND",
"routeEditor.matchPlaceholder": "匹配值", "routeEditor.matchPlaceholder": "匹配值",

View file

@ -1,7 +1,7 @@
import { describe, it, expect, beforeEach, afterEach } from "bun:test"; import { describe, it, expect, beforeEach, afterEach } from "bun:test";
import { sendMessage } from "../discord-rest"; import { sendMessage } from "../discord-rest";
import { isGatewayEnabled } from "../discord"; import { dispatchEvent, isGatewayEnabled } from "../discord";
import type { Env } from "../types"; import type { Env, Route } from "../types";
function mockFetch( function mockFetch(
handler: (url: string, init?: RequestInit) => Response, handler: (url: string, init?: RequestInit) => Response,
@ -77,3 +77,95 @@ describe("isGatewayEnabled", () => {
expect(isGatewayEnabled(createEnv())).toBe(false); expect(isGatewayEnabled(createEnv())).toBe(false);
}); });
}); });
describe("dispatchEvent fallback routing", () => {
function createMockKV(): KVNamespace {
const store = new Map<string, string>();
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);
});
});

View file

@ -86,7 +86,10 @@ function validateRoutes(
if (r.fallback !== undefined && typeof r.fallback !== "boolean") { if (r.fallback !== undefined && typeof r.fallback !== "boolean") {
return { ok: false, error: `route "${r.id}".fallback must be a 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` }; 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++) {

View file

@ -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 groupById = new Map(groups.map((g) => [g.id, g]));
const owners = eventOwners(event); const owners = eventOwners(event);
// Only routes that pass both their filters and their group's owner // A regular route counts as "matched" only when it passes both its filters
// restriction count as "matched", so a fallback route still fires when a // and its group's owner restriction. Fallback routes ignore their own filters
// regular route was suppressed by its group. // and fire whenever no regular route matched, so they still catch events that
// a regular route's group suppressed.
const accepted = (route: Route): boolean => { const accepted = (route: Route): boolean => {
if (!route.groupId) return true; if (!route.groupId) return true;
const group = groupById.get(route.groupId); const group = groupById.get(route.groupId);
return !group || groupAcceptsOwners(group, owners); return !group || groupAcceptsOwners(group, owners);
}; };
const matched = config.routes.filter((route) => matchRoute(route, event) && accepted(route)); const matched = config.routes.filter(
const anyRegularMatched = matched.some((route) => !route.fallback); (route) => !route.fallback && matchRoute(route, event) && accepted(route),
);
const anyRegularMatched = matched.length > 0;
const tasks = matched const tasks = config.routes
.filter((route) => !(route.fallback && anyRegularMatched)) .filter((route) => {
if (!accepted(route)) return false;
if (route.fallback) return !anyRegularMatched;
return matchRoute(route, event);
})
.map(async (route) => { .map(async (route) => {
const target = route.target.threadId const target = route.target.threadId
? `${route.target.channelId}/${route.target.threadId}` ? `${route.target.channelId}/${route.target.threadId}`

View file

@ -46,7 +46,7 @@ export interface Route {
/** /**
* Fallback route: only fires when no other (non-fallback) route matched the * Fallback route: only fires when no other (non-fallback) route matched the
* event. Multiple fallback routes may exist; they are all skipped whenever at * 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; fallback?: boolean;
} }