diff --git a/AGENTS.md b/AGENTS.md
index f133059..9a8a103 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -23,7 +23,7 @@ Core pipeline: GitHub Webhook → Worker (verify + filter + format) → Discord
- Invites: single-use 7-day links (`invite:{token}`) for joining a group as admin/viewer; `ALLOW_SELF_SIGNUP=1` gives access-less users a personal group on first login (self-service SaaS entry)
- Audit log: every admin operation (logins, group/route/member/invite changes) recorded in D1 `audit_logs`; pruned by the scheduled trigger after `AUDIT_RETENTION_DAYS` (default 90)
- Group webhook log channel: optional `Group.logTarget` (Discord channel/thread or Telegram chat/topic) receives one summary message per webhook the group's routes dispatched (event, repo, delivery id, per route×target ✅/❌ outcome, green/red color); best-effort, not recorded in `send_logs`
-- Per-group forge branding: optional `Group.forgeLabel` (default off) renders the source forge in the message footer — GitHub brand + favicon, Gitea instance hostname + favicon (from the repo URL origin), or plain `Custom` for custom webhooks; Discord shows the footer icon + name, Telegram a linked name
+- Per-group forge branding: optional `Group.forgeSources` (a list of `{ host, type: "github" | "gitea", name? }` the group defines itself) labels each message's footer with the first entry whose type matches the event's provider and whose host matches the repository URL's hostname (GitHub matches `github.com`, so two Gitea instances can be `git1.example.com`/`git2.example.com`); the label is the entry's optional `name` (fallback: host); links/favicons are derived from the repo URL; Discord shows the footer icon + name, Telegram a linked name
- Local dev: wrangler + Miniflare
## Architecture
@@ -125,7 +125,7 @@ tests/ # bun test unit tests (webhook, formatter, discord, tel
- Record every admin operation (login/logout, group/route/member/invite changes) to D1 `audit_logs`; the scheduled trigger prunes entries past `AUDIT_RETENTION_DAYS`
- Mention Discord roles on route trigger: route-level `discordRoleIds` are rendered as `<@&id>` into the Discord message `content` (Telegram targets ignore the field)
- Format 28 GitHub/Gitea event types plus `custom` webhooks as platform-neutral messages (Discord embeds + Telegram HTML)
-- Show the forge source (GitHub / Gitea instance / custom) in the message footer when the group enables `Group.forgeLabel`
+- Show the forge source (named per `Group.forgeSources` host entries) in the message footer when the group defines a host matching the event's repository
- Route messages to Discord channels/threads and Telegram chats/topics via REST
- Edit already-sent messages in place for `workflow_run` / `check_run` progress (stable `updateKey`, KV `msg:*` tracking)
- Record every dispatch attempt to D1 `send_logs` (route id, event, target, ok/error, duration, error code)
diff --git a/app/assets/css/main.css b/app/assets/css/main.css
index 897a8fe..f806797 100644
--- a/app/assets/css/main.css
+++ b/app/assets/css/main.css
@@ -478,6 +478,32 @@
@apply mt-1 w-full justify-center;
}
+ .forge-source-row {
+ @apply mb-2 grid items-center gap-2 rounded-sm border border-border bg-surface-2 p-2.5;
+ grid-template-areas:
+ "host type del"
+ "name name name";
+ grid-template-columns: 1fr auto auto;
+ }
+
+ .forge-source-row .f-host {
+ grid-area: host;
+ }
+
+ .forge-source-row .select.forge-type {
+ grid-area: type;
+ @apply w-[110px];
+ }
+
+ .forge-source-row .f-name {
+ grid-area: name;
+ }
+
+ .forge-source-row > .icon-btn {
+ grid-area: del;
+ justify-self: end;
+ }
+
/* ---- Toasts ---- */
.toasts {
@apply pointer-events-none fixed bottom-7 left-1/2 z-[60] flex -translate-x-1/2 flex-col items-center gap-2;
diff --git a/app/components/GroupEditor.vue b/app/components/GroupEditor.vue
index 4025073..c59bcf0 100644
--- a/app/components/GroupEditor.vue
+++ b/app/components/GroupEditor.vue
@@ -46,27 +46,47 @@
/>
{{ t("groupEditor.langHint") }}
-
- {{ t("groupEditor.emoji") }}
- {{ t("groupEditor.emojiNote") }}
-
-
- {{ t("groupEditor.emojiLabel") }}
-
-
-
-
{{ t("groupEditor.forgeLabel") }}
- {{ t("groupEditor.forgeLabelNote") }}
-
-
- {{ t("groupEditor.forgeLabelLabel") }}
-
-
{{ t("groupEditor.forgeLabelHint") }}
+
+ {{ t("groupEditor.emoji") }}
+ {{ t("groupEditor.emojiNote") }}
+
+
+ {{ t("groupEditor.emojiLabel") }}
+
+
+
+
{{ t("groupEditor.forgeSources") }}
+ {{ t("groupEditor.forgeSourcesNote") }}
+
+
+
+ GitHub
+ Gitea
+
+
+ ✕
+
+
+
+ {{ t("groupEditor.forgeSourcesAdd") }}
+
+
{{ t("groupEditor.forgeSourcesHint") }}
+
import { reactive, watch } from "vue";
-import type { Group } from "~/types";
+import type { ForgeSource, Group } from "~/types";
const { t } = useI18n();
@@ -219,7 +239,7 @@ const form = reactive({
providers: [] as ("github" | "gitea")[],
installationId: "",
emoji: true,
- forgeLabel: false,
+ forgeSources: [] as ForgeSource[],
lang: "",
logPlatform: "" as "" | "discord" | "telegram",
logChannelId: "",
@@ -228,6 +248,9 @@ const form = reactive({
logTopicId: "",
});
+function addForgeSource(): void {
+ form.forgeSources.push({ host: "", type: "github" });
+}
function toggleProvider(p: "github" | "gitea", e: Event): void {
const checked = (e.target as HTMLInputElement).checked;
form.providers = checked
@@ -249,7 +272,7 @@ watch(
);
form.installationId = g?.installationId != null ? String(g.installationId) : "";
form.emoji = g?.emoji ?? true;
- form.forgeLabel = g?.forgeLabel ?? false;
+ form.forgeSources = (g?.forgeSources ?? []).map((s) => ({ ...s }));
form.lang = g?.lang ?? "";
form.logPlatform = lt?.platform ?? "";
form.logChannelId = lt?.channelId ?? "";
@@ -308,6 +331,12 @@ function save(): void {
return;
}
}
+ for (const s of form.forgeSources) {
+ if (!/^[a-z0-9]([a-z0-9-]*[a-z0-9])?(\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)*$/i.test(s.host.trim())) {
+ formError.value = t("groupEditor.errForgeSourceHost");
+ return;
+ }
+ }
const owners = splitList(form.owners);
const members = props.group?.members
? props.group.members.map((m) => ({ ...m }))
@@ -323,7 +352,13 @@ function save(): void {
providers: form.providers.length ? form.providers : undefined,
installationId,
emoji: form.emoji,
- forgeLabel: form.forgeLabel,
+ forgeSources: form.forgeSources.length
+ ? form.forgeSources.map((s) => ({
+ host: s.host.trim(),
+ type: s.type,
+ ...(s.name?.trim() ? { name: s.name.trim() } : {}),
+ }))
+ : undefined,
lang: form.lang.trim() || undefined,
logTarget,
});
diff --git a/app/composables/useApi.ts b/app/composables/useApi.ts
index d8b0e77..17bf707 100644
--- a/app/composables/useApi.ts
+++ b/app/composables/useApi.ts
@@ -16,7 +16,15 @@ export async function apiFetch(path: string, init?: RequestInit): Promise
const res = await fetch(path, {
credentials: "same-origin",
...init,
- headers: { accept: "application/json", ...(init?.headers ?? {}) },
+ // JSON in/out: h3's readBody only parses JSON bodies when the request
+ // declares application/json, and the browser defaults string bodies to
+ // text/plain — without this header every PUT/POST would arrive as a
+ // raw string and fail validation.
+ headers: {
+ accept: "application/json",
+ "content-type": "application/json",
+ ...(init?.headers ?? {}),
+ },
});
if (res.status === 401) {
useAuthState().needLogin.value = true;
diff --git a/app/composables/useI18n.ts b/app/composables/useI18n.ts
index 2431c47..dae4975 100644
--- a/app/composables/useI18n.ts
+++ b/app/composables/useI18n.ts
@@ -198,11 +198,14 @@ const en: Dict = {
"Bind this group to one GitHub App installation (the org/user that installed the app). Only events from that installation enter this group's routes; leave empty to allow any installation (still filtered by Owner scope).",
"groupEditor.errInstallationId": "Installation ID must be a positive integer",
"groupEditor.emoji": "Show emojis in messages",
- "groupEditor.forgeLabel": "Show forge source",
- "groupEditor.forgeLabelNote": "(optional)",
- "groupEditor.forgeLabelLabel": "Show which forge (GitHub / Gitea / custom) produced each event",
- "groupEditor.forgeLabelHint":
- "Adds the forge icon and site name to the message footer so events from different forges can be told apart.",
+ "groupEditor.forgeSources": "Forge hosts",
+ "groupEditor.forgeSourcesNote": "(optional — label events by their host)",
+ "groupEditor.forgeSourcesHost": "git.example.com",
+ "groupEditor.forgeSourcesName": "Optional display name",
+ "groupEditor.forgeSourcesAdd": "+ Add host",
+ "groupEditor.forgeSourcesHint":
+ "Events whose repository host matches an entry are labeled in the message footer with its display name (or the host when none is set) — e.g. git1.example.com and git2.example.com for two Gitea instances. GitHub events match github.com.",
+ "groupEditor.errForgeSourceHost": "Every forge host must be a valid hostname",
"groupEditor.logTarget": "Webhook log channel",
"groupEditor.logTargetNote": "(optional)",
"groupEditor.logDisabled": "— Disabled —",
@@ -453,11 +456,14 @@ const zh: Dict = {
"将本分组绑定到一个 GitHub App 安装(安装了 App 的组织/用户)。只有来自该安装的事件才会进入本分组的路由;留空表示接受任意安装(仍受来源限定过滤)。",
"groupEditor.errInstallationId": "安装 ID 必须为正整数",
"groupEditor.emoji": "消息中显示表情符号",
- "groupEditor.forgeLabel": "显示来源平台",
- "groupEditor.forgeLabelNote": "(可选)",
- "groupEditor.forgeLabelLabel": "在消息中标识事件来源(GitHub / Gitea / 自定义)",
- "groupEditor.forgeLabelHint":
- "在消息底部显示来源平台的图标与站点名称,便于区分不同 forge 的事件。",
+ "groupEditor.forgeSources": "来源主机",
+ "groupEditor.forgeSourcesNote": "(可选 —— 按主机标识事件)",
+ "groupEditor.forgeSourcesHost": "git.example.com",
+ "groupEditor.forgeSourcesName": "可选显示名称",
+ "groupEditor.forgeSourcesAdd": "+ 添加主机",
+ "groupEditor.forgeSourcesHint":
+ "仓库主机与条目匹配的事件,会在消息底部用该条目的显示名称标注(未填名称则用主机名)——例如两个 Gitea 实例可分别填 git1.example.com 和 git2.example.com。GitHub 事件匹配 github.com。",
+ "groupEditor.errForgeSourceHost": "每个来源主机必须是有效的主机名",
"groupEditor.logTarget": "Webhook 日志频道",
"groupEditor.logTargetNote": "(可选)",
"groupEditor.logDisabled": "— 未启用 —",
diff --git a/app/types.ts b/app/types.ts
index 0825101..042aeff 100644
--- a/app/types.ts
+++ b/app/types.ts
@@ -26,6 +26,12 @@ export interface Route {
export type GroupRole = "owner" | "admin" | "viewer";
+export interface ForgeSource {
+ host: string;
+ type: "github" | "gitea";
+ name?: string;
+}
+
export interface GroupMember {
login: string;
role: GroupRole;
@@ -40,7 +46,7 @@ export interface Group {
providers?: ("github" | "gitea" | "gitlab")[];
installationId?: number;
emoji?: boolean;
- forgeLabel?: boolean;
+ forgeSources?: ForgeSource[];
lang?: string;
logTarget?: RouteTarget;
}
diff --git a/config.example.yaml b/config.example.yaml
index a33488c..828058d 100644
--- a/config.example.yaml
+++ b/config.example.yaml
@@ -14,7 +14,16 @@ groups:
# providers: ["github", "gitea"] # restrict to source platforms (optional; empty = all)
# installationId: 12345678 # GitHub App installation binding (auto-created on installation.created)
# emoji: true # include emoji in messages (default true)
- # forgeLabel: false # show the forge source (GitHub / Gitea / custom) in message footers (default false)
+ # Forge hosts: label message footers with the host an event came from
+ # (optional `name` overrides the host as the display label).
+ # forgeSources:
+ # - host: "github.com" # GitHub events match github.com
+ # type: github
+ # name: "GitHub 主站" # optional display label
+ # - host: "git1.example.com" # a self-hosted Gitea instance
+ # type: gitea
+ # - host: "git2.example.com" # another one
+ # type: gitea
# lang: "en" # message language for this group's routes (en/zh, default en)
# Webhook log channel: a Discord channel/thread or Telegram chat/topic that
# receives a summary of every webhook this group's routes dispatch.
diff --git a/docs/guide/groups.md b/docs/guide/groups.md
index 5804366..78d5e5e 100644
--- a/docs/guide/groups.md
+++ b/docs/guide/groups.md
@@ -30,7 +30,7 @@ Routes belong to groups. Groups scope admin access and can restrict which events
| `providers` | string[] | No | Source platforms allowed into this group (`github`, `gitea`); empty = all |
| `installationId` | number | No | GitHub App installation id bound to this group; only that installation's events are accepted (empty = all) |
| `emoji` | boolean | No | Whether to include emoji in this group's messages (default `true`) |
-| `forgeLabel` | boolean | No | Whether to show the forge source (GitHub / Gitea instance / custom) in the footer of this group's messages (default `false`) |
+| `forgeSources` | object[] | No | Forge hosts: `{ host, type, name? }` entries (`type` is `github` or `gitea`, `name` is an optional display label) that label this group's message footers; empty = no label |
| `lang` | string | No | Message language for every route in this group (e.g. `en`, `zh`; custom via KV `i18n:`) — see [Message Language](./i18n) — defaults to `en` |
| `logTarget` | object | No | Webhook log channel: a Discord `{ platform, channelId, threadId? }` or Telegram `{ platform, chatId, topicId? }` target that receives a summary of every webhook the group's routes dispatch |
@@ -59,11 +59,19 @@ A group may set `logTarget` to a Discord channel/thread or Telegram chat/topic.
## Forge Source Label
-With `forgeLabel: true`, every message this group's routes send carries the source forge in its footer, so events from GitHub, a self-hosted Gitea instance and custom webhooks are easy to tell apart when they share a channel:
+A group defines the forges it receives events from via `forgeSources`, a list of `{ host, type, name? }` entries (`type` is `github` or `gitea`). Every message this group's routes send carries the footer label of the **first entry whose type matches the event's provider and whose host matches the repository URL's hostname** (GitHub events match `github.com`). The label is the entry's optional `name` — falling back to the host — so two self-hosted Gitea instances can be shown as "内网 Gitea" / "Git2 仓库" while matched by their distinct hosts:
-- **Discord** — the embed footer shows the forge name next to the repo (`GitHub · acme/widget`) with the site's favicon as the footer icon (for Gitea the instance's own favicon is fetched from its origin).
-- **Telegram** — the footer line starts with the hyperlinked site name (`[GitHub](https://github.com)` or the Gitea instance hostname).
-- **Custom** webhooks are labeled `Custom` without a link.
+```json
+{ "forgeSources": [
+ { "host": "github.com", "type": "github", "name": "GitHub 主站" },
+ { "host": "git1.example.com", "type": "gitea", "name": "内网 Gitea" },
+ { "host": "git2.example.com", "type": "gitea" }
+] }
+```
+
+- **Discord** — the embed footer shows the label next to the repo (`内网 Gitea · acme/widget`) with the site's favicon as the footer icon (derived from the repository URL).
+- **Telegram** — the footer line starts with the hyperlinked label (`[内网 Gitea](https://git1.example.com)`).
+- Events whose repository host has no matching entry (e.g. custom webhooks, or a repo hosted elsewhere) get no label.
The label is independent of `Group.emoji` and follows every message that group dispatches (including in-place edits of workflow/check messages).
diff --git a/docs/guide/message-format.md b/docs/guide/message-format.md
index 63056e8..9bd79f3 100644
--- a/docs/guide/message-format.md
+++ b/docs/guide/message-format.md
@@ -29,7 +29,7 @@ Event-specific emoji are added by the formatters; per-group `Group.emoji` (defau
## Forge Source Label
-With `Group.forgeLabel` (default false) the message footer additionally names the source forge — GitHub, the Gitea instance hostname (hyperlinked), or a plain `Custom` — so events from different forges can be told apart. See [Groups → Forge Source Label](./groups#forge-source-label).
+With `Group.forgeSources` (a list of `{ host, type, name? }` entries) the message footer names the matching source — the first entry whose type (`github` / `gitea`) matches the event's provider and whose host matches the repository URL's hostname, labeled with its optional `name` (falling back to the host) — so events from different forges can be told apart. See [Groups → Forge Source Label](./groups#forge-source-label).
## In-Place Updates
diff --git a/docs/zh/guide/groups.md b/docs/zh/guide/groups.md
index 70284b0..b7e022a 100644
--- a/docs/zh/guide/groups.md
+++ b/docs/zh/guide/groups.md
@@ -30,7 +30,7 @@
| `providers` | string[] | 否 | 允许进入该分组的来源平台(`github`、`gitea`);空 = 全部 |
| `installationId` | number | 否 | 绑定到该分组的 GitHub App 安装 id;仅接受该安装的事件(空 = 全部) |
| `emoji` | boolean | 否 | 该分组消息是否包含表情(默认 `true`) |
-| `forgeLabel` | boolean | 否 | 是否在该分组消息的底部显示来源平台标识(GitHub / Gitea 实例 / 自定义)(默认 `false`) |
+| `forgeSources` | object[] | 否 | 来源主机:`{ host, type, name? }` 条目(`type` 为 `github` 或 `gitea`,`name` 为可选显示名称),用于标注该分组消息底部;空 = 不标注 |
| `lang` | string | 否 | 该分组所有路由的消息语言(如 `en`、`zh`;可通过 KV `i18n:` 自定义)——见[消息语言](./i18n)——默认 `en` |
| `logTarget` | object | 否 | Webhook 日志频道:Discord 目标 `{ platform, channelId, threadId? }` 或 Telegram 目标 `{ platform, chatId, topicId? }`,接收该分组路由每次分发 webhook 的摘要 |
@@ -59,11 +59,19 @@
## 来源平台标识
-设置 `forgeLabel: true` 后,该分组路由发出的每条消息都会在底部带上来源平台,便于在共享频道中区分来自 GitHub、自建 Gitea 实例与自定义 webhook 的事件:
+分组通过 `forgeSources` 定义自己接收事件的 forge,即一组 `{ host, type, name? }` 条目(`type` 为 `github` 或 `gitea`)。该分组路由发出的每条消息都会使用**与事件来源类型匹配、且 host 与仓库 URL 主机名一致的第一条**配置作为底部标识(GitHub 事件匹配 `github.com`)。标识文本为条目的可选 `name`——未填则回退为 host——因此两个自建 Gitea 实例可以显示为「内网 Gitea」/「Git2 仓库」,同时通过各自主机名完成匹配:
-- **Discord** — embed 底部在仓库名旁显示平台名(`GitHub · acme/widget`),并以站点 favicon 作为底部图标(Gitea 会从其实例源站获取自身 favicon)。
-- **Telegram** — 底部行以带超链接的站点名称开头(`[GitHub](https://github.com)` 或 Gitea 实例主机名)。
-- **自定义** webhook 显示为无链接的 `Custom`。
+```json
+{ "forgeSources": [
+ { "host": "github.com", "type": "github", "name": "GitHub 主站" },
+ { "host": "git1.example.com", "type": "gitea", "name": "内网 Gitea" },
+ { "host": "git2.example.com", "type": "gitea" }
+] }
+```
+
+- **Discord** — embed 底部在仓库名旁显示标识(`内网 Gitea · acme/widget`),并以按仓库 URL 推导的站点 favicon 作为底部图标。
+- **Telegram** — 底部行以带超链接的标识开头(`[内网 Gitea](https://git1.example.com)`)。
+- 仓库主机没有匹配条目的事件(如自定义 webhook,或仓库托管在别处)不显示标识。
该标识与 `Group.emoji` 相互独立,并跟随该分组分发的所有消息(包括工作流/检查消息的就地更新)。
diff --git a/docs/zh/guide/message-format.md b/docs/zh/guide/message-format.md
index 6bced20..28bb87b 100644
--- a/docs/zh/guide/message-format.md
+++ b/docs/zh/guide/message-format.md
@@ -29,7 +29,7 @@
## 来源平台标识
-设置 `Group.forgeLabel`(默认关闭)后,消息底部还会标注来源平台——GitHub、Gitea 实例主机名(带超链接)或无链接的 `Custom`——便于区分来自不同 forge 的事件。见[分组 → 来源平台标识](./groups#来源平台标识)。
+设置 `Group.forgeSources`(一组 `{ host, type, name? }` 条目)后,消息底部会标注匹配的来源——即 `type`(`github` / `gitea`)与事件来源一致、且 `host` 与仓库 URL 主机名一致的第一条配置,标识文本为其可选 `name`(未填回退为 host)——便于区分来自不同 forge 的事件。见[分组 → 来源平台标识](./groups#来源平台标识)。
## 原地更新
diff --git a/server/lib/core/dispatch.ts b/server/lib/core/dispatch.ts
index 82a0254..0fab5bb 100644
--- a/server/lib/core/dispatch.ts
+++ b/server/lib/core/dispatch.ts
@@ -151,8 +151,8 @@ export async function dispatchEvent(
const tr = trMap.get(group?.lang ?? "en")!;
const showEmoji = group?.emoji !== false;
const message = formatEvent(route, event, tr, showEmoji);
- if (group?.forgeLabel) {
- message.forge = forgeInfo(event);
+ if (group?.forgeSources?.length) {
+ message.forge = forgeInfo(event, group.forgeSources);
}
if (route.discordRoleIds?.length) {
message.mentionRoleIds = route.discordRoleIds;
diff --git a/server/lib/formatters/helpers.ts b/server/lib/formatters/helpers.ts
index 3b9801a..a3b63a1 100644
--- a/server/lib/formatters/helpers.ts
+++ b/server/lib/formatters/helpers.ts
@@ -1,4 +1,4 @@
-import type { NeutralForge, NeutralMessage, WebhookEvent } from "../types";
+import type { ForgeSource, NeutralForge, NeutralMessage, WebhookEvent } from "../types";
import { t as translate } from "../lib/i18n";
import type { Translations } from "../lib/i18n";
@@ -162,31 +162,50 @@ export function senderProfileUrl(repoUrl: string | undefined, login: string): st
}
/**
- * Forge branding for an event (used when the group enables `forgeLabel`):
- * GitHub gets its brand + favicon, Gitea the instance hostname + favicon
- * derived from the repository URL, custom webhooks a plain "Custom" label.
+ * Forge branding for an event, driven by the group's own forgeSources list.
+ * The event's repository host (github.com for GitHub, the instance hostname
+ * for Gitea) is matched case-insensitively against the configured hosts; the
+ * first entry whose type and host both match wins. The footer label is the
+ * entry's display `name` (or its host when no name is set). Link/favicon are
+ * derived from the repository URL.
*/
-export function forgeInfo(event: WebhookEvent): NeutralForge | undefined {
+export function forgeInfo(
+ event: WebhookEvent,
+ sources?: ForgeSource[],
+): NeutralForge | undefined {
const repoUrl = (event.payload.repository as { html_url?: string } | undefined)?.html_url;
- switch (event.provider) {
- case "github":
- return {
- name: "GitHub",
- url: "https://github.com",
- iconUrl: "https://github.com/favicon.ico",
- };
- case "gitea": {
- if (!repoUrl) return undefined;
- try {
- const origin = new URL(repoUrl).origin;
- return { name: new URL(origin).hostname, url: origin, iconUrl: `${origin}/favicon.ico` };
- } catch {
- return undefined;
- }
+ let host: string | undefined;
+ if (repoUrl) {
+ try {
+ host = new URL(repoUrl).hostname.toLowerCase();
+ } catch {
+ // unparseable repo URL — fall through
}
- case "custom":
- return { name: "Custom" };
- default:
- return undefined;
}
+ // GitHub events without a repository (e.g. ping) still match github.com.
+ if (!host && event.provider === "github") host = "github.com";
+ if (!host) return undefined;
+
+ const source = sources?.find(
+ (s) => s.type === event.provider && s.host.toLowerCase() === host,
+ );
+ if (!source) return undefined;
+
+ const label = source.name?.trim() || source.host;
+ if (repoUrl) {
+ try {
+ const origin = new URL(repoUrl).origin;
+ return { name: label, url: origin, iconUrl: `${origin}/favicon.ico` };
+ } catch {
+ // unparseable repo URL — name only
+ }
+ }
+ if (event.provider === "github") {
+ return {
+ name: label,
+ url: "https://github.com",
+ iconUrl: "https://github.com/favicon.ico",
+ };
+ }
+ return { name: label };
}
diff --git a/server/lib/types.ts b/server/lib/types.ts
index 7d8b054..95ca0d5 100644
--- a/server/lib/types.ts
+++ b/server/lib/types.ts
@@ -127,10 +127,14 @@ export interface Group {
*/
emoji?: boolean;
/**
- * Whether to show the forge source (GitHub / Gitea instance / custom) in the
- * footer of this group's messages. Defaults to false when omitted.
+ * Named forge sources shown in the footer of this group's messages. Each
+ * entry pairs a host (e.g. `git1.example.com`) with a source type
+ * (`github` / `gitea`) and an optional display `name`; an event is labeled
+ * with the first entry whose type matches its provider and whose host
+ * matches the repository URL's hostname (GitHub events match `github.com`).
+ * Empty/omitted = no forge label.
*/
- forgeLabel?: boolean;
+ forgeSources?: ForgeSource[];
/**
* Message language for every route in this group (e.g. "en", "zh"; custom
* via KV i18n:). Defaults to "en" when omitted.
@@ -170,13 +174,31 @@ export interface NeutralAuthor {
url?: string;
}
+export type ForgeType = "github" | "gitea";
+
+/**
+ * A forge host a group configures itself. When an event's repository host
+ * matches `host`, the message footer is labeled with `name` (when set) or the
+ * host itself — e.g. two self-hosted Gitea instances can be shown as
+ * "内网 Gitea" / "Git2 仓库" while matched by git1.example.com /
+ * git2.example.com. GitHub events match `github.com` (or a GitHub Enterprise
+ * host). Link/icon are derived from the repository URL (https, port preserved).
+ */
+export interface ForgeSource {
+ /** Hostname matched against the repository URL (case-insensitive). */
+ host: string;
+ type: ForgeType;
+ /** Optional display label shown in the message footer; falls back to `host`. */
+ name?: string;
+}
+
/**
* The forge (source platform) that produced an event: shown in the message
- * footer when the group enables `Group.forgeLabel` so recipients can tell
- * GitHub, a Gitea instance or a custom webhook apart at a glance.
+ * footer when the group defines a matching `Group.forgeSources` entry so
+ * recipients can tell GitHub and Gitea instances apart at a glance.
*/
export interface NeutralForge {
- /** Display name, e.g. "GitHub" or the Gitea instance hostname. */
+ /** Display name: the source's `name` or its host (e.g. "内网 Gitea"). */
name: string;
/** Site URL for the hyperlink (Telegram) / brand context (Discord). */
url?: string;
diff --git a/server/lib/web/actions.ts b/server/lib/web/actions.ts
index 31896fb..fe00b47 100644
--- a/server/lib/web/actions.ts
+++ b/server/lib/web/actions.ts
@@ -15,7 +15,15 @@ function isValidId(value: unknown): value is number {
async function readJsonBody(event: H3Event): Promise | null> {
try {
- return (await readBody(event)) as Record;
+ const body = await readBody(event);
+ if (typeof body === "string") {
+ try {
+ return JSON.parse(body) as Record;
+ } catch {
+ return null;
+ }
+ }
+ return (body ?? {}) as Record;
} catch {
return null;
}
diff --git a/server/lib/web/admin.ts b/server/lib/web/admin.ts
index a7e4ac5..5795260 100644
--- a/server/lib/web/admin.ts
+++ b/server/lib/web/admin.ts
@@ -7,7 +7,7 @@ import {
setResponseHeader,
setResponseStatus,
} from "h3";
-import type { Route, Group, GroupMember, GroupRole } from "../types";
+import type { Route, Group, GroupMember, GroupRole, ForgeSource } from "../types";
import { loadRoutes, saveRoutes } from "../config";
import { getAdminSession, destroyAdminSession, clearAdminCookie } from "./session";
import { saveGroups, loadGroups, identityMatches, normalizeGroupMembers } from "./groups";
@@ -36,6 +36,7 @@ import { log } from "../lib/log";
const VALID_FILTER_TYPES = new Set(["event", "repo", "actor", "action", "branch", "keyword"]);
const ID_RE = /^[a-z0-9][a-z0-9-]*$/;
+const HOST_RE = /^[a-zA-Z0-9]([a-zA-Z0-9-]*[a-zA-Z0-9])?(\.[a-zA-Z0-9]([a-zA-Z0-9-]*[a-zA-Z0-9])?)*$/;
function isValidMatch(match: unknown): match is string | string[] {
if (typeof match === "string") return match.trim().length > 0;
@@ -301,8 +302,55 @@ export function validateGroups(
if (g.emoji !== undefined && typeof g.emoji !== "boolean") {
return { ok: false, error: `group "${g.id}".emoji must be a boolean` };
}
- if (g.forgeLabel !== undefined && typeof g.forgeLabel !== "boolean") {
- return { ok: false, error: `group "${g.id}".forgeLabel must be a boolean` };
+ if (g.forgeSources !== undefined && g.forgeSources !== null) {
+ if (!Array.isArray(g.forgeSources)) {
+ return { ok: false, error: `group "${g.id}".forgeSources must be an array` };
+ }
+ if (g.forgeSources.length > 20) {
+ return { ok: false, error: `group "${g.id}".forgeSources: too many sources` };
+ }
+ const seen = new Set();
+ const normalized: ForgeSource[] = [];
+ for (let i = 0; i < g.forgeSources.length; i++) {
+ const s = g.forgeSources[i] as Record;
+ if (!s || typeof s !== "object") {
+ return { ok: false, error: `group "${g.id}".forgeSources[${i}] is not an object` };
+ }
+ if (typeof s.host !== "string" || !HOST_RE.test(s.host)) {
+ return {
+ ok: false,
+ error: `group "${g.id}".forgeSources[${i}].host must be a valid hostname`,
+ };
+ }
+ if (s.type !== "github" && s.type !== "gitea") {
+ return {
+ ok: false,
+ error: `group "${g.id}".forgeSources[${i}].type must be "github" | "gitea"`,
+ };
+ }
+ if (
+ s.name !== undefined &&
+ (typeof s.name !== "string" || s.name.length > 50)
+ ) {
+ return {
+ ok: false,
+ error: `group "${g.id}".forgeSources[${i}].name must be a string`,
+ };
+ }
+ const key = `${s.type}:${s.host.toLowerCase()}`;
+ if (seen.has(key)) {
+ return { ok: false, error: `group "${g.id}".forgeSources has a duplicate source` };
+ }
+ seen.add(key);
+ normalized.push({
+ host: s.host.trim(),
+ type: s.type,
+ ...(s.name !== undefined && s.name.trim() ? { name: s.name.trim() } : {}),
+ });
+ }
+ g.forgeSources = normalized;
+ } else {
+ delete g.forgeSources;
}
if (g.lang !== undefined && typeof g.lang !== "string") {
return { ok: false, error: `group "${g.id}".lang must be a string` };
@@ -344,10 +392,21 @@ function accessError(
);
}
-/** Read a JSON body, returning null when it is not valid JSON. */
+/**
+ * Read a JSON body, returning null when it is not valid JSON. h3's readBody
+ * only parses `application/json` bodies; tolerate clients that omit the
+ * Content-Type header (curl, older UI) by JSON-parsing the raw string.
+ */
async function readJsonBody(event: H3Event): Promise | null> {
try {
const body = await readBody(event);
+ if (typeof body === "string") {
+ try {
+ return JSON.parse(body) as Record;
+ } catch {
+ return null;
+ }
+ }
return (body ?? {}) as Record;
} catch {
return null;
diff --git a/tests/admin-api.test.ts b/tests/admin-api.test.ts
index 52c0e8b..17f1581 100644
--- a/tests/admin-api.test.ts
+++ b/tests/admin-api.test.ts
@@ -1,5 +1,10 @@
import { describe, it, expect } from "bun:test";
-import { adminGroupRename, adminGroupRoutesGet, adminApiMe } from "../server/lib/web/admin";
+import {
+ adminGroupRename,
+ adminGroupRoutesGet,
+ adminApiGroupsPut,
+ adminApiMe,
+} from "../server/lib/web/admin";
import { createAdminSession, adminCookie } from "../server/lib/web/session";
import { loadGroups } from "../server/lib/web/groups";
import { loadRoutes } from "../server/lib/config";
@@ -116,6 +121,46 @@ describe("admin handlers (h3)", () => {
expect(await listInvites(kv, "old-team")).toHaveLength(0);
});
+ it("accepts a groups PUT without the application/json content-type", async () => {
+ const kv = createMockKV();
+ await kv.put(
+ "config:groups",
+ JSON.stringify([
+ { id: "team", name: "Team", adminIds: [], members: [{ login: "alice", role: "owner" }] },
+ ]),
+ );
+ const env = createEnv({ KV: kv });
+ const sessionId = await createAdminSession(kv, "1001", "alice");
+
+ // The console's fetch helper always declares application/json, but curl
+ // and older clients may omit it — h3's readBody then returns the raw
+ // string, which used to fail validation with "groups must be an array".
+ const event = makeEvent("/api/groups", {
+ method: "PUT",
+ headers: { cookie: adminCookie(sessionId) },
+ body: JSON.stringify({
+ groups: [
+ {
+ id: "team",
+ name: "Team",
+ adminIds: [],
+ members: [{ login: "alice", role: "owner" }],
+ forgeSources: [{ host: "git.example.com", type: "gitea", name: "内网 Gitea" }],
+ },
+ ],
+ }),
+ env,
+ });
+ const result = (await adminApiGroupsPut(event)) as { ok?: boolean };
+ expect(responseStatus(event)).toBe(200);
+ expect(result.ok).toBe(true);
+ const groups = await loadGroups(kv);
+ expect(groups).toHaveLength(1);
+ expect(groups[0]!.forgeSources).toEqual([
+ { host: "git.example.com", type: "gitea", name: "内网 Gitea" },
+ ]);
+ });
+
it("forbids non-owner members from renaming", async () => {
const kv = createMockKV();
await kv.put(
diff --git a/tests/discord.test.ts b/tests/discord.test.ts
index 657dc73..1cb6bb1 100644
--- a/tests/discord.test.ts
+++ b/tests/discord.test.ts
@@ -385,7 +385,7 @@ describe("dispatchEvent fallback routing", () => {
expect(logBody.embeds?.[0]?.fields?.[0]?.value).toContain("❌ Push Route → 111");
});
- it("attaches the forge label when the group enables it", async () => {
+ it("attaches the forge label when the group defines a matching host", async () => {
const bodies: string[] = [];
mockFetch((url, init) => {
bodies.push(String(init?.body ?? ""));
@@ -395,7 +395,12 @@ describe("dispatchEvent fallback routing", () => {
await kv.put(
"config:groups",
JSON.stringify([
- { id: "gh", name: "GH", adminIds: [], forgeLabel: true },
+ {
+ id: "gh",
+ name: "GH",
+ adminIds: [],
+ forgeSources: [{ host: "git.example.com", type: "gitea", name: "内网 Gitea" }],
+ },
{ id: "plain", name: "Plain", adminIds: [] },
]),
);
@@ -445,7 +450,7 @@ describe("dispatchEvent fallback routing", () => {
return parsed.embeds?.[0]?.footer;
});
expect(footers).toContainEqual({
- text: "git.example.com · owner/repo",
+ text: "内网 Gitea · owner/repo",
icon_url: "https://git.example.com/favicon.ico",
});
expect(footers).toContainEqual({ text: "owner/repo" });
diff --git a/tests/formatter.test.ts b/tests/formatter.test.ts
index 49e7050..ed4ad99 100644
--- a/tests/formatter.test.ts
+++ b/tests/formatter.test.ts
@@ -520,32 +520,129 @@ describe("limits and localization", () => {
});
describe("forge source branding", () => {
- it("github events brand as GitHub with the favicon", () => {
- expect(forgeInfo({ event: "push", provider: "github", payload: {} })).toEqual({
- name: "GitHub",
+ const ghHost = { host: "github.com", type: "github" as const };
+
+ it("labels github events with the configured host and the github favicon", () => {
+ expect(
+ forgeInfo(
+ { event: "push", provider: "github", payload: { repository: { html_url: "https://github.com/org/repo" } } },
+ [ghHost],
+ ),
+ ).toEqual({
+ name: "github.com",
url: "https://github.com",
iconUrl: "https://github.com/favicon.ico",
});
});
- it("gitea events brand as the instance hostname derived from the repo url", () => {
+ it("labels github events without a repository (ping) via the github.com fallback", () => {
expect(
- forgeInfo({
- event: "push",
- provider: "gitea",
- payload: { repository: { html_url: "https://git.example.com/org/repo" } },
- }),
+ forgeInfo({ event: "ping", provider: "github", payload: {} }, [ghHost]),
).toEqual({
- name: "git.example.com",
- url: "https://git.example.com",
- iconUrl: "https://git.example.com/favicon.ico",
+ name: "github.com",
+ url: "https://github.com",
+ iconUrl: "https://github.com/favicon.ico",
});
- expect(forgeInfo({ event: "push", provider: "gitea", payload: {} })).toBeUndefined();
});
- it("custom events brand as a plain label without links", () => {
- expect(forgeInfo({ event: "custom", provider: "custom", payload: {} })).toEqual({
- name: "Custom",
+ it("matches distinct gitea instances by their own host", () => {
+ const sources = [
+ { host: "git1.example.com", type: "gitea" as const },
+ { host: "git2.example.com", type: "gitea" as const },
+ ];
+ expect(
+ forgeInfo(
+ { event: "push", provider: "gitea", payload: { repository: { html_url: "https://git1.example.com/org/a" } } },
+ sources,
+ ),
+ ).toEqual({
+ name: "git1.example.com",
+ url: "https://git1.example.com",
+ iconUrl: "https://git1.example.com/favicon.ico",
+ });
+ expect(
+ forgeInfo(
+ { event: "push", provider: "gitea", payload: { repository: { html_url: "https://git2.example.com/org/b" } } },
+ sources,
+ ),
+ ).toEqual({
+ name: "git2.example.com",
+ url: "https://git2.example.com",
+ iconUrl: "https://git2.example.com/favicon.ico",
});
});
+
+ it("labels gitea events with the configured name when set, else the host", () => {
+ const sources = [
+ { host: "git1.example.com", type: "gitea" as const, name: "内网 Gitea" },
+ { host: "git2.example.com", type: "gitea" as const },
+ ];
+ expect(
+ forgeInfo(
+ { event: "push", provider: "gitea", payload: { repository: { html_url: "https://git1.example.com/org/a" } } },
+ sources,
+ ),
+ ).toEqual({
+ name: "内网 Gitea",
+ url: "https://git1.example.com",
+ iconUrl: "https://git1.example.com/favicon.ico",
+ });
+ expect(
+ forgeInfo(
+ { event: "push", provider: "gitea", payload: { repository: { html_url: "https://git2.example.com/org/b" } } },
+ sources,
+ ),
+ ).toEqual({
+ name: "git2.example.com",
+ url: "https://git2.example.com",
+ iconUrl: "https://git2.example.com/favicon.ico",
+ });
+ expect(
+ forgeInfo(
+ { event: "push", provider: "gitea", payload: { repository: { html_url: "https://git1.example.com/org/a" } } },
+ [{ host: "git1.example.com", type: "gitea", name: " " }],
+ ),
+ ).toEqual({
+ name: "git1.example.com",
+ url: "https://git1.example.com",
+ iconUrl: "https://git1.example.com/favicon.ico",
+ });
+ });
+
+ it("matches hosts case-insensitively", () => {
+ expect(
+ forgeInfo(
+ { event: "push", provider: "gitea", payload: { repository: { html_url: "https://GIT1.Example.COM/org/a" } } },
+ [{ host: "Git1.Example.com", type: "gitea" }],
+ ),
+ ).toEqual({
+ name: "Git1.Example.com",
+ url: "https://git1.example.com",
+ iconUrl: "https://git1.example.com/favicon.ico",
+ });
+ });
+
+ it("returns no label when the gitea repo url is missing or unparseable", () => {
+ expect(
+ forgeInfo({ event: "push", provider: "gitea", payload: {} }, [{ host: "git1.example.com", type: "gitea" }]),
+ ).toBeUndefined();
+ expect(
+ forgeInfo(
+ { event: "push", provider: "gitea", payload: { repository: { html_url: "not-a-url" } } },
+ [{ host: "git1.example.com", type: "gitea" }],
+ ),
+ ).toBeUndefined();
+ });
+
+ it("returns undefined when no source matches the provider or host", () => {
+ expect(forgeInfo({ event: "custom", provider: "custom", payload: {} }, [ghHost])).toBeUndefined();
+ expect(
+ forgeInfo(
+ { event: "push", provider: "gitea", payload: { repository: { html_url: "https://other.example.com/org/a" } } },
+ [ghHost],
+ ),
+ ).toBeUndefined();
+ expect(forgeInfo({ event: "push", provider: "github", payload: {} }, [])).toBeUndefined();
+ expect(forgeInfo({ event: "push", provider: "github", payload: {} })).toBeUndefined();
+ });
});