From 7e45b0a09cb2aafe89131487728bab68195a0b08 Mon Sep 17 00:00:00 2001 From: RhenCloud Date: Wed, 12 Aug 2026 00:43:47 +0800 Subject: [PATCH] feat(groups): configure message language per group instead of per route Route.lang is removed; Group.lang now drives the message language for every route in the group (default en, custom via KV i18n:). dispatch.ts loads translations per group and falls back to en for groups without a lang. Admin UI: GroupEditor gains the language field, RouteEditor/RouteCard drop theirs; docs and config example updated. --- admin/components/GroupEditor.vue | 28 ++++++++++++--- admin/components/RouteCard.vue | 1 - admin/components/RouteEditor.vue | 22 +++--------- admin/composables/useI18n.ts | 16 +++++---- admin/types.ts | 2 +- config.example.yaml | 1 + docs/guide/configuration.md | 2 +- docs/zh/guide/configuration.md | 2 +- src/__tests__/discord.test.ts | 58 ++++++++++++++++++++++++++++++++ src/core/dispatch.ts | 10 +++--- src/types.ts | 6 +++- src/web/admin-routes.ts | 6 ++-- 12 files changed, 114 insertions(+), 40 deletions(-) diff --git a/admin/components/GroupEditor.vue b/admin/components/GroupEditor.vue index e1ce8bc..147319c 100644 --- a/admin/components/GroupEditor.vue +++ b/admin/components/GroupEditor.vue @@ -32,6 +32,27 @@
{{ t("groupEditor.idHint") }}
+
+
+ + +
{{ t("groupEditor.langHint") }}
+
+
+ + +
+
{{ t("groupEditor.providersHint") }}
-
{{ formError }}
@@ -126,6 +143,7 @@ const form = reactive({ owners: "", providers: [] as ("github" | "gitea")[], emoji: true, + lang: "", }); function splitList(text: string): string[] { @@ -154,6 +172,7 @@ watch( (p): p is "github" | "gitea" => p === "github" || p === "gitea", ); form.emoji = g?.emoji ?? true; + form.lang = g?.lang ?? ""; formError.value = ""; }, ); @@ -188,6 +207,7 @@ function save(): void { owners: props.superAdmin ? (owners.length ? owners : undefined) : props.group?.owners, providers: form.providers.length ? form.providers : undefined, emoji: form.emoji, + lang: form.lang.trim() || undefined, }); } diff --git a/admin/components/RouteCard.vue b/admin/components/RouteCard.vue index 068b313..ec1306d 100644 --- a/admin/components/RouteCard.vue +++ b/admin/components/RouteCard.vue @@ -9,7 +9,6 @@ {{ route.name || t("route.untitled") }} {{ route.id }} - {{ route.lang }}
-
-
- - -
{{ t("routeEditor.idHint") }}
-
-
- - -
{{ t("routeEditor.langHint") }}
-
+
+ + +
{{ t("routeEditor.idHint") }}
@@ -211,7 +200,6 @@ function blankTarget(): TargetForm { const form = reactive({ id: "", name: "", - lang: "", enabled: true, fallback: false, stop: false, @@ -263,7 +251,6 @@ watch( const r = props.route; form.id = r?.id ?? ""; form.name = r?.name ?? ""; - form.lang = r?.lang ?? ""; form.enabled = r?.enabled ?? true; form.fallback = r?.fallback ?? false; form.stop = r?.stop ?? false; @@ -340,7 +327,6 @@ function collect(): Route | null { enabled: form.enabled, fallback: form.fallback || undefined, stop: form.stop || undefined, - lang: form.lang.trim() || undefined, discordRoleIds: discordRoles.length ? discordRoles : undefined, filters, targets, diff --git a/admin/composables/useI18n.ts b/admin/composables/useI18n.ts index 56b080d..623fae1 100644 --- a/admin/composables/useI18n.ts +++ b/admin/composables/useI18n.ts @@ -99,9 +99,6 @@ const en: Dict = { "routeEditor.namePlaceholder": "My Route", "routeEditor.id": "ID", "routeEditor.idHint": "Unique, use a-z / 0-9 / dashes", - "routeEditor.language": "Language", - "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; filters still apply", @@ -148,6 +145,11 @@ const en: Dict = { "groupEditor.id": "ID", "groupEditor.idPlaceholder": "my-team", "groupEditor.idHint": "Unique, use a-z / 0-9 / dashes", + "groupEditor.language": "Message language", + "groupEditor.langPlaceholder": "en", + "groupEditor.langHint": "en or zh; custom via KV i18n:", + "groupEditor.emojiNote": "(default on)", + "groupEditor.emojiLabel": "Show emojis in messages", "groupEditor.admins": "Group admins", "groupEditor.adminsNote": "(GitHub login or user id, comma-separated)", "groupEditor.adminsPlaceholder": "octocat, 12345", @@ -314,9 +316,6 @@ const zh: Dict = { "routeEditor.namePlaceholder": "我的路由", "routeEditor.id": "ID", "routeEditor.idHint": "唯一,使用 a-z / 0-9 / 短横线", - "routeEditor.language": "语言", - "routeEditor.langPlaceholder": "zh", - "routeEditor.langHint": "en 或 zh;可通过 KV i18n: 自定义", "routeEditor.enabled": "启用路由", "routeEditor.fallback": "兜底路由", "routeEditor.fallbackHint": "仅当没有其它路由匹配该事件时才发送,其过滤器仍会生效", @@ -362,6 +361,11 @@ const zh: Dict = { "groupEditor.id": "ID", "groupEditor.idPlaceholder": "my-team", "groupEditor.idHint": "唯一,使用 a-z / 0-9 / 短横线", + "groupEditor.language": "消息语言", + "groupEditor.langPlaceholder": "zh", + "groupEditor.langHint": "en 或 zh;可通过 KV i18n: 自定义", + "groupEditor.emojiNote": "(默认开启)", + "groupEditor.emojiLabel": "消息中显示表情符号", "groupEditor.admins": "分组管理员", "groupEditor.adminsNote": "(GitHub 登录名或用户 ID,逗号分隔)", "groupEditor.adminsPlaceholder": "octocat, 12345", diff --git a/admin/types.ts b/admin/types.ts index 26e0f84..f2d1522 100644 --- a/admin/types.ts +++ b/admin/types.ts @@ -18,7 +18,6 @@ export interface Route { enabled: boolean; filters: Filter[]; targets: RouteTarget[]; - lang?: string; groupId?: string; fallback?: boolean; stop?: boolean; @@ -40,6 +39,7 @@ export interface Group { owners?: string[]; providers?: ("github" | "gitea" | "gitlab")[]; emoji?: boolean; + lang?: string; } export interface GroupInvite { diff --git a/config.example.yaml b/config.example.yaml index a9b91a3..b8a8e2e 100644 --- a/config.example.yaml +++ b/config.example.yaml @@ -13,6 +13,7 @@ groups: # owners: ["myorg"] # restrict events to this org/user (optional) # providers: ["github", "gitea"] # restrict to source platforms (optional; empty = all) # emoji: true # include emoji in messages (default true) + # lang: "en" # message language for this group's routes (en/zh, default en) routes: - id: all-push diff --git a/docs/guide/configuration.md b/docs/guide/configuration.md index 9cf3e65..8f0b1e8 100644 --- a/docs/guide/configuration.md +++ b/docs/guide/configuration.md @@ -138,7 +138,6 @@ Other route fields: | `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 | | `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 | | `discordRoleIds` | string[] | No | Discord role ids to ping when this route fires; applied to Discord targets only | ### Custom Route Example @@ -195,6 +194,7 @@ Routes belong to groups. Groups scope admin access and can restrict which events | `owners` | string[] | No | Org/user logins whose events are accepted into this group; empty = all | | `providers` | string[] | No | Source platforms allowed into this group (`github`, `gitea`); empty = all | | `emoji` | boolean | No | Whether to include emoji in this group's messages (default `true`) | +| `lang` | string | No | Message language for every route in this group (e.g. `en`, `zh`; custom via KV `i18n:`) — defaults to `en` | ### Roles diff --git a/docs/zh/guide/configuration.md b/docs/zh/guide/configuration.md index ef6d38f..d66655a 100644 --- a/docs/zh/guide/configuration.md +++ b/docs/zh/guide/configuration.md @@ -138,7 +138,6 @@ WebHooker 内置了位于 `/admin` 的配置控制台,可在浏览器中管理 | `groupId` | string | 是 | 该路由所属[分组](#分组)的 id | | `fallback` | boolean | 否 | 为 `true` 时,仅当没有其它路由匹配该事件时才发送,其自身过滤器会被忽略 | | `stop` | boolean | 否 | 为 `true` 且该路由匹配时,停止评估后续路由 | -| `lang` | string | 否 | 该路由的消息语言覆盖(如 `en`、`zh`),默认跟随全局设置 | | `discordRoleIds` | string[] | 否 | 该路由触发时要在 Discord 目标中 @提醒的身份组 id | ### 自定义路由示例 @@ -195,6 +194,7 @@ WebHooker 内置了位于 `/admin` 的配置控制台,可在浏览器中管理 | `owners` | string[] | 否 | 允许事件进入该分组的组织/用户登录名;为空表示不限制 | | `providers` | string[] | 否 | 允许进入该分组的来源平台(`github`、`gitea`);为空表示全部 | | `emoji` | boolean | 否 | 是否在该分组消息中显示 emoji(默认 `true`) | +| `lang` | string | 否 | 该分组所有路由的消息语言(如 `en`、`zh`;可通过 KV `i18n:` 自定义)——默认 `en` | ### 角色 diff --git a/src/__tests__/discord.test.ts b/src/__tests__/discord.test.ts index 897770b..d90e823 100644 --- a/src/__tests__/discord.test.ts +++ b/src/__tests__/discord.test.ts @@ -259,4 +259,62 @@ describe("dispatchEvent fallback routing", () => { expect(sent.filter((u) => u.includes("/222/"))).toHaveLength(1); expect(sent.filter((u) => u.includes("/111/"))).toHaveLength(0); }); + + it("uses the group's message language (not the route's)", async () => { + const bodies: string[] = []; + mockFetch((url, init) => { + bodies.push(String(init?.body ?? "")); + return new Response("{}", { status: 200 }); + }); + const kv = createMockKV(); + await kv.put( + "config:groups", + JSON.stringify([ + { id: "zh-group", name: "中文组", adminIds: [], lang: "zh" }, + { id: "en-group", name: "English", adminIds: [] }, + ]), + ); + const env = createEnv({ KV: kv, DB: createMockDB() }); + const routes: Route[] = [ + { + id: "zh-push", + name: "ZH", + enabled: true, + groupId: "zh-group", + filters: [{ type: "event", match: "push" }], + targets: [{ channelId: "111" }], + }, + { + id: "en-push", + name: "EN", + enabled: true, + groupId: "en-group", + filters: [{ type: "event", match: "push" }], + targets: [{ channelId: "222" }], + }, + ]; + + await dispatchEvent( + { ...baseConfig, routes }, + { + event: "push", + payload: { + repository: { full_name: "owner/repo" }, + commits: [{ id: "abc", message: "fix", author: { name: "a" } }], + ref: "refs/heads/main", + compare: "https://example.com/compare", + }, + }, + env, + ); + + expect(bodies).toHaveLength(2); + const titles = bodies.map((b) => { + const parsed = JSON.parse(b) as { embeds?: Array<{ title?: string }> }; + return parsed.embeds?.[0]?.title ?? ""; + }); + expect(titles.sort()).toEqual( + ["owner/repo: 推送了 1 个提交", "owner/repo: Pushed 1 commit"].sort(), + ); + }); }); diff --git a/src/core/dispatch.ts b/src/core/dispatch.ts index 1aa14ae..85600dd 100644 --- a/src/core/dispatch.ts +++ b/src/core/dispatch.ts @@ -9,7 +9,11 @@ import { getDriver } from "../drivers"; import type { SendResult } from "../drivers/types"; export async function dispatchEvent(config: Config, event: WebhookEvent, env: Env): Promise { - const langs = [...new Set(config.routes.map((r) => r.lang ?? "en"))]; + const groups = await loadGroups(env.KV); + const groupById = new Map(groups.map((g) => [g.id, g])); + + // Message language is configured per group (Group.lang), not per route. + const langs = [...new Set(groups.map((g) => g.lang ?? "en"))]; const trMap = new Map(); await Promise.all( langs.map(async (lang) => { @@ -17,8 +21,6 @@ export async function dispatchEvent(config: Config, event: WebhookEvent, env: En }), ); - const groups = await loadGroups(env.KV); - const groupById = new Map(groups.map((g) => [g.id, g])); const owners = eventOwners(event); const accepted = (route: Route): boolean => { @@ -53,8 +55,8 @@ export async function dispatchEvent(config: Config, event: WebhookEvent, env: En 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 tr = trMap.get(group?.lang ?? "en")!; const showEmoji = group?.emoji !== false; const message = formatEvent(route, event, tr, showEmoji); if (route.discordRoleIds?.length) { diff --git a/src/types.ts b/src/types.ts index 57fd5bf..3c17a8f 100644 --- a/src/types.ts +++ b/src/types.ts @@ -58,7 +58,6 @@ export interface Route { enabled: boolean; filters: Filter[]; targets: RouteTarget[]; - lang?: string; groupId?: string; /** * Fallback route: only fires when no other (non-fallback) route matched the @@ -120,6 +119,11 @@ export interface Group { * Defaults to true when omitted. */ emoji?: boolean; + /** + * Message language for every route in this group (e.g. "en", "zh"; custom + * via KV i18n:). Defaults to "en" when omitted. + */ + lang?: string; } export interface Filter { diff --git a/src/web/admin-routes.ts b/src/web/admin-routes.ts index 8dc0ec7..f793117 100644 --- a/src/web/admin-routes.ts +++ b/src/web/admin-routes.ts @@ -87,9 +87,6 @@ function validateRoutes( } if (typeof r.enabled !== "boolean") return { ok: false, error: `route "${r.id}".enabled must be boolean` }; - 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` }; } @@ -283,6 +280,9 @@ function validateGroups( if (g.emoji !== undefined && typeof g.emoji !== "boolean") { return { ok: false, error: `group "${g.id}".emoji must be a boolean` }; } + if (g.lang !== undefined && typeof g.lang !== "string") { + return { ok: false, error: `group "${g.id}".lang must be a string` }; + } } return { ok: true, groups: groups as Group[] }; }