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:
RhenCloud 2026-08-14 08:10:04 +08:00
parent a5324fb0ea
commit 3dce114cec
No known key found for this signature in database
GPG key ID: A574A617378C4E0B
19 changed files with 273 additions and 16 deletions

View file

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

View file

@ -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,
},
],

View file

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

View file

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

View file

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

View file

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