mirror of
https://github.com/ReCloudStudio/WebHooker.git
synced 2026-09-23 00:21:28 +00:00
feat: add fallback routes and repository event enrichment
This commit is contained in:
parent
44b8fead78
commit
d419b9c940
11 changed files with 104 additions and 8 deletions
|
|
@ -14,6 +14,8 @@
|
||||||
--accent-text: #3730a3;
|
--accent-text: #3730a3;
|
||||||
--ok: #16a34a;
|
--ok: #16a34a;
|
||||||
--ok-dim: #ecfdf3;
|
--ok-dim: #ecfdf3;
|
||||||
|
--warn: #b45309;
|
||||||
|
--warn-dim: #fef3c7;
|
||||||
--bad: #dc2626;
|
--bad: #dc2626;
|
||||||
--bad-dim: #fef2f2;
|
--bad-dim: #fef2f2;
|
||||||
--dot: rgba(15, 23, 42, 0.05);
|
--dot: rgba(15, 23, 42, 0.05);
|
||||||
|
|
@ -44,6 +46,8 @@
|
||||||
--accent-text: #c7d2fe;
|
--accent-text: #c7d2fe;
|
||||||
--ok: #22c55e;
|
--ok: #22c55e;
|
||||||
--ok-dim: #10241a;
|
--ok-dim: #10241a;
|
||||||
|
--warn: #fbbf24;
|
||||||
|
--warn-dim: #2a2410;
|
||||||
--bad: #f05252;
|
--bad: #f05252;
|
||||||
--bad-dim: #2a1618;
|
--bad-dim: #2a1618;
|
||||||
--dot: rgba(148, 163, 184, 0.08);
|
--dot: rgba(148, 163, 184, 0.08);
|
||||||
|
|
@ -345,6 +349,12 @@ main {
|
||||||
border: 1px solid transparent;
|
border: 1px solid transparent;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.badge.fallback {
|
||||||
|
background: var(--warn-dim);
|
||||||
|
color: var(--warn);
|
||||||
|
border: 1px solid transparent;
|
||||||
|
}
|
||||||
|
|
||||||
.card-actions {
|
.card-actions {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 6px;
|
gap: 6px;
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,7 @@
|
||||||
<span class="route-name">{{ route.name || t("route.untitled") }}</span>
|
<span class="route-name">{{ route.name || t("route.untitled") }}</span>
|
||||||
<span class="route-id">{{ route.id }}</span>
|
<span class="route-id">{{ route.id }}</span>
|
||||||
<span v-if="route.lang" class="badge lang">{{ route.lang }}</span>
|
<span v-if="route.lang" class="badge lang">{{ route.lang }}</span>
|
||||||
|
<span v-if="route.fallback" class="badge fallback">{{ t("route.fallback") }}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="card-actions">
|
<div class="card-actions">
|
||||||
<button class="icon-btn" :title="t('routeEditor.editTitle')" @click="$emit('edit', route)">✎</button>
|
<button class="icon-btn" :title="t('routeEditor.editTitle')" @click="$emit('edit', route)">✎</button>
|
||||||
|
|
|
||||||
|
|
@ -30,6 +30,10 @@
|
||||||
<input v-model="form.enabled" type="checkbox" />
|
<input v-model="form.enabled" type="checkbox" />
|
||||||
<span>{{ t("routeEditor.enabled") }}</span>
|
<span>{{ t("routeEditor.enabled") }}</span>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="field inline">
|
||||||
|
<input v-model="form.fallback" type="checkbox" />
|
||||||
|
<span>{{ t("routeEditor.fallback") }} <span class="lbl-note">{{ t("routeEditor.fallbackHint") }}</span></span>
|
||||||
|
</div>
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label>{{ t("routeEditor.filters") }} <span class="lbl-note">{{ t("routeEditor.filtersNote") }}</span></label>
|
<label>{{ t("routeEditor.filters") }} <span class="lbl-note">{{ t("routeEditor.filtersNote") }}</span></label>
|
||||||
<div v-for="(f, i) in form.filters" :key="i" class="filter-row">
|
<div v-for="(f, i) in form.filters" :key="i" class="filter-row">
|
||||||
|
|
@ -91,6 +95,7 @@ const form = reactive({
|
||||||
name: "",
|
name: "",
|
||||||
lang: "",
|
lang: "",
|
||||||
enabled: true,
|
enabled: true,
|
||||||
|
fallback: false,
|
||||||
channelId: "",
|
channelId: "",
|
||||||
threadId: "",
|
threadId: "",
|
||||||
filters: [] as FilterForm[],
|
filters: [] as FilterForm[],
|
||||||
|
|
@ -123,6 +128,7 @@ watch(
|
||||||
form.name = r?.name ?? "";
|
form.name = r?.name ?? "";
|
||||||
form.lang = r?.lang ?? "";
|
form.lang = r?.lang ?? "";
|
||||||
form.enabled = r?.enabled ?? true;
|
form.enabled = r?.enabled ?? true;
|
||||||
|
form.fallback = r?.fallback ?? false;
|
||||||
form.channelId = r?.target.channelId ?? "";
|
form.channelId = r?.target.channelId ?? "";
|
||||||
form.threadId = r?.target.threadId ?? "";
|
form.threadId = r?.target.threadId ?? "";
|
||||||
form.filters = (r && r.filters.length
|
form.filters = (r && r.filters.length
|
||||||
|
|
@ -158,6 +164,7 @@ function collect(): Route | null {
|
||||||
id: form.id.trim(),
|
id: form.id.trim(),
|
||||||
name: form.name.trim(),
|
name: form.name.trim(),
|
||||||
enabled: form.enabled,
|
enabled: form.enabled,
|
||||||
|
fallback: form.fallback || undefined,
|
||||||
lang: form.lang.trim() || undefined,
|
lang: form.lang.trim() || undefined,
|
||||||
filters,
|
filters,
|
||||||
target: {
|
target: {
|
||||||
|
|
|
||||||
|
|
@ -34,6 +34,7 @@ const en: Dict = {
|
||||||
"route.noFilters": "no filters",
|
"route.noFilters": "no filters",
|
||||||
"route.channel": "CHANNEL",
|
"route.channel": "CHANNEL",
|
||||||
"route.thread": "THREAD",
|
"route.thread": "THREAD",
|
||||||
|
"route.fallback": "fallback",
|
||||||
"filter.event": "Event",
|
"filter.event": "Event",
|
||||||
"filter.repo": "Repo",
|
"filter.repo": "Repo",
|
||||||
"filter.actor": "Actor",
|
"filter.actor": "Actor",
|
||||||
|
|
@ -51,6 +52,8 @@ const en: Dict = {
|
||||||
"routeEditor.langPlaceholder": "en",
|
"routeEditor.langPlaceholder": "en",
|
||||||
"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.fallbackHint": "Only fires when no other route matched",
|
||||||
"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",
|
||||||
|
|
@ -137,6 +140,7 @@ const zh: Dict = {
|
||||||
"route.noFilters": "无过滤器",
|
"route.noFilters": "无过滤器",
|
||||||
"route.channel": "频道",
|
"route.channel": "频道",
|
||||||
"route.thread": "子区",
|
"route.thread": "子区",
|
||||||
|
"route.fallback": "兜底",
|
||||||
"filter.event": "事件",
|
"filter.event": "事件",
|
||||||
"filter.repo": "仓库",
|
"filter.repo": "仓库",
|
||||||
"filter.actor": "操作者",
|
"filter.actor": "操作者",
|
||||||
|
|
@ -154,6 +158,8 @@ const zh: Dict = {
|
||||||
"routeEditor.langPlaceholder": "zh",
|
"routeEditor.langPlaceholder": "zh",
|
||||||
"routeEditor.langHint": "en 或 zh;可通过 KV i18n:<lang> 自定义",
|
"routeEditor.langHint": "en 或 zh;可通过 KV i18n:<lang> 自定义",
|
||||||
"routeEditor.enabled": "启用路由",
|
"routeEditor.enabled": "启用路由",
|
||||||
|
"routeEditor.fallback": "兜底路由",
|
||||||
|
"routeEditor.fallbackHint": "仅当没有其它路由匹配该事件时才发送",
|
||||||
"routeEditor.filters": "过滤器",
|
"routeEditor.filters": "过滤器",
|
||||||
"routeEditor.filtersNote": "(全部匹配 · AND)",
|
"routeEditor.filtersNote": "(全部匹配 · AND)",
|
||||||
"routeEditor.matchPlaceholder": "匹配值",
|
"routeEditor.matchPlaceholder": "匹配值",
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,7 @@ export interface Route {
|
||||||
};
|
};
|
||||||
lang?: string;
|
lang?: string;
|
||||||
groupId?: string;
|
groupId?: string;
|
||||||
|
fallback?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Group {
|
export interface Group {
|
||||||
|
|
|
||||||
|
|
@ -83,6 +83,9 @@ function validateRoutes(
|
||||||
if (r.lang !== undefined && typeof r.lang !== "string") {
|
if (r.lang !== undefined && typeof r.lang !== "string") {
|
||||||
return { ok: false, error: `route "${r.id}".lang must be a string` };
|
return { ok: false, error: `route "${r.id}".lang must be a string` };
|
||||||
}
|
}
|
||||||
|
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) || 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` };
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import type { Config, FormattedMessage, WebhookEvent, Env } from "./types";
|
import type { Config, FormattedMessage, WebhookEvent, Env, Route } from "./types";
|
||||||
import { formatEvent } from "./formatter";
|
import { formatEvent } from "./formatter";
|
||||||
import { matchRoute, eventOwners } from "./webhook";
|
import { matchRoute, eventOwners } from "./webhook";
|
||||||
import { log } from "./log";
|
import { log } from "./log";
|
||||||
|
|
@ -42,13 +42,20 @@ 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);
|
||||||
|
|
||||||
for (const route of config.routes) {
|
// Only routes that pass both their filters and their group's owner
|
||||||
if (!matchRoute(route, event)) continue;
|
// restriction count as "matched", so a fallback route still fires when a
|
||||||
|
// regular route was suppressed by its group.
|
||||||
if (route.groupId) {
|
const accepted = (route: Route): boolean => {
|
||||||
|
if (!route.groupId) return true;
|
||||||
const group = groupById.get(route.groupId);
|
const group = groupById.get(route.groupId);
|
||||||
if (group && !groupAcceptsOwners(group, owners)) continue;
|
return !group || groupAcceptsOwners(group, owners);
|
||||||
}
|
};
|
||||||
|
const matched = config.routes.filter((route) => matchRoute(route, event) && accepted(route));
|
||||||
|
const anyRegularMatched = matched.some((route) => !route.fallback);
|
||||||
|
|
||||||
|
for (const route of matched) {
|
||||||
|
// A fallback route only fires when no regular route matched the event.
|
||||||
|
if (route.fallback && anyRegularMatched) continue;
|
||||||
|
|
||||||
const target = route.target.threadId
|
const target = route.target.threadId
|
||||||
? `${route.target.channelId}/${route.target.threadId}`
|
? `${route.target.channelId}/${route.target.threadId}`
|
||||||
|
|
|
||||||
|
|
@ -124,7 +124,7 @@ export function formatEvent(route: Route, event: WebhookEvent, tr?: Translations
|
||||||
case "discussion_comment":
|
case "discussion_comment":
|
||||||
return formatDiscussionComment(payload, repo, author, t);
|
return formatDiscussionComment(payload, repo, author, t);
|
||||||
case "repository":
|
case "repository":
|
||||||
return formatRepository(payload, repo, author, t);
|
return formatRepository(payload, repo, repoUrl, author, t);
|
||||||
case "code_scanning_alert":
|
case "code_scanning_alert":
|
||||||
return formatCodeScanningAlert(payload, repo, author, t);
|
return formatCodeScanningAlert(payload, repo, author, t);
|
||||||
case "dependabot_alert":
|
case "dependabot_alert":
|
||||||
|
|
@ -1231,6 +1231,7 @@ function formatDiscussionComment(
|
||||||
function formatRepository(
|
function formatRepository(
|
||||||
payload: Record<string, unknown>,
|
payload: Record<string, unknown>,
|
||||||
repo: string | undefined,
|
repo: string | undefined,
|
||||||
|
repoUrl: string | undefined,
|
||||||
author: { name: string; icon_url?: string; url?: string },
|
author: { name: string; icon_url?: string; url?: string },
|
||||||
t: T,
|
t: T,
|
||||||
): FormattedMessage {
|
): FormattedMessage {
|
||||||
|
|
@ -1261,12 +1262,50 @@ function formatRepository(
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Enrich create / visibility-change notifications with a clickable link and
|
||||||
|
// basic metadata. repoUrl also makes the embed title a hyperlink for all actions.
|
||||||
|
const repoData = payload.repository as {
|
||||||
|
visibility?: string;
|
||||||
|
fork?: boolean;
|
||||||
|
description?: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const isCreateOrVisibility =
|
||||||
|
action === "created" || action === "publicized" || action === "privatized";
|
||||||
|
|
||||||
|
if (isCreateOrVisibility) {
|
||||||
|
if (repoData.visibility) {
|
||||||
|
fields.push({
|
||||||
|
name: t("events.repository.visibility"),
|
||||||
|
value: t("events.repository." + repoData.visibility) ?? repoData.visibility,
|
||||||
|
inline: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (repoData.fork) {
|
||||||
|
fields.push({
|
||||||
|
name: t("common.repository"),
|
||||||
|
value: t("events.repository.is_fork"),
|
||||||
|
inline: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const descriptionParts: string[] = [];
|
||||||
|
if (isCreateOrVisibility && repoUrl) {
|
||||||
|
descriptionParts.push(`[${t("events.repository.open")}](${repoUrl})`);
|
||||||
|
}
|
||||||
|
if (isCreateOrVisibility && repoData.description) {
|
||||||
|
descriptionParts.push(`> ${repoData.description}`);
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
embeds: [
|
embeds: [
|
||||||
{
|
{
|
||||||
author,
|
author,
|
||||||
title: t("events.repository.title", { action: al, repo: repo ?? t("common.repository") }),
|
title: t("events.repository.title", { action: al, repo: repo ?? t("common.repository") }),
|
||||||
|
url: repoUrl,
|
||||||
color: GITHUB_COLORS.repository,
|
color: GITHUB_COLORS.repository,
|
||||||
|
description: descriptionParts.length > 0 ? descriptionParts.join("\n") : undefined,
|
||||||
fields: fields.length > 0 ? fields : undefined,
|
fields: fields.length > 0 ? fields : undefined,
|
||||||
footer: { text: t("common.footer", { repo: repo ?? t("common.github") }) },
|
footer: { text: t("common.footer", { repo: repo ?? t("common.github") }) },
|
||||||
timestamp: new Date().toISOString(),
|
timestamp: new Date().toISOString(),
|
||||||
|
|
|
||||||
|
|
@ -27,6 +27,8 @@ export const en = {
|
||||||
pinned: "Pinned",
|
pinned: "Pinned",
|
||||||
unpinned: "Unpinned",
|
unpinned: "Unpinned",
|
||||||
transferred: "Transferred",
|
transferred: "Transferred",
|
||||||
|
publicized: "made public",
|
||||||
|
privatized: "made private",
|
||||||
locked: "Locked",
|
locked: "Locked",
|
||||||
unlocked: "Unlocked",
|
unlocked: "Unlocked",
|
||||||
renamed: "Renamed",
|
renamed: "Renamed",
|
||||||
|
|
@ -159,6 +161,12 @@ export const en = {
|
||||||
},
|
},
|
||||||
repository: {
|
repository: {
|
||||||
title: "📦 Repository {action}: {repo}",
|
title: "📦 Repository {action}: {repo}",
|
||||||
|
open: "🔗 Open repository",
|
||||||
|
public: "public",
|
||||||
|
private: "private",
|
||||||
|
internal: "internal",
|
||||||
|
is_fork: "This is a fork",
|
||||||
|
visibility: "Visibility",
|
||||||
},
|
},
|
||||||
code_scanning: {
|
code_scanning: {
|
||||||
title: "🔍 Code Scanning: {action}",
|
title: "🔍 Code Scanning: {action}",
|
||||||
|
|
|
||||||
|
|
@ -27,6 +27,8 @@ export const zh = {
|
||||||
pinned: "已置顶",
|
pinned: "已置顶",
|
||||||
unpinned: "已取消置顶",
|
unpinned: "已取消置顶",
|
||||||
transferred: "已转移",
|
transferred: "已转移",
|
||||||
|
publicized: "已公开",
|
||||||
|
privatized: "已设为私有",
|
||||||
locked: "已锁定",
|
locked: "已锁定",
|
||||||
unlocked: "已解锁",
|
unlocked: "已解锁",
|
||||||
renamed: "已重命名",
|
renamed: "已重命名",
|
||||||
|
|
@ -159,6 +161,12 @@ export const zh = {
|
||||||
},
|
},
|
||||||
repository: {
|
repository: {
|
||||||
title: "📦 仓库 {action}: {repo}",
|
title: "📦 仓库 {action}: {repo}",
|
||||||
|
open: "🔗 打开仓库",
|
||||||
|
public: "公开",
|
||||||
|
private: "私有",
|
||||||
|
internal: "内部",
|
||||||
|
is_fork: "这是 Fork 仓库",
|
||||||
|
visibility: "可见性",
|
||||||
},
|
},
|
||||||
code_scanning: {
|
code_scanning: {
|
||||||
title: "🔍 代码扫描: {action}",
|
title: "🔍 代码扫描: {action}",
|
||||||
|
|
|
||||||
|
|
@ -43,6 +43,12 @@ export interface Route {
|
||||||
};
|
};
|
||||||
lang?: string;
|
lang?: string;
|
||||||
groupId?: string;
|
groupId?: string;
|
||||||
|
/**
|
||||||
|
* 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.
|
||||||
|
*/
|
||||||
|
fallback?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Group {
|
export interface Group {
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue