mirror of
https://github.com/ReCloudStudio/WebHooker.git
synced 2026-09-22 16:11:29 +00:00
feat(groups): optional forge source label in message footers
- Group.forgeLabel (default off, editable per group in the console) shows the source forge on every message the group's routes dispatch - Discord embed footer: forge name next to the repo + the site's favicon as icon_url (Gitea instance's own favicon from its origin) - Telegram footer: hyperlinked site name (GitHub or Gitea hostname); custom webhooks render a plain 'Custom' label - forgeInfo() derives branding from event.provider + repository.html_url; dispatch attaches it to the NeutralMessage like mentionRoleIds - validateGroups accepts forgeLabel booleans; GroupEditor gains the toggle - tests: forgeInfo unit, discord/telegram footer render, dispatch on/off - docs: groups.md + message-format.md (en/zh), config.example.yaml, AGENTS.md - fix: wrangler.jsonc compatibility_date was an incomplete '2026-'
This commit is contained in:
parent
a5324fb0ea
commit
3dce114cec
19 changed files with 273 additions and 16 deletions
|
|
@ -23,6 +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
|
||||
- Local dev: wrangler + Miniflare
|
||||
|
||||
## Architecture
|
||||
|
|
@ -124,6 +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`
|
||||
- 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)
|
||||
|
|
|
|||
|
|
@ -46,16 +46,27 @@
|
|||
/>
|
||||
<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.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>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label
|
||||
|
|
@ -208,6 +219,7 @@ const form = reactive({
|
|||
providers: [] as ("github" | "gitea")[],
|
||||
installationId: "",
|
||||
emoji: true,
|
||||
forgeLabel: false,
|
||||
lang: "",
|
||||
logPlatform: "" as "" | "discord" | "telegram",
|
||||
logChannelId: "",
|
||||
|
|
@ -237,6 +249,7 @@ watch(
|
|||
);
|
||||
form.installationId = g?.installationId != null ? String(g.installationId) : "";
|
||||
form.emoji = g?.emoji ?? true;
|
||||
form.forgeLabel = g?.forgeLabel ?? false;
|
||||
form.lang = g?.lang ?? "";
|
||||
form.logPlatform = lt?.platform ?? "";
|
||||
form.logChannelId = lt?.channelId ?? "";
|
||||
|
|
@ -310,6 +323,7 @@ function save(): void {
|
|||
providers: form.providers.length ? form.providers : undefined,
|
||||
installationId,
|
||||
emoji: form.emoji,
|
||||
forgeLabel: form.forgeLabel,
|
||||
lang: form.lang.trim() || undefined,
|
||||
logTarget,
|
||||
});
|
||||
|
|
|
|||
|
|
@ -198,6 +198,11 @@ 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.logTarget": "Webhook log channel",
|
||||
"groupEditor.logTargetNote": "(optional)",
|
||||
"groupEditor.logDisabled": "— Disabled —",
|
||||
|
|
@ -448,6 +453,10 @@ const zh: Dict = {
|
|||
"将本分组绑定到一个 GitHub App 安装(安装了 App 的组织/用户)。只有来自该安装的事件才会进入本分组的路由;留空表示接受任意安装(仍受来源限定过滤)。",
|
||||
"groupEditor.errInstallationId": "安装 ID 必须为正整数",
|
||||
"groupEditor.emoji": "消息中显示表情符号",
|
||||
"groupEditor.forgeLabel": "显示来源平台",
|
||||
"groupEditor.forgeLabelNote": "(可选)",
|
||||
"groupEditor.forgeLabelLabel": "在消息中标识事件来源(GitHub / Gitea / 自定义)",
|
||||
"groupEditor.forgeLabelHint": "在消息底部显示来源平台的图标与站点名称,便于区分不同 forge 的事件。",
|
||||
"groupEditor.logTarget": "Webhook 日志频道",
|
||||
"groupEditor.logTargetNote": "(可选)",
|
||||
"groupEditor.logDisabled": "— 未启用 —",
|
||||
|
|
|
|||
|
|
@ -40,6 +40,7 @@ export interface Group {
|
|||
providers?: ("github" | "gitea" | "gitlab")[];
|
||||
installationId?: number;
|
||||
emoji?: boolean;
|
||||
forgeLabel?: boolean;
|
||||
lang?: string;
|
||||
logTarget?: RouteTarget;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@ 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)
|
||||
# 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.
|
||||
|
|
|
|||
|
|
@ -30,6 +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`) |
|
||||
| `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 |
|
||||
|
||||
|
|
@ -56,6 +57,16 @@ Every group member has one of three roles. Super admins (`ADMIN_USER_IDS`) alway
|
|||
|
||||
A group may set `logTarget` to a Discord channel/thread or Telegram chat/topic. Whenever the group's routes dispatch a webhook, a single summary message is sent there: the event type/action, the repo, the delivery id, and one line per route×target with an ✅/❌ outcome (including the error for failed sends; at most the first 10 lines are listed, the rest is summarized as `+N`). The message is green when every dispatch succeeded and red when any failed. The summary uses the group's message language. Log messages are sent best-effort and are not themselves recorded in the D1 send log.
|
||||
|
||||
## 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:
|
||||
|
||||
- **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.
|
||||
|
||||
The label is independent of `Group.emoji` and follows every message that group dispatches (including in-place edits of workflow/check messages).
|
||||
|
||||
## Invites
|
||||
|
||||
Owners (and super admins) can create single-use invite links valid for 7 days from the group's _Members_ panel. Accepting an invite adds the user with the invited role (`admin` or `viewer` — never `owner`); an existing `viewer` is upgraded to `admin`. Invites are stored in KV as `invite:{token}`.
|
||||
|
|
|
|||
|
|
@ -27,6 +27,10 @@ Commit hashes, branches, and tags render as inline code wrapped in a hyperlink (
|
|||
|
||||
Event-specific emoji are added by the formatters; per-group `Group.emoji` (default true) strips them all when disabled. Milestone progress bars are exempt. See [Message Language](./i18n).
|
||||
|
||||
## 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).
|
||||
|
||||
## In-Place Updates
|
||||
|
||||
`workflow_run` and `check_run` messages are sent once and edited in place as the run progresses (queued → running → success/failure) — no duplicate messages. Tracking uses KV `msg:*` with a stable `updateKey` per run.
|
||||
|
|
|
|||
|
|
@ -30,6 +30,7 @@
|
|||
| `providers` | string[] | 否 | 允许进入该分组的来源平台(`github`、`gitea`);空 = 全部 |
|
||||
| `installationId` | number | 否 | 绑定到该分组的 GitHub App 安装 id;仅接受该安装的事件(空 = 全部) |
|
||||
| `emoji` | boolean | 否 | 该分组消息是否包含表情(默认 `true`) |
|
||||
| `forgeLabel` | boolean | 否 | 是否在该分组消息的底部显示来源平台标识(GitHub / Gitea 实例 / 自定义)(默认 `false`) |
|
||||
| `lang` | string | 否 | 该分组所有路由的消息语言(如 `en`、`zh`;可通过 KV `i18n:<lang>` 自定义)——见[消息语言](./i18n)——默认 `en` |
|
||||
| `logTarget` | object | 否 | Webhook 日志频道:Discord 目标 `{ platform, channelId, threadId? }` 或 Telegram 目标 `{ platform, chatId, topicId? }`,接收该分组路由每次分发 webhook 的摘要 |
|
||||
|
||||
|
|
@ -56,6 +57,16 @@
|
|||
|
||||
分组可以设置 `logTarget` 指向一个 Discord 频道/子区或 Telegram 群组/话题。每当该分组的路由分发(dispatch)一个 webhook,就会向那里发送一条摘要消息:事件类型/动作、仓库、投递 ID,以及每条「路由 × 目标」一行的 ✅/❌ 结果(失败时附带错误信息;最多列出前 10 行,其余以 `+N` 汇总)。全部成功时消息为绿色,任一失败则为红色。摘要使用分组的消息语言。日志消息尽力发送,本身不会被记入 D1 发送日志。
|
||||
|
||||
## 来源平台标识
|
||||
|
||||
设置 `forgeLabel: true` 后,该分组路由发出的每条消息都会在底部带上来源平台,便于在共享频道中区分来自 GitHub、自建 Gitea 实例与自定义 webhook 的事件:
|
||||
|
||||
- **Discord** — embed 底部在仓库名旁显示平台名(`GitHub · acme/widget`),并以站点 favicon 作为底部图标(Gitea 会从其实例源站获取自身 favicon)。
|
||||
- **Telegram** — 底部行以带超链接的站点名称开头(`[GitHub](https://github.com)` 或 Gitea 实例主机名)。
|
||||
- **自定义** webhook 显示为无链接的 `Custom`。
|
||||
|
||||
该标识与 `Group.emoji` 相互独立,并跟随该分组分发的所有消息(包括工作流/检查消息的就地更新)。
|
||||
|
||||
## 邀请
|
||||
|
||||
Owner(与超级管理员)可以在分组的 _成员_ 面板创建单次使用、有效期 7 天的邀请链接。接受邀请后,用户以被邀请的角色(`admin` 或 `viewer`——绝不会是 `owner`)加入;已有 `viewer` 会被升级为 `admin`。邀请存储在 KV 的 `invite:{token}` 键下。
|
||||
|
|
|
|||
|
|
@ -27,6 +27,10 @@
|
|||
|
||||
事件专属表情由格式化器添加;分组级 `Group.emoji`(默认开启)关闭后会全部去除。里程碑进度条不受影响。见[消息语言](./i18n)。
|
||||
|
||||
## 来源平台标识
|
||||
|
||||
设置 `Group.forgeLabel`(默认关闭)后,消息底部还会标注来源平台——GitHub、Gitea 实例主机名(带超链接)或无链接的 `Custom`——便于区分来自不同 forge 的事件。见[分组 → 来源平台标识](./groups#来源平台标识)。
|
||||
|
||||
## 原地更新
|
||||
|
||||
`workflow_run` 与 `check_run` 消息只发送一次,随运行进度原地编辑(queued → running → success/failure),不会重复发消息。追踪使用 KV `msg:*` 与每次运行的稳定 `updateKey`。
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import type { Config, WebhookEvent, Env, Route, Group, NeutralMessage } from "../types";
|
||||
import { formatEvent } from "../formatters";
|
||||
import { emojiPrefix } from "../formatters/helpers";
|
||||
import { emojiPrefix, forgeInfo } from "../formatters/helpers";
|
||||
import { matchRoute, eventOwners } from "../events/match";
|
||||
import { log } from "../lib/log";
|
||||
import { loadTranslations, t as translate, type Translations } from "../lib/i18n";
|
||||
|
|
@ -156,6 +156,9 @@ 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 (route.discordRoleIds?.length) {
|
||||
message.mentionRoleIds = route.discordRoleIds;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -39,6 +39,10 @@ export function renderNeutralMessage(message: NeutralMessage): FormattedMessage
|
|||
|
||||
// Safety net: clamp every embed part to the Discord API limits so a single
|
||||
// oversized formatter or custom payload can never hard-fail the request.
|
||||
const footerText = cap(
|
||||
[message.forge?.name, message.footer].filter(Boolean).join(" · "),
|
||||
MAX_FOOTER,
|
||||
);
|
||||
return {
|
||||
content,
|
||||
embeds: [
|
||||
|
|
@ -61,7 +65,9 @@ export function renderNeutralMessage(message: NeutralMessage): FormattedMessage
|
|||
value: cap(f.value, MAX_FIELD_VALUE),
|
||||
inline: f.inline,
|
||||
})),
|
||||
footer: message.footer ? { text: cap(message.footer, MAX_FOOTER) } : undefined,
|
||||
footer: footerText
|
||||
? { text: footerText, icon_url: message.forge?.iconUrl }
|
||||
: undefined,
|
||||
timestamp: message.timestamp,
|
||||
},
|
||||
],
|
||||
|
|
|
|||
|
|
@ -89,6 +89,10 @@ export function renderNeutralMessage(message: NeutralMessage): string {
|
|||
}
|
||||
|
||||
const meta: string[] = [];
|
||||
if (message.forge) {
|
||||
const name = mdToHtml(message.forge.name);
|
||||
meta.push(message.forge.url ? `<a href="${esc(message.forge.url)}">${name}</a>` : name);
|
||||
}
|
||||
if (message.footer) meta.push(esc(message.footer));
|
||||
const ts = formatTimestamp(message.timestamp);
|
||||
if (ts) meta.push(ts);
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import type { NeutralMessage } from "../types";
|
||||
import type { NeutralForge, NeutralMessage, WebhookEvent } from "../types";
|
||||
import { t as translate } from "../lib/i18n";
|
||||
import type { Translations } from "../lib/i18n";
|
||||
|
||||
|
|
@ -160,3 +160,29 @@ export function senderProfileUrl(repoUrl: string | undefined, login: string): st
|
|||
}
|
||||
return `https://github.com/${login}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
export function forgeInfo(event: WebhookEvent): 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;
|
||||
}
|
||||
}
|
||||
case "custom":
|
||||
return { name: "Custom" };
|
||||
default:
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -126,6 +126,11 @@ export interface Group {
|
|||
* Defaults to true when omitted.
|
||||
*/
|
||||
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.
|
||||
*/
|
||||
forgeLabel?: boolean;
|
||||
/**
|
||||
* Message language for every route in this group (e.g. "en", "zh"; custom
|
||||
* via KV i18n:<lang>). Defaults to "en" when omitted.
|
||||
|
|
@ -165,6 +170,20 @@ export interface NeutralAuthor {
|
|||
url?: 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.
|
||||
*/
|
||||
export interface NeutralForge {
|
||||
/** Display name, e.g. "GitHub" or the Gitea instance hostname. */
|
||||
name: string;
|
||||
/** Site URL for the hyperlink (Telegram) / brand context (Discord). */
|
||||
url?: string;
|
||||
/** Site favicon used as the Discord embed footer icon. */
|
||||
iconUrl?: string;
|
||||
}
|
||||
|
||||
export interface NeutralField {
|
||||
name: string;
|
||||
value: string;
|
||||
|
|
@ -189,6 +208,11 @@ export interface NeutralMessage {
|
|||
footer?: string;
|
||||
timestamp?: string;
|
||||
actions?: NeutralAction[];
|
||||
/**
|
||||
* Forge branding set by dispatch when `Group.forgeLabel` is enabled; drivers
|
||||
* render it in the footer (Discord icon + name, Telegram linked name).
|
||||
*/
|
||||
forge?: NeutralForge;
|
||||
/**
|
||||
* Stable key identifying a message chain that should be updated in place
|
||||
* (e.g. workflow run progress). When set, subsequent events edit the
|
||||
|
|
@ -215,7 +239,7 @@ export interface FormattedMessage {
|
|||
url?: string;
|
||||
};
|
||||
fields?: Array<{ name: string; value: string; inline?: boolean }>;
|
||||
footer?: { text: string };
|
||||
footer?: { text: string; icon_url?: string };
|
||||
timestamp?: string;
|
||||
}>;
|
||||
components?: Array<{
|
||||
|
|
|
|||
|
|
@ -301,6 +301,9 @@ 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.lang !== undefined && typeof g.lang !== "string") {
|
||||
return { ok: false, error: `group "${g.id}".lang must be a string` };
|
||||
}
|
||||
|
|
|
|||
|
|
@ -83,6 +83,22 @@ describe("discord role mentions", () => {
|
|||
const out = renderNeutralMessage({ title: "T" });
|
||||
expect(out.content).toBeUndefined();
|
||||
});
|
||||
|
||||
it("renders the forge source in the embed footer", () => {
|
||||
const out = renderNeutralMessage({
|
||||
title: "acme/widget: Add feature",
|
||||
footer: "acme/widget",
|
||||
forge: {
|
||||
name: "GitHub",
|
||||
url: "https://github.com",
|
||||
iconUrl: "https://github.com/favicon.ico",
|
||||
},
|
||||
});
|
||||
expect(out.embeds?.[0]?.footer).toEqual({
|
||||
text: "GitHub · acme/widget",
|
||||
icon_url: "https://github.com/favicon.ico",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("dispatchEvent fallback routing", () => {
|
||||
|
|
@ -369,6 +385,72 @@ 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 () => {
|
||||
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: "gh", name: "GH", adminIds: [], forgeLabel: true },
|
||||
{ id: "plain", name: "Plain", adminIds: [] },
|
||||
]),
|
||||
);
|
||||
const env = createEnv({ KV: kv, DB: createMockDB() });
|
||||
const routes: Route[] = [
|
||||
{
|
||||
id: "gitea-route",
|
||||
name: "Gitea Route",
|
||||
enabled: true,
|
||||
groupId: "gh",
|
||||
filters: [{ type: "event", match: "push" }],
|
||||
targets: [{ channelId: "111" }],
|
||||
},
|
||||
{
|
||||
id: "plain-route",
|
||||
name: "Plain Route",
|
||||
enabled: true,
|
||||
groupId: "plain",
|
||||
filters: [{ type: "event", match: "push" }],
|
||||
targets: [{ channelId: "222" }],
|
||||
},
|
||||
];
|
||||
|
||||
await dispatchEvent(
|
||||
{ ...baseConfig, routes },
|
||||
{
|
||||
event: "push",
|
||||
provider: "gitea",
|
||||
payload: {
|
||||
repository: {
|
||||
full_name: "owner/repo",
|
||||
html_url: "https://git.example.com/owner/repo",
|
||||
},
|
||||
ref: "refs/heads/main",
|
||||
commits: [{ id: "abc", message: "fix" }],
|
||||
sender: { login: "octo" },
|
||||
},
|
||||
},
|
||||
env,
|
||||
);
|
||||
|
||||
expect(bodies).toHaveLength(2);
|
||||
const footers = bodies.map((b) => {
|
||||
const parsed = JSON.parse(b) as {
|
||||
embeds?: Array<{ footer?: { text: string; icon_url?: string } }>;
|
||||
};
|
||||
return parsed.embeds?.[0]?.footer;
|
||||
});
|
||||
expect(footers).toContainEqual({
|
||||
text: "git.example.com · owner/repo",
|
||||
icon_url: "https://git.example.com/favicon.ico",
|
||||
});
|
||||
expect(footers).toContainEqual({ text: "owner/repo" });
|
||||
});
|
||||
|
||||
it("sends no group log when no route matched the event", async () => {
|
||||
const sent: string[] = [];
|
||||
mockFetch((url, init) => {
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import { describe, it, expect } from "bun:test";
|
||||
import { formatEvent } from "../server/lib/formatters";
|
||||
import { MAX_COMMIT_SUBJECT } from "../server/lib/formatters/helpers";
|
||||
import { forgeInfo, MAX_COMMIT_SUBJECT } from "../server/lib/formatters/helpers";
|
||||
import type { Route, WebhookEvent } from "../server/lib/types";
|
||||
|
||||
const route: Route = {
|
||||
|
|
@ -518,3 +518,38 @@ describe("limits and localization", () => {
|
|||
expect(msg.description!.length).toBe(4096);
|
||||
});
|
||||
});
|
||||
|
||||
describe("forge source branding", () => {
|
||||
it("github events brand as GitHub with the favicon", () => {
|
||||
expect(
|
||||
forgeInfo({ event: "push", provider: "github", payload: {} }),
|
||||
).toEqual({
|
||||
name: "GitHub",
|
||||
url: "https://github.com",
|
||||
iconUrl: "https://github.com/favicon.ico",
|
||||
});
|
||||
});
|
||||
|
||||
it("gitea events brand as the instance hostname derived from the repo url", () => {
|
||||
expect(
|
||||
forgeInfo({
|
||||
event: "push",
|
||||
provider: "gitea",
|
||||
payload: { repository: { html_url: "https://git.example.com/org/repo" } },
|
||||
}),
|
||||
).toEqual({
|
||||
name: "git.example.com",
|
||||
url: "https://git.example.com",
|
||||
iconUrl: "https://git.example.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",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -31,6 +31,23 @@ describe("telegram renderNeutralMessage", () => {
|
|||
expect(out).toContain("<i>acme/widget</i>");
|
||||
});
|
||||
|
||||
it("renders the forge source as a linked name in the footer", () => {
|
||||
const out = renderNeutralMessage({
|
||||
title: "acme/widget: Add feature",
|
||||
footer: "acme/widget",
|
||||
forge: { name: "GitHub", url: "https://github.com", iconUrl: "https://github.com/favicon.ico" },
|
||||
});
|
||||
expect(out).toContain(
|
||||
'<i><a href="https://github.com">GitHub</a> · acme/widget</i>',
|
||||
);
|
||||
|
||||
const gitea = renderNeutralMessage({
|
||||
title: "org/repo: Add feature",
|
||||
forge: { name: "git.example.com", url: "https://git.example.com" },
|
||||
});
|
||||
expect(gitea).toContain('<i><a href="https://git.example.com">git.example.com</a></i>');
|
||||
});
|
||||
|
||||
it("escapes HTML special characters", () => {
|
||||
const out = renderNeutralMessage({
|
||||
title: 'a <b> & "c"',
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
{
|
||||
"name": "webhooker",
|
||||
"main": ".output/server/index.mjs",
|
||||
"compatibility_date": "2026-",
|
||||
"compatibility_date": "2026-08-04",
|
||||
"compatibility_flags": ["nodejs_compat"],
|
||||
"build": {
|
||||
"command": "bun run build"
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue