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

@ -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<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;
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");
}
}
}
}

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` };
}