From d419b9c940ed7ab82ed5e16f728bc2aab4c956dd Mon Sep 17 00:00:00 2001 From: RhenCloud Date: Sun, 2 Aug 2026 23:23:07 +0800 Subject: [PATCH] feat: add fallback routes and repository event enrichment --- admin/assets/css/main.css | 10 ++++++++ admin/components/RouteCard.vue | 1 + admin/components/RouteEditor.vue | 7 ++++++ admin/composables/useI18n.ts | 6 +++++ admin/types.ts | 1 + src/admin-routes.ts | 3 +++ src/discord.ts | 21 ++++++++++------ src/formatter.ts | 41 +++++++++++++++++++++++++++++++- src/locales/en.ts | 8 +++++++ src/locales/zh.ts | 8 +++++++ src/types.ts | 6 +++++ 11 files changed, 104 insertions(+), 8 deletions(-) diff --git a/admin/assets/css/main.css b/admin/assets/css/main.css index 51a816e..1fefcb3 100644 --- a/admin/assets/css/main.css +++ b/admin/assets/css/main.css @@ -14,6 +14,8 @@ --accent-text: #3730a3; --ok: #16a34a; --ok-dim: #ecfdf3; + --warn: #b45309; + --warn-dim: #fef3c7; --bad: #dc2626; --bad-dim: #fef2f2; --dot: rgba(15, 23, 42, 0.05); @@ -44,6 +46,8 @@ --accent-text: #c7d2fe; --ok: #22c55e; --ok-dim: #10241a; + --warn: #fbbf24; + --warn-dim: #2a2410; --bad: #f05252; --bad-dim: #2a1618; --dot: rgba(148, 163, 184, 0.08); @@ -345,6 +349,12 @@ main { border: 1px solid transparent; } +.badge.fallback { + background: var(--warn-dim); + color: var(--warn); + border: 1px solid transparent; +} + .card-actions { display: flex; gap: 6px; diff --git a/admin/components/RouteCard.vue b/admin/components/RouteCard.vue index 3c74c32..1f0b75c 100644 --- a/admin/components/RouteCard.vue +++ b/admin/components/RouteCard.vue @@ -9,6 +9,7 @@ {{ route.name || t("route.untitled") }} {{ route.id }} {{ route.lang }} + {{ t("route.fallback") }}
diff --git a/admin/components/RouteEditor.vue b/admin/components/RouteEditor.vue index d8b0734..d94eefd 100644 --- a/admin/components/RouteEditor.vue +++ b/admin/components/RouteEditor.vue @@ -30,6 +30,10 @@ {{ t("routeEditor.enabled") }}
+
+ + {{ t("routeEditor.fallback") }} {{ t("routeEditor.fallbackHint") }} +
@@ -91,6 +95,7 @@ const form = reactive({ name: "", lang: "", enabled: true, + fallback: false, channelId: "", threadId: "", filters: [] as FilterForm[], @@ -123,6 +128,7 @@ watch( form.name = r?.name ?? ""; form.lang = r?.lang ?? ""; form.enabled = r?.enabled ?? true; + form.fallback = r?.fallback ?? false; form.channelId = r?.target.channelId ?? ""; form.threadId = r?.target.threadId ?? ""; form.filters = (r && r.filters.length @@ -158,6 +164,7 @@ function collect(): Route | null { id: form.id.trim(), name: form.name.trim(), enabled: form.enabled, + fallback: form.fallback || undefined, lang: form.lang.trim() || undefined, filters, target: { diff --git a/admin/composables/useI18n.ts b/admin/composables/useI18n.ts index 7c83320..882accb 100644 --- a/admin/composables/useI18n.ts +++ b/admin/composables/useI18n.ts @@ -34,6 +34,7 @@ const en: Dict = { "route.noFilters": "no filters", "route.channel": "CHANNEL", "route.thread": "THREAD", + "route.fallback": "fallback", "filter.event": "Event", "filter.repo": "Repo", "filter.actor": "Actor", @@ -51,6 +52,8 @@ const en: Dict = { "routeEditor.langPlaceholder": "en", "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.filters": "Filters", "routeEditor.filtersNote": "(all must match · AND)", "routeEditor.matchPlaceholder": "match value", @@ -137,6 +140,7 @@ const zh: Dict = { "route.noFilters": "无过滤器", "route.channel": "频道", "route.thread": "子区", + "route.fallback": "兜底", "filter.event": "事件", "filter.repo": "仓库", "filter.actor": "操作者", @@ -154,6 +158,8 @@ const zh: Dict = { "routeEditor.langPlaceholder": "zh", "routeEditor.langHint": "en 或 zh;可通过 KV i18n: 自定义", "routeEditor.enabled": "启用路由", + "routeEditor.fallback": "兜底路由", + "routeEditor.fallbackHint": "仅当没有其它路由匹配该事件时才发送", "routeEditor.filters": "过滤器", "routeEditor.filtersNote": "(全部匹配 · AND)", "routeEditor.matchPlaceholder": "匹配值", diff --git a/admin/types.ts b/admin/types.ts index 9daa259..bd1c90c 100644 --- a/admin/types.ts +++ b/admin/types.ts @@ -15,6 +15,7 @@ export interface Route { }; lang?: string; groupId?: string; + fallback?: boolean; } export interface Group { diff --git a/src/admin-routes.ts b/src/admin-routes.ts index 2baa6c6..b0fd220 100644 --- a/src/admin-routes.ts +++ b/src/admin-routes.ts @@ -83,6 +83,9 @@ function validateRoutes( if (r.lang !== undefined && typeof r.lang !== "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) { return { ok: false, error: `route "${r.id}" needs at least one filter` }; } diff --git a/src/discord.ts b/src/discord.ts index 09f3ea8..d11432f 100644 --- a/src/discord.ts +++ b/src/discord.ts @@ -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 { matchRoute, eventOwners } from "./webhook"; 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 owners = eventOwners(event); - for (const route of config.routes) { - if (!matchRoute(route, event)) continue; + // 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. + 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); - if (route.groupId) { - const group = groupById.get(route.groupId); - if (group && !groupAcceptsOwners(group, owners)) continue; - } + 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 ? `${route.target.channelId}/${route.target.threadId}` diff --git a/src/formatter.ts b/src/formatter.ts index 5973b5f..ee3f47f 100644 --- a/src/formatter.ts +++ b/src/formatter.ts @@ -124,7 +124,7 @@ export function formatEvent(route: Route, event: WebhookEvent, tr?: Translations case "discussion_comment": return formatDiscussionComment(payload, repo, author, t); case "repository": - return formatRepository(payload, repo, author, t); + return formatRepository(payload, repo, repoUrl, author, t); case "code_scanning_alert": return formatCodeScanningAlert(payload, repo, author, t); case "dependabot_alert": @@ -1231,6 +1231,7 @@ function formatDiscussionComment( function formatRepository( payload: Record, repo: string | undefined, + repoUrl: string | undefined, author: { name: string; icon_url?: string; url?: string }, t: T, ): 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 { embeds: [ { author, title: t("events.repository.title", { action: al, repo: repo ?? t("common.repository") }), + url: repoUrl, color: GITHUB_COLORS.repository, + description: descriptionParts.length > 0 ? descriptionParts.join("\n") : undefined, fields: fields.length > 0 ? fields : undefined, footer: { text: t("common.footer", { repo: repo ?? t("common.github") }) }, timestamp: new Date().toISOString(), diff --git a/src/locales/en.ts b/src/locales/en.ts index 696262c..69f0a0d 100644 --- a/src/locales/en.ts +++ b/src/locales/en.ts @@ -27,6 +27,8 @@ export const en = { pinned: "Pinned", unpinned: "Unpinned", transferred: "Transferred", + publicized: "made public", + privatized: "made private", locked: "Locked", unlocked: "Unlocked", renamed: "Renamed", @@ -159,6 +161,12 @@ export const en = { }, repository: { title: "📦 Repository {action}: {repo}", + open: "🔗 Open repository", + public: "public", + private: "private", + internal: "internal", + is_fork: "This is a fork", + visibility: "Visibility", }, code_scanning: { title: "🔍 Code Scanning: {action}", diff --git a/src/locales/zh.ts b/src/locales/zh.ts index 42e54f0..95af9dd 100644 --- a/src/locales/zh.ts +++ b/src/locales/zh.ts @@ -27,6 +27,8 @@ export const zh = { pinned: "已置顶", unpinned: "已取消置顶", transferred: "已转移", + publicized: "已公开", + privatized: "已设为私有", locked: "已锁定", unlocked: "已解锁", renamed: "已重命名", @@ -159,6 +161,12 @@ export const zh = { }, repository: { title: "📦 仓库 {action}: {repo}", + open: "🔗 打开仓库", + public: "公开", + private: "私有", + internal: "内部", + is_fork: "这是 Fork 仓库", + visibility: "可见性", }, code_scanning: { title: "🔍 代码扫描: {action}", diff --git a/src/types.ts b/src/types.ts index 49608c4..fc001b1 100644 --- a/src/types.ts +++ b/src/types.ts @@ -43,6 +43,12 @@ export interface Route { }; lang?: 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 {