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("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[] };
}