mirror of
https://github.com/ReCloudStudio/WebHooker.git
synced 2026-09-23 00:21:28 +00:00
feat: add stop property to route for exclusive matching
This commit is contained in:
parent
cb18683fb8
commit
feada32e83
9 changed files with 150 additions and 114 deletions
|
|
@ -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 Discord interactions (Web Crypto Ed25519, X-Signature-Ed25519 over timestamp + body)
|
||||||
- Verify Telegram webhook calls (X-Telegram-Bot-Api-Secret-Token when configured)
|
- 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 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)
|
- Format 28 event types as platform-neutral messages (Discord embeds + Telegram HTML)
|
||||||
- Route messages to Discord channels/threads and Telegram chats/topics via REST
|
- 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)
|
- Edit already-sent messages in place for `workflow_run` progress (stable `updateKey`, KV `msg:*` tracking)
|
||||||
|
|
|
||||||
|
|
@ -79,6 +79,7 @@ Routes are stored in KV (`config:routes` as JSON). There are **no default routes
|
||||||
"enabled": true,
|
"enabled": true,
|
||||||
"groupId": "default",
|
"groupId": "default",
|
||||||
"filters": [{ "type": "event", "match": "push" }],
|
"filters": [{ "type": "event", "match": "push" }],
|
||||||
|
"stop": true,
|
||||||
"targets": [
|
"targets": [
|
||||||
{ "platform": "discord", "channelId": "CHANNEL_ID" },
|
{ "platform": "discord", "channelId": "CHANNEL_ID" },
|
||||||
{ "platform": "telegram", "chatId": "-1001234567890" }
|
{ "platform": "telegram", "chatId": "-1001234567890" }
|
||||||
|
|
|
||||||
|
|
@ -79,6 +79,7 @@ npx wrangler dev # 启动本地开发服务器
|
||||||
"enabled": true,
|
"enabled": true,
|
||||||
"groupId": "default",
|
"groupId": "default",
|
||||||
"filters": [{ "type": "event", "match": "push" }],
|
"filters": [{ "type": "event", "match": "push" }],
|
||||||
|
"stop": true,
|
||||||
"targets": [
|
"targets": [
|
||||||
{ "platform": "discord", "channelId": "频道ID" },
|
{ "platform": "discord", "channelId": "频道ID" },
|
||||||
{ "platform": "telegram", "chatId": "-1001234567890" }
|
{ "platform": "telegram", "chatId": "-1001234567890" }
|
||||||
|
|
|
||||||
|
|
@ -61,3 +61,16 @@ routes:
|
||||||
# targets:
|
# targets:
|
||||||
# - platform: telegram
|
# - platform: telegram
|
||||||
# chatId: "-1001234567890"
|
# 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"
|
||||||
|
|
|
||||||
|
|
@ -74,6 +74,7 @@ There are **no default routes** — each route must define its own target. If no
|
||||||
"enabled": true,
|
"enabled": true,
|
||||||
"groupId": "my-group",
|
"groupId": "my-group",
|
||||||
"fallback": false,
|
"fallback": false,
|
||||||
|
"stop": false,
|
||||||
"filters": [
|
"filters": [
|
||||||
{ "type": "event", "match": "push" },
|
{ "type": "event", "match": "push" },
|
||||||
{ "type": "repo", "match": "org/repo", "exclude": false }
|
{ "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 |
|
| `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 |
|
| `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 |
|
| `lang` | string | No | Message language override for this route (e.g. `en`, `zh`); defaults to the global setting |
|
||||||
|
|
||||||
### Custom Route Example
|
### Custom Route Example
|
||||||
|
|
|
||||||
|
|
@ -74,6 +74,7 @@ WebHooker 内置了位于 `/admin` 的配置控制台,可在浏览器中管理
|
||||||
"enabled": true,
|
"enabled": true,
|
||||||
"groupId": "my-group",
|
"groupId": "my-group",
|
||||||
"fallback": false,
|
"fallback": false,
|
||||||
|
"stop": false,
|
||||||
"filters": [
|
"filters": [
|
||||||
{ "type": "event", "match": "push" },
|
{ "type": "event", "match": "push" },
|
||||||
{ "type": "repo", "match": "org/repo", "exclude": false }
|
{ "type": "repo", "match": "org/repo", "exclude": false }
|
||||||
|
|
@ -96,6 +97,7 @@ WebHooker 内置了位于 `/admin` 的配置控制台,可在浏览器中管理
|
||||||
| ---------- | ------- | ---- | ---------------------------------------------------------------------- |
|
| ---------- | ------- | ---- | ---------------------------------------------------------------------- |
|
||||||
| `groupId` | string | 是 | 该路由所属[分组](#分组)的 id |
|
| `groupId` | string | 是 | 该路由所属[分组](#分组)的 id |
|
||||||
| `fallback` | boolean | 否 | 为 `true` 时,仅当没有其它路由匹配该事件时才发送,其自身过滤器会被忽略 |
|
| `fallback` | boolean | 否 | 为 `true` 时,仅当没有其它路由匹配该事件时才发送,其自身过滤器会被忽略 |
|
||||||
|
| `stop` | boolean | 否 | 为 `true` 且该路由匹配时,停止评估后续路由 |
|
||||||
| `lang` | string | 否 | 该路由的消息语言覆盖(如 `en`、`zh`),默认跟随全局设置 |
|
| `lang` | string | 否 | 该路由的消息语言覆盖(如 `en`、`zh`),默认跟随全局设置 |
|
||||||
|
|
||||||
### 自定义路由示例
|
### 自定义路由示例
|
||||||
|
|
|
||||||
|
|
@ -31,119 +31,127 @@ export async function dispatchEvent(config: Config, event: WebhookEvent, env: En
|
||||||
);
|
);
|
||||||
const anyRegularMatched = matched.length > 0;
|
const anyRegularMatched = matched.length > 0;
|
||||||
|
|
||||||
const tasks = config.routes
|
const tasks: Promise<void>[] = [];
|
||||||
.filter((route) => {
|
for (const route of config.routes) {
|
||||||
if (!accepted(route)) return false;
|
if (!accepted(route)) continue;
|
||||||
if (route.fallback) return !anyRegularMatched && matchRoute(route, event);
|
if (route.fallback) {
|
||||||
return matchRoute(route, event);
|
if (!anyRegularMatched && matchRoute(route, event)) {
|
||||||
})
|
tasks.push(processRoute(route));
|
||||||
.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");
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
});
|
continue;
|
||||||
|
}
|
||||||
|
if (matchRoute(route, event)) {
|
||||||
|
tasks.push(processRoute(route));
|
||||||
|
if (route.stop) break;
|
||||||
|
}
|
||||||
|
}
|
||||||
await Promise.allSettled(tasks);
|
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");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -58,6 +58,12 @@ export interface Route {
|
||||||
* least one regular route matches. Its own filters are ignored.
|
* least one regular route matches. Its own filters are ignored.
|
||||||
*/
|
*/
|
||||||
fallback?: boolean;
|
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 {
|
export interface Group {
|
||||||
|
|
|
||||||
|
|
@ -78,6 +78,9 @@ 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 (r.stop !== undefined && typeof r.stop !== "boolean") {
|
||||||
|
return { ok: false, error: `route "${r.id}".stop must be a boolean` };
|
||||||
|
}
|
||||||
if (!Array.isArray(r.filters)) {
|
if (!Array.isArray(r.filters)) {
|
||||||
return { ok: false, error: `route "${r.id}".filters must be an array` };
|
return { ok: false, error: `route "${r.id}".filters must be an array` };
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue