feat(groups): host-based forge sources with optional display name

- forgeSources entries are now { host, type, name? }: the repository URL's
  hostname is matched case-insensitively against host (github.com for GitHub,
  distinct hosts for multiple Gitea instances); the footer label is the
  optional name, falling back to the host
- GroupEditor renders one row per source: host input + type select + optional
  display name (grid layout); hostname validation mirrors the server
- fix: apiFetch sends Content-Type: application/json — h3's readBody only
  parses JSON bodies with that header, so every PUT/POST from the refactored
  console arrived as a raw string and failed with 'groups must be an array'
- hardening: readJsonBody (admin + actions) JSON-parses string bodies so curl
  and older clients without the content-type header still work
- regression test: groups PUT without content-type + forgeSources round-trip
- docs: groups.md/message-format.md (en/zh), AGENTS.md, config.example.yaml
This commit is contained in:
RhenCloud 2026-08-14 08:49:48 +08:00
parent 17d10db845
commit 3f6f7f17b5
No known key found for this signature in database
GPG key ID: A574A617378C4E0B
19 changed files with 469 additions and 108 deletions

View file

@ -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)

View file

@ -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;

View file

@ -46,27 +46,47 @@
/>
<div class="hint">{{ t("groupEditor.langHint") }}</div>
</div>
<div class="field">
<label
>{{ t("groupEditor.emoji") }}
<span class="lbl-note">{{ t("groupEditor.emojiNote") }}</span></label
>
<label class="inline">
<input v-model="form.emoji" type="checkbox" />
<span>{{ t("groupEditor.emojiLabel") }}</span>
</label>
</div>
<div class="field">
<label
>{{ t("groupEditor.forgeLabel") }}
<span class="lbl-note">{{ t("groupEditor.forgeLabelNote") }}</span></label
>
<label class="inline">
<input v-model="form.forgeLabel" type="checkbox" />
<span>{{ t("groupEditor.forgeLabelLabel") }}</span>
</label>
<div class="hint">{{ t("groupEditor.forgeLabelHint") }}</div>
<div class="field">
<label
>{{ t("groupEditor.emoji") }}
<span class="lbl-note">{{ t("groupEditor.emojiNote") }}</span></label
>
<label class="inline">
<input v-model="form.emoji" type="checkbox" />
<span>{{ t("groupEditor.emojiLabel") }}</span>
</label>
</div>
<div class="field">
<label
>{{ t("groupEditor.forgeSources") }}
<span class="lbl-note">{{ t("groupEditor.forgeSourcesNote") }}</span></label
>
<div v-for="(s, i) in form.forgeSources" :key="i" class="forge-source-row">
<input
v-model="s.host"
type="text"
class="input f-host"
:placeholder="t('groupEditor.forgeSourcesHost')"
/>
<select v-model="s.type" class="select forge-type">
<option value="github">GitHub</option>
<option value="gitea">Gitea</option>
</select>
<button type="button" class="icon-btn danger" @click="form.forgeSources.splice(i, 1)">
</button>
<input
v-model="s.name"
type="text"
class="input f-name"
:placeholder="t('groupEditor.forgeSourcesName')"
/>
</div>
<button type="button" class="btn btn-ghost add-filter" @click="addForgeSource">
{{ t("groupEditor.forgeSourcesAdd") }}
</button>
<div class="hint">{{ t("groupEditor.forgeSourcesHint") }}</div>
</div>
</div>
<div class="field">
<label
@ -193,7 +213,7 @@
<script setup lang="ts">
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,
});

View file

@ -16,7 +16,15 @@ export async function apiFetch<T>(path: string, init?: RequestInit): Promise<T>
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;

View file

@ -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": "— 未启用 —",

View file

@ -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;
}

View file

@ -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.

View file

@ -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:<lang>`) — 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).

View file

@ -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

View file

@ -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:<lang>` 自定义)——见[消息语言](./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` 相互独立,并跟随该分组分发的所有消息(包括工作流/检查消息的就地更新)。

View file

@ -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#来源平台标识)。
## 原地更新

View file

@ -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;

View file

@ -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 };
}

View file

@ -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:<lang>). 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;

View file

@ -15,7 +15,15 @@ function isValidId(value: unknown): value is number {
async function readJsonBody(event: H3Event): Promise<Record<string, unknown> | null> {
try {
return (await readBody(event)) as Record<string, unknown>;
const body = await readBody(event);
if (typeof body === "string") {
try {
return JSON.parse(body) as Record<string, unknown>;
} catch {
return null;
}
}
return (body ?? {}) as Record<string, unknown>;
} catch {
return null;
}

View file

@ -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<string>();
const normalized: ForgeSource[] = [];
for (let i = 0; i < g.forgeSources.length; i++) {
const s = g.forgeSources[i] as Record<string, unknown>;
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<Record<string, unknown> | null> {
try {
const body = await readBody(event);
if (typeof body === "string") {
try {
return JSON.parse(body) as Record<string, unknown>;
} catch {
return null;
}
}
return (body ?? {}) as Record<string, unknown>;
} catch {
return null;

View file

@ -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(

View file

@ -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" });

View file

@ -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();
});
});