mirror of
https://github.com/ReCloudStudio/WebHooker.git
synced 2026-09-22 16:11:29 +00:00
fix(formatters): localize PR buttons, cap oversized content, dedupe status logic
- PR merge/close button labels now follow the group language (actions.merge/close)
instead of hardcoded Chinese
- clamp content to Discord embed limits (title 256, description 4096, field value
1024, 25 fields) in formatters plus a render-layer safety net; Telegram gets a
tag-safe 4096-char cap (capHtml closes dangling tags)
- raise commit subject truncation from 72 to 200 chars (MAX_COMMIT_SUBJECT)
- extract workflowStatus/workflowRunStatus/statusColorKey helpers shared by
check_run/check_suite/workflow_run/workflow_job
- dedupe deployment ref/sha fields via addDeploymentRefFields
- commit_comment without a commit id uses title_plain (no dangling ???????)
- tag pushes now report 'Tag created'; zh push title includes the {ref}
- sender profile link derives from the repo's forge origin instead of github.com
- group webhook log emoji injected via emojiPrefix, removed from locale files
- dispatchEvent accepts preloaded groups (single KV read per webhook)
- webhook 401 logs distinguish 'secret not configured' from 'invalid signature'
This commit is contained in:
parent
e59b10f739
commit
a5324fb0ea
20 changed files with 425 additions and 136 deletions
|
|
@ -155,6 +155,10 @@ tests/ # bun test unit tests (webhook, formatter, discord, tel
|
|||
(`commitLink`/`branchLink`/`tagLink` helpers in `server/lib/formatters/helpers.ts`, e.g.
|
||||
``[`abc123d`](https://.../commit/abc123def456)``, ``[`main`](https://.../tree/main)``),
|
||||
falling back to plain inline code when the repo base URL is unavailable.
|
||||
- Content is clamped to the Discord embed limits (title 256, description 4096, field value
|
||||
1024, 25 fields) both in the formatters and as a final safety net in the Discord render;
|
||||
the Telegram render caps the whole message at 4096 chars with tag-safe truncation. Commit
|
||||
subjects render only the first line, truncated to 200 chars.
|
||||
- Locale templates use a `{emoji}` placeholder immediately followed by the text (no space);
|
||||
the formatter injects `em(...)` which carries the trailing space.
|
||||
|
||||
|
|
|
|||
|
|
@ -36,7 +36,7 @@ Two equivalent ways:
|
|||
|
||||
### Merge / close a PR
|
||||
|
||||
Notifications for open PRs include **合并 / 关闭** (merge/close) buttons:
|
||||
Notifications for open PRs include **Merge / Close** buttons (labels follow the group's message language):
|
||||
|
||||
- Clicking a button merges (squash) or closes the PR as your linked GitHub account; GitHub enforces permission.
|
||||
- On success the buttons are removed from the notification and the result is shown in an ephemeral reply.
|
||||
|
|
|
|||
|
|
@ -36,7 +36,7 @@ Discord 命令为**斜杠命令**与**消息右键菜单命令**,由定时任
|
|||
|
||||
### 合并 / 关闭 PR
|
||||
|
||||
开放 PR 的通知附带 **合并 / 关闭** 按钮:
|
||||
开放 PR 的通知附带 **合并 / 关闭** 按钮(文案跟随分组的消息语言):
|
||||
|
||||
- 点击按钮即以你绑定的 GitHub 账号合并(squash)或关闭 PR;权限由 GitHub 强制校验。
|
||||
- 成功后按钮会从通知中移除,结果以临时消息展示。
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import type { Config, WebhookEvent, Env, Route, NeutralMessage } from "../types";
|
||||
import type { Config, WebhookEvent, Env, Route, Group, NeutralMessage } from "../types";
|
||||
import { formatEvent } from "../formatters";
|
||||
import { emojiPrefix } from "../formatters/helpers";
|
||||
import { matchRoute, eventOwners } from "../events/match";
|
||||
import { log } from "../lib/log";
|
||||
import { loadTranslations, t as translate, type Translations } from "../lib/i18n";
|
||||
|
|
@ -23,12 +24,17 @@ interface DispatchAttempt {
|
|||
error?: string;
|
||||
}
|
||||
|
||||
export async function dispatchEvent(config: Config, event: WebhookEvent, env: Env): Promise<void> {
|
||||
const groups = await loadGroups(env.KV);
|
||||
const groupById = new Map(groups.map((g) => [g.id, g]));
|
||||
export async function dispatchEvent(
|
||||
config: Config,
|
||||
event: WebhookEvent,
|
||||
env: Env,
|
||||
groups?: Group[],
|
||||
): Promise<void> {
|
||||
const loadedGroups = groups ?? (await loadGroups(env.KV));
|
||||
const groupById = new Map(loadedGroups.map((g) => [g.id, g]));
|
||||
|
||||
// Message language is configured per group (Group.lang), not per route.
|
||||
const langs = [...new Set(groups.map((g) => g.lang ?? "en"))];
|
||||
const langs = [...new Set(loadedGroups.map((g) => g.lang ?? "en"))];
|
||||
const trMap = new Map<string, Translations>();
|
||||
await Promise.all(
|
||||
langs.map(async (lang) => {
|
||||
|
|
@ -89,8 +95,15 @@ export async function dispatchEvent(config: Config, event: WebhookEvent, env: En
|
|||
.slice(0, 10)
|
||||
.map((a) =>
|
||||
a.ok
|
||||
? translate("log.route_ok", { route: a.routeName, target: a.target }, undefined, tr)
|
||||
: translate(
|
||||
? emojiPrefix("✅", true) +
|
||||
translate(
|
||||
"log.route_ok",
|
||||
{ route: a.routeName, target: a.target },
|
||||
undefined,
|
||||
tr,
|
||||
)
|
||||
: emojiPrefix("❌", true) +
|
||||
translate(
|
||||
"log.route_fail",
|
||||
{ route: a.routeName, target: a.target, error: a.error ?? "?" },
|
||||
undefined,
|
||||
|
|
|
|||
|
|
@ -1,5 +1,14 @@
|
|||
import type { FormattedMessage, NeutralActionStyle, NeutralMessage } from "../../types";
|
||||
import { repoUrlFromMessage, splitMessageTitle } from "../../formatters/helpers";
|
||||
import {
|
||||
cap,
|
||||
MAX_DESCRIPTION,
|
||||
MAX_FIELDS,
|
||||
MAX_FIELD_VALUE,
|
||||
MAX_FOOTER,
|
||||
MAX_TITLE,
|
||||
repoUrlFromMessage,
|
||||
splitMessageTitle,
|
||||
} from "../../formatters/helpers";
|
||||
|
||||
function toStyle(style: NeutralActionStyle): number {
|
||||
switch (style) {
|
||||
|
|
@ -22,29 +31,37 @@ export function renderNeutralMessage(message: NeutralMessage): FormattedMessage
|
|||
// rendered as the first line of the description, unlinked.
|
||||
const { head, subject } = splitMessageTitle(message.title);
|
||||
const repoUrl = repoUrlFromMessage(message.url);
|
||||
const description = subject
|
||||
const rawDescription = subject
|
||||
? message.description
|
||||
? `${subject}\n${message.description}`
|
||||
: subject
|
||||
: message.description;
|
||||
: (message.description ?? "");
|
||||
|
||||
// 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.
|
||||
return {
|
||||
content,
|
||||
embeds: [
|
||||
{
|
||||
title: head,
|
||||
title: cap(head, MAX_TITLE),
|
||||
url: subject ? repoUrl : message.url,
|
||||
color: message.color,
|
||||
description,
|
||||
description: cap(rawDescription, MAX_DESCRIPTION) || undefined,
|
||||
author: message.author
|
||||
? {
|
||||
name: message.author.name,
|
||||
name: cap(message.author.name, MAX_TITLE),
|
||||
icon_url: message.author.iconUrl,
|
||||
url: message.author.url,
|
||||
}
|
||||
: undefined,
|
||||
fields: message.fields,
|
||||
footer: message.footer ? { text: message.footer } : undefined,
|
||||
fields: message.fields
|
||||
?.slice(0, MAX_FIELDS)
|
||||
.map((f) => ({
|
||||
name: cap(f.name, MAX_TITLE),
|
||||
value: cap(f.value, MAX_FIELD_VALUE),
|
||||
inline: f.inline,
|
||||
})),
|
||||
footer: message.footer ? { text: cap(message.footer, MAX_FOOTER) } : undefined,
|
||||
timestamp: message.timestamp,
|
||||
},
|
||||
],
|
||||
|
|
|
|||
|
|
@ -1,6 +1,9 @@
|
|||
import type { NeutralMessage } from "../../types";
|
||||
import { repoUrlFromMessage, splitMessageTitle } from "../../formatters/helpers";
|
||||
|
||||
/** Telegram caps a single message at 4096 characters (HTML entities included). */
|
||||
const MAX_TEXT = 4096;
|
||||
|
||||
function esc(s: string): string {
|
||||
return s
|
||||
.replace(/&/g, "&")
|
||||
|
|
@ -30,6 +33,33 @@ function formatTimestamp(ts?: string): string {
|
|||
return `${d.getUTCFullYear()}-${pad(d.getUTCMonth() + 1)}-${pad(d.getUTCDate())} ${pad(d.getUTCHours())}:${pad(d.getUTCMinutes())} UTC`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Truncate Telegram HTML to `max` characters without breaking markup: drops
|
||||
* an incomplete trailing tag and closes every tag still open at the cut
|
||||
* point, so parse_mode HTML never rejects the message.
|
||||
*/
|
||||
function capHtml(text: string, max: number): string {
|
||||
if (text.length <= max) return text;
|
||||
let cut = text.slice(0, max).replace(/<[^>]*$/u, "");
|
||||
const stack: string[] = [];
|
||||
const re = /<(\/?)([a-z]+)(?:\s[^>]*)?>/g;
|
||||
let m: RegExpExecArray | null;
|
||||
while ((m = re.exec(cut)) !== null) {
|
||||
const tag = m[2]!;
|
||||
if (m[1] === "/") {
|
||||
const open = stack.lastIndexOf(tag);
|
||||
if (open >= 0) stack.splice(open);
|
||||
} else {
|
||||
stack.push(tag);
|
||||
}
|
||||
}
|
||||
const closers = stack
|
||||
.reverse()
|
||||
.map((tag) => `</${tag}>`)
|
||||
.join("");
|
||||
return `${cut}…${closers}`;
|
||||
}
|
||||
|
||||
export function renderNeutralMessage(message: NeutralMessage): string {
|
||||
const parts: string[] = [];
|
||||
|
||||
|
|
@ -66,5 +96,5 @@ export function renderNeutralMessage(message: NeutralMessage): string {
|
|||
parts.push(`<i>${meta.join(" · ")}</i>`);
|
||||
}
|
||||
|
||||
return parts.join("\n");
|
||||
return capHtml(parts.join("\n"), MAX_TEXT);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,17 @@
|
|||
import type { NeutralMessage, NeutralAuthor } from "../types";
|
||||
import { GITHUB_COLORS, WORKFLOW_CONCLUSION_EMOJI } from "./colors";
|
||||
import { branchLink, commitLink, emojiPrefix, type T, buildMessage, repoBaseUrl } from "./helpers";
|
||||
import {
|
||||
branchLink,
|
||||
cap,
|
||||
commitLink,
|
||||
emojiPrefix,
|
||||
MAX_FIELD_VALUE,
|
||||
statusColorKey,
|
||||
type T,
|
||||
buildMessage,
|
||||
repoBaseUrl,
|
||||
workflowStatus,
|
||||
} from "./helpers";
|
||||
|
||||
export function formatCheckSuite(
|
||||
payload: Record<string, unknown>,
|
||||
|
|
@ -18,21 +29,11 @@ export function formatCheckSuite(
|
|||
html_url?: string;
|
||||
};
|
||||
|
||||
const status =
|
||||
suite.status === "queued"
|
||||
? "queued"
|
||||
: suite.status === "in_progress"
|
||||
? "running"
|
||||
: (suite.conclusion ?? "pending");
|
||||
const status = workflowStatus(suite.status, suite.conclusion);
|
||||
const baseUrl = repoBaseUrl(payload, repo);
|
||||
const emoji = WORKFLOW_CONCLUSION_EMOJI[status] ?? "⏳";
|
||||
const em = (e: string): string => emojiPrefix(e, showEmoji);
|
||||
const colorKey =
|
||||
status === "success"
|
||||
? "check_run_success"
|
||||
: status === "failure"
|
||||
? "check_run_failure"
|
||||
: "check_run_other";
|
||||
const colorKey = statusColorKey("check_run", status);
|
||||
|
||||
const fields: Array<{ name: string; value: string; inline?: boolean }> = [];
|
||||
|
||||
|
|
@ -172,20 +173,10 @@ export function formatCheckRun(
|
|||
output?: { title?: string; summary?: string };
|
||||
};
|
||||
|
||||
const status =
|
||||
checkRun.status === "queued"
|
||||
? "queued"
|
||||
: checkRun.status === "in_progress"
|
||||
? "running"
|
||||
: (checkRun.conclusion ?? "pending");
|
||||
const status = workflowStatus(checkRun.status, checkRun.conclusion);
|
||||
const emoji = WORKFLOW_CONCLUSION_EMOJI[status] ?? "⏳";
|
||||
const em = (e: string): string => emojiPrefix(e, showEmoji);
|
||||
const colorKey =
|
||||
status === "success"
|
||||
? "check_run_success"
|
||||
: status === "failure"
|
||||
? "check_run_failure"
|
||||
: "check_run_other";
|
||||
const colorKey = statusColorKey("check_run", status);
|
||||
|
||||
const fields: Array<{ name: string; value: string; inline?: boolean }> = [];
|
||||
|
||||
|
|
@ -198,7 +189,7 @@ export function formatCheckRun(
|
|||
if (checkRun.output?.title) {
|
||||
fields.push({
|
||||
name: t("fields.details"),
|
||||
value: checkRun.output.title,
|
||||
value: cap(checkRun.output.title, MAX_FIELD_VALUE),
|
||||
inline: false,
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -22,13 +22,14 @@ export function formatCommitComment(
|
|||
const truncated = comment.body && comment.body.length > 500;
|
||||
const baseUrl = repoBaseUrl(payload, repo);
|
||||
const shortSha = comment.commit_id?.slice(0, 7) ?? "???????";
|
||||
const shaLink = comment.commit_id ? commitLink(baseUrl, comment.commit_id, shortSha) : undefined;
|
||||
|
||||
const fields: Array<{ name: string; value: string; inline?: boolean }> = [];
|
||||
|
||||
if (comment.commit_id) {
|
||||
fields.push({
|
||||
name: t("fields.commit"),
|
||||
value: commitLink(baseUrl, comment.commit_id, shortSha),
|
||||
value: shaLink!,
|
||||
inline: true,
|
||||
});
|
||||
}
|
||||
|
|
@ -36,10 +37,14 @@ export function formatCommitComment(
|
|||
return buildMessage(
|
||||
{
|
||||
author,
|
||||
title: t("events.commit_comment.title", {
|
||||
repo: repo ?? t("common.repository"),
|
||||
sha: commitLink(baseUrl, comment.commit_id ?? "", shortSha),
|
||||
}),
|
||||
title: shaLink
|
||||
? t("events.commit_comment.title", {
|
||||
repo: repo ?? t("common.repository"),
|
||||
sha: shaLink,
|
||||
})
|
||||
: t("events.commit_comment.title_plain", {
|
||||
repo: repo ?? t("common.repository"),
|
||||
}),
|
||||
url: comment.html_url,
|
||||
color: GITHUB_COLORS.commit_comment,
|
||||
description: `${t("events.commit_comment.action_comment", { emoji: em("💬"), action: al })}\n\n> ${commentBody}${truncated ? "..." : ""}`,
|
||||
|
|
|
|||
|
|
@ -1,5 +1,13 @@
|
|||
import type { NeutralAuthor, NeutralField, NeutralMessage } from "../types";
|
||||
import { type T, buildMessage } from "./helpers";
|
||||
import {
|
||||
cap,
|
||||
MAX_DESCRIPTION,
|
||||
MAX_FIELDS,
|
||||
MAX_FIELD_VALUE,
|
||||
MAX_TITLE,
|
||||
type T,
|
||||
buildMessage,
|
||||
} from "./helpers";
|
||||
|
||||
const COLOR_WORDS: Record<string, number> = {
|
||||
red: 0xf85149,
|
||||
|
|
@ -29,12 +37,12 @@ function parseFields(raw: unknown): NeutralField[] | undefined {
|
|||
const value = (f as Record<string, unknown>).value;
|
||||
if (typeof name !== "string" || typeof value !== "string") continue;
|
||||
fields.push({
|
||||
name: name || "\u200b",
|
||||
value,
|
||||
name: cap(name || "\u200b", MAX_TITLE),
|
||||
value: cap(value, MAX_FIELD_VALUE),
|
||||
inline: (f as Record<string, unknown>).inline === true,
|
||||
});
|
||||
}
|
||||
return fields.length > 0 ? fields : undefined;
|
||||
return fields.length > 0 ? fields.slice(0, MAX_FIELDS) : undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -50,15 +58,18 @@ export function formatCustom(
|
|||
t: T,
|
||||
_showEmoji: boolean,
|
||||
): NeutralMessage {
|
||||
const title =
|
||||
const title = cap(
|
||||
typeof payload.title === "string" && payload.title.trim()
|
||||
? payload.title.trim()
|
||||
: t("custom.title_fallback");
|
||||
: t("custom.title_fallback"),
|
||||
MAX_TITLE,
|
||||
);
|
||||
const payloadRepo =
|
||||
typeof payload.repo === "string" && payload.repo.trim() ? payload.repo.trim() : undefined;
|
||||
const effectiveRepo = payloadRepo ?? repo;
|
||||
const fullTitle = effectiveRepo ? `${effectiveRepo}: ${title}` : title;
|
||||
const description = typeof payload.description === "string" ? payload.description : undefined;
|
||||
const description =
|
||||
typeof payload.description === "string" ? cap(payload.description, MAX_DESCRIPTION) : undefined;
|
||||
const url = typeof payload.url === "string" ? payload.url : undefined;
|
||||
const footer = typeof payload.footer === "string" ? payload.footer : undefined;
|
||||
|
||||
|
|
|
|||
|
|
@ -10,6 +10,30 @@ import {
|
|||
repoBaseUrl,
|
||||
} from "./helpers";
|
||||
|
||||
/** Shared "branch/tag + commit" fields for deployment events. */
|
||||
function addDeploymentRefFields(
|
||||
fields: Array<{ name: string; value: string; inline?: boolean }>,
|
||||
deployment: { ref?: string; sha?: string },
|
||||
baseUrl: string | undefined,
|
||||
t: T,
|
||||
): void {
|
||||
if (deployment.ref) {
|
||||
const isTag = deployment.ref.startsWith("refs/tags/");
|
||||
fields.push({
|
||||
name: t("fields.branch_tag"),
|
||||
value: isTag ? tagLink(baseUrl, deployment.ref) : branchLink(baseUrl, deployment.ref),
|
||||
inline: true,
|
||||
});
|
||||
}
|
||||
if (deployment.sha) {
|
||||
fields.push({
|
||||
name: t("fields.commit"),
|
||||
value: commitLink(baseUrl, deployment.sha, deployment.sha.slice(0, 7)),
|
||||
inline: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export function formatDeployment(
|
||||
payload: Record<string, unknown>,
|
||||
repo: string | undefined,
|
||||
|
|
@ -29,7 +53,6 @@ export function formatDeployment(
|
|||
const emoji = "🚀";
|
||||
const em = (e: string): string => emojiPrefix(e, showEmoji);
|
||||
const env = deployment.environment ?? t("common.unknown");
|
||||
const shortSha = deployment.sha?.slice(0, 7) ?? "???????";
|
||||
const baseUrl = repoBaseUrl(payload, repo);
|
||||
const fields: Array<{ name: string; value: string; inline?: boolean }> = [];
|
||||
|
||||
|
|
@ -45,22 +68,7 @@ export function formatDeployment(
|
|||
inline: true,
|
||||
});
|
||||
|
||||
if (deployment.ref) {
|
||||
const isTag = deployment.ref.startsWith("refs/tags/");
|
||||
fields.push({
|
||||
name: t("fields.branch_tag"),
|
||||
value: isTag ? tagLink(baseUrl, deployment.ref) : branchLink(baseUrl, deployment.ref),
|
||||
inline: true,
|
||||
});
|
||||
}
|
||||
|
||||
if (deployment.sha) {
|
||||
fields.push({
|
||||
name: t("fields.commit"),
|
||||
value: commitLink(baseUrl, deployment.sha, shortSha),
|
||||
inline: true,
|
||||
});
|
||||
}
|
||||
addDeploymentRefFields(fields, deployment, baseUrl, t);
|
||||
|
||||
if (deployment.description) {
|
||||
fields.push({
|
||||
|
|
@ -116,7 +124,6 @@ export function formatDeploymentStatus(
|
|||
const emoji = state === "success" ? "✅" : state === "failure" ? "❌" : "⏳";
|
||||
const em = (e: string): string => emojiPrefix(e, showEmoji);
|
||||
const env = status.environment ?? deployment.environment ?? t("common.unknown");
|
||||
const shortSha = deployment.sha?.slice(0, 7) ?? "???????";
|
||||
const baseUrl = repoBaseUrl(payload, repo);
|
||||
|
||||
const fields: Array<{ name: string; value: string; inline?: boolean }> = [];
|
||||
|
|
@ -133,22 +140,7 @@ export function formatDeploymentStatus(
|
|||
inline: true,
|
||||
});
|
||||
|
||||
if (deployment.ref) {
|
||||
const isTag = deployment.ref.startsWith("refs/tags/");
|
||||
fields.push({
|
||||
name: t("fields.branch_tag"),
|
||||
value: isTag ? tagLink(baseUrl, deployment.ref) : branchLink(baseUrl, deployment.ref),
|
||||
inline: true,
|
||||
});
|
||||
}
|
||||
|
||||
if (deployment.sha) {
|
||||
fields.push({
|
||||
name: t("fields.commit"),
|
||||
value: commitLink(baseUrl, deployment.sha, shortSha),
|
||||
inline: true,
|
||||
});
|
||||
}
|
||||
addDeploymentRefFields(fields, deployment, baseUrl, t);
|
||||
|
||||
if (status.environment_url) {
|
||||
fields.push({
|
||||
|
|
|
|||
|
|
@ -99,3 +99,64 @@ export function tagLink(baseUrl: string | undefined, tag: string, label?: string
|
|||
? `[\`${display}\`](${baseUrl}/releases/tag/${encodeRefPath(clean)})`
|
||||
: `\`${display}\``;
|
||||
}
|
||||
|
||||
/* ---- Content size limits (mirror the Discord embed limits) ---- */
|
||||
export const MAX_TITLE = 256;
|
||||
export const MAX_DESCRIPTION = 4096;
|
||||
export const MAX_FIELD_VALUE = 1024;
|
||||
export const MAX_FIELDS = 25;
|
||||
export const MAX_FOOTER = 2048;
|
||||
/** First-line commit message length (stays well under the field value budget). */
|
||||
export const MAX_COMMIT_SUBJECT = 200;
|
||||
|
||||
/** Truncate a string to `max` characters. */
|
||||
export function cap(text: string, max: number): string {
|
||||
return text.length > max ? text.slice(0, max) : text;
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize a check/workflow status: `queued` / `in_progress` keep their
|
||||
* value (the latter becomes `running`), anything else falls back to the
|
||||
* conclusion and finally `pending`.
|
||||
*/
|
||||
export function workflowStatus(status?: string, conclusion?: string): string {
|
||||
if (status === "queued") return "queued";
|
||||
if (status === "in_progress") return "running";
|
||||
return conclusion ?? "pending";
|
||||
}
|
||||
|
||||
/** workflow_run events derive their progress from the action, not `status`. */
|
||||
export function workflowRunStatus(action?: string, conclusion?: string): string {
|
||||
if (action === "in_progress") return "running";
|
||||
if (action === "requested") return "queued";
|
||||
return conclusion ?? "pending";
|
||||
}
|
||||
|
||||
/** GITHUB_COLORS key for a success/failure/other status. */
|
||||
export function statusColorKey(
|
||||
prefix: "check_run" | "workflow_run",
|
||||
status: string,
|
||||
): `${typeof prefix}_${"success" | "failure" | "other"}` {
|
||||
return status === "success"
|
||||
? `${prefix}_success`
|
||||
: status === "failure"
|
||||
? `${prefix}_failure`
|
||||
: `${prefix}_other`;
|
||||
}
|
||||
|
||||
/**
|
||||
* The sender's profile URL on the same forge as the repo (derived from the
|
||||
* repo's html_url origin, e.g. `https://github.com/owner/repo` →
|
||||
* `https://github.com/login`). Falls back to github.com when the repo URL is
|
||||
* unavailable or unparseable.
|
||||
*/
|
||||
export function senderProfileUrl(repoUrl: string | undefined, login: string): string {
|
||||
if (repoUrl) {
|
||||
try {
|
||||
return `${new URL(repoUrl).origin}/${encodeURIComponent(login)}`;
|
||||
} catch {
|
||||
// unparseable repo URL — fall through to github.com
|
||||
}
|
||||
}
|
||||
return `https://github.com/${login}`;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import type { Route, WebhookEvent, NeutralMessage, NeutralAuthor } from "../types";
|
||||
import type { Translations } from "../lib/i18n";
|
||||
import { makeT, type T } from "./helpers";
|
||||
import { makeT, senderProfileUrl, type T } from "./helpers";
|
||||
import { formatPush } from "./push";
|
||||
import { formatPullRequest } from "./pull-request";
|
||||
import { formatPullRequestReview, formatPullRequestReviewComment } from "./review";
|
||||
|
|
@ -41,7 +41,7 @@ export function formatEvent(
|
|||
const author: NeutralAuthor = {
|
||||
name: sender ?? t("common.unknown"),
|
||||
iconUrl: senderAvatar,
|
||||
url: senderUrl ?? (sender ? `https://github.com/${sender}` : undefined),
|
||||
url: senderUrl ?? (sender ? senderProfileUrl(repoUrl, sender) : undefined),
|
||||
};
|
||||
|
||||
switch (eventType) {
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import type { NeutralMessage, NeutralAuthor } from "../types";
|
||||
import { GITHUB_COLORS } from "./colors";
|
||||
import { emojiPrefix, type T, buildMessage } from "./helpers";
|
||||
import { cap, emojiPrefix, MAX_FIELD_VALUE, type T, buildMessage } from "./helpers";
|
||||
|
||||
export function formatIssues(
|
||||
payload: Record<string, unknown>,
|
||||
|
|
@ -47,7 +47,7 @@ export function formatIssues(
|
|||
if (issue.labels && issue.labels.length > 0) {
|
||||
fields.push({
|
||||
name: t("fields.labels"),
|
||||
value: issue.labels.map((l) => l.name).join(", "),
|
||||
value: cap(issue.labels.map((l) => l.name).join(", "), MAX_FIELD_VALUE),
|
||||
inline: true,
|
||||
});
|
||||
}
|
||||
|
|
@ -55,7 +55,7 @@ export function formatIssues(
|
|||
if (issue.assignees && issue.assignees.length > 0) {
|
||||
fields.push({
|
||||
name: t("fields.assignees"),
|
||||
value: issue.assignees.map((a) => a.login).join(", "),
|
||||
value: cap(issue.assignees.map((a) => a.login).join(", "), MAX_FIELD_VALUE),
|
||||
inline: true,
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,14 @@
|
|||
import type { NeutralMessage, NeutralAuthor, NeutralAction } from "../types";
|
||||
import { GITHUB_COLORS } from "./colors";
|
||||
import { branchLink, emojiPrefix, type T, buildMessage, repoBaseUrl } from "./helpers";
|
||||
import {
|
||||
branchLink,
|
||||
cap,
|
||||
emojiPrefix,
|
||||
MAX_FIELD_VALUE,
|
||||
type T,
|
||||
buildMessage,
|
||||
repoBaseUrl,
|
||||
} from "./helpers";
|
||||
|
||||
export function formatPullRequest(
|
||||
payload: Record<string, unknown>,
|
||||
|
|
@ -82,7 +90,7 @@ export function formatPullRequest(
|
|||
if (pr.labels && pr.labels.length > 0) {
|
||||
fields.push({
|
||||
name: t("fields.labels"),
|
||||
value: pr.labels.map((l) => l.name).join(", "),
|
||||
value: cap(pr.labels.map((l) => l.name).join(", "), MAX_FIELD_VALUE),
|
||||
inline: true,
|
||||
});
|
||||
}
|
||||
|
|
@ -91,8 +99,16 @@ export function formatPullRequest(
|
|||
const actionable = pr.state === "open" && !!repoOwner && !!repoName && pr.number != null;
|
||||
const actions: NeutralAction[] | undefined = actionable
|
||||
? [
|
||||
{ id: `ghpr|merge|${repoOwner}|${repoName}|${pr.number}`, label: "合并", style: "primary" },
|
||||
{ id: `ghpr|close|${repoOwner}|${repoName}|${pr.number}`, label: "关闭", style: "danger" },
|
||||
{
|
||||
id: `ghpr|merge|${repoOwner}|${repoName}|${pr.number}`,
|
||||
label: t("actions.merge"),
|
||||
style: "primary",
|
||||
},
|
||||
{
|
||||
id: `ghpr|close|${repoOwner}|${repoName}|${pr.number}`,
|
||||
label: t("actions.close"),
|
||||
style: "danger",
|
||||
},
|
||||
]
|
||||
: undefined;
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,14 @@
|
|||
import type { NeutralMessage, NeutralAuthor } from "../types";
|
||||
import { GITHUB_COLORS } from "./colors";
|
||||
import { branchLink, emojiPrefix, tagLink, type T, buildMessage, repoBaseUrl } from "./helpers";
|
||||
import {
|
||||
branchLink,
|
||||
emojiPrefix,
|
||||
MAX_COMMIT_SUBJECT,
|
||||
tagLink,
|
||||
type T,
|
||||
buildMessage,
|
||||
repoBaseUrl,
|
||||
} from "./helpers";
|
||||
|
||||
export function formatPush(
|
||||
payload: Record<string, unknown>,
|
||||
|
|
@ -55,7 +63,10 @@ export function formatPush(
|
|||
descriptionParts.push(em("⚠️") + t("events.push.force_push"));
|
||||
}
|
||||
if (created) {
|
||||
descriptionParts.push(em("🆕") + t("events.push.branch_created"));
|
||||
descriptionParts.push(
|
||||
em("🆕") +
|
||||
t(isTagPush ? "events.push.tag_created" : "events.push.branch_created"),
|
||||
);
|
||||
}
|
||||
|
||||
if (compareUrl) {
|
||||
|
|
@ -66,7 +77,8 @@ export function formatPush(
|
|||
|
||||
const commitField = (c: (typeof commits)[number]): { name: string; value: string } => {
|
||||
const shortId = c.id?.slice(0, 7) ?? "???????";
|
||||
const msg = (c.message?.split("\n")[0] ?? "").slice(0, 72) || t("common.no_message");
|
||||
const msg = (c.message?.split("\n")[0] ?? "").slice(0, MAX_COMMIT_SUBJECT) ||
|
||||
t("common.no_message");
|
||||
const url = baseUrl && c.id ? `${baseUrl}/commit/${c.id}` : null;
|
||||
const hash = url ? `[\`${shortId}\`](${url})` : `\`${shortId}\``;
|
||||
return { name: `\u200b`, value: `${hash} ${msg}` };
|
||||
|
|
|
|||
|
|
@ -1,6 +1,18 @@
|
|||
import type { NeutralMessage, NeutralAuthor } from "../types";
|
||||
import { GITHUB_COLORS, WORKFLOW_CONCLUSION_EMOJI } from "./colors";
|
||||
import { emojiPrefix, type T, buildMessage, branchLink, commitLink, repoBaseUrl } from "./helpers";
|
||||
import {
|
||||
branchLink,
|
||||
cap,
|
||||
commitLink,
|
||||
emojiPrefix,
|
||||
MAX_FIELD_VALUE,
|
||||
statusColorKey,
|
||||
type T,
|
||||
buildMessage,
|
||||
repoBaseUrl,
|
||||
workflowRunStatus,
|
||||
workflowStatus,
|
||||
} from "./helpers";
|
||||
|
||||
export function formatWorkflowJob(
|
||||
payload: Record<string, unknown>,
|
||||
|
|
@ -20,21 +32,11 @@ export function formatWorkflowJob(
|
|||
run_id?: number;
|
||||
};
|
||||
|
||||
const status =
|
||||
job.status === "queued"
|
||||
? "queued"
|
||||
: job.status === "in_progress"
|
||||
? "running"
|
||||
: (job.conclusion ?? "pending");
|
||||
const status = workflowStatus(job.status, job.conclusion ?? undefined);
|
||||
const emoji = WORKFLOW_CONCLUSION_EMOJI[status] ?? "⏳";
|
||||
const em = (e: string): string => emojiPrefix(e, showEmoji);
|
||||
const baseUrl = repoBaseUrl(payload, repo);
|
||||
const colorKey =
|
||||
status === "success"
|
||||
? "workflow_run_success"
|
||||
: status === "failure"
|
||||
? "workflow_run_failure"
|
||||
: "workflow_run_other";
|
||||
const colorKey = statusColorKey("workflow_run", status);
|
||||
|
||||
const fields: Array<{ name: string; value: string; inline?: boolean }> = [];
|
||||
|
||||
|
|
@ -114,21 +116,11 @@ export function formatWorkflowRun(
|
|||
};
|
||||
|
||||
const action = payload.action as string | undefined;
|
||||
const status =
|
||||
action === "in_progress"
|
||||
? "running"
|
||||
: action === "requested"
|
||||
? "queued"
|
||||
: (workflow.conclusion ?? "pending");
|
||||
const status = workflowRunStatus(action, workflow.conclusion);
|
||||
const emoji = WORKFLOW_CONCLUSION_EMOJI[status] ?? "⏳";
|
||||
const em = (e: string): string => emojiPrefix(e, showEmoji);
|
||||
const baseUrl = repoBaseUrl(payload, repo);
|
||||
const colorKey =
|
||||
status === "success"
|
||||
? "workflow_run_success"
|
||||
: status === "failure"
|
||||
? "workflow_run_failure"
|
||||
: "workflow_run_other";
|
||||
const colorKey = statusColorKey("workflow_run", status);
|
||||
|
||||
const fields: Array<{ name: string; value: string; inline?: boolean }> = [];
|
||||
|
||||
|
|
@ -139,12 +131,13 @@ export function formatWorkflowRun(
|
|||
});
|
||||
|
||||
if (workflow.jobs?.length) {
|
||||
// Many-jobs workflows must stay under the Discord field value limit.
|
||||
const jobLines = workflow.jobs.map(
|
||||
(j) => `${em(WORKFLOW_CONCLUSION_EMOJI[j.conclusion ?? ""] ?? "⏳")}${j.name ?? ""}`,
|
||||
(j) => `${em(WORKFLOW_CONCLUSION_EMOJI[j.conclusion ?? ""] ?? "⏳")}${cap(j.name ?? "", 200)}`,
|
||||
);
|
||||
fields.push({
|
||||
name: t("fields.job"),
|
||||
value: jobLines.join("\n"),
|
||||
value: cap(jobLines.join("\n"), MAX_FIELD_VALUE),
|
||||
inline: false,
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -38,6 +38,8 @@ export const en = {
|
|||
appeared_in_branch: "Appeared in Branch",
|
||||
reopened_by_user: "Reopened by User",
|
||||
closed_by_user: "Closed by User",
|
||||
merge: "Merge",
|
||||
close: "Close",
|
||||
},
|
||||
fields: {
|
||||
branch: "Branch",
|
||||
|
|
@ -88,6 +90,7 @@ export const en = {
|
|||
push: {
|
||||
force_push: "**Force push**",
|
||||
branch_created: "Branch created",
|
||||
tag_created: "Tag created",
|
||||
view_comparison: "[View comparison]({url})",
|
||||
added: "+{count} added",
|
||||
removed: "-{count} removed",
|
||||
|
|
@ -154,6 +157,7 @@ export const en = {
|
|||
commit_comment: {
|
||||
action_comment: "{emoji}**{action}**",
|
||||
title: "{repo}: Comment on commit {sha}",
|
||||
title_plain: "{repo}: Comment on commit",
|
||||
},
|
||||
deployment: {
|
||||
title: "{repo}: Deployment to `{env}` — {state}",
|
||||
|
|
@ -201,7 +205,7 @@ export const en = {
|
|||
title: "{repo}: {event}{action}",
|
||||
routes: "Routes",
|
||||
delivery: "Delivery",
|
||||
route_ok: "✅ {route} → {target}",
|
||||
route_fail: "❌ {route} → {target}: {error}",
|
||||
route_ok: "{route} → {target}",
|
||||
route_fail: "{route} → {target}: {error}",
|
||||
},
|
||||
};
|
||||
|
|
|
|||
|
|
@ -38,6 +38,8 @@ export const zh = {
|
|||
appeared_in_branch: "出现在分支中",
|
||||
reopened_by_user: "用户重新打开",
|
||||
closed_by_user: "用户关闭",
|
||||
merge: "合并",
|
||||
close: "关闭",
|
||||
},
|
||||
fields: {
|
||||
branch: "分支",
|
||||
|
|
@ -88,12 +90,13 @@ export const zh = {
|
|||
push: {
|
||||
force_push: "**强制推送**",
|
||||
branch_created: "分支已创建",
|
||||
tag_created: "标签已创建",
|
||||
commits_pushed: "**{count}** 个提交已推送到 {ref}",
|
||||
view_comparison: "[查看比较]({url})",
|
||||
added: "+{count} 新增",
|
||||
removed: "-{count} 删除",
|
||||
modified: "~{count} 修改",
|
||||
title: "{repo}: 推送了 {count} 个提交",
|
||||
title: "{repo}: 推送了 {count} 个提交到 {ref}",
|
||||
},
|
||||
pr: {
|
||||
action_pr: "{emoji}**{action}** 拉取请求",
|
||||
|
|
@ -155,6 +158,7 @@ export const zh = {
|
|||
commit_comment: {
|
||||
action_comment: "{emoji}**{action}**",
|
||||
title: "{repo}: 提交 {sha} 的评论",
|
||||
title_plain: "{repo}: 提交评论",
|
||||
},
|
||||
deployment: {
|
||||
title: "{repo}: 部署到 `{env}` — {state}",
|
||||
|
|
@ -202,7 +206,7 @@ export const zh = {
|
|||
title: "{repo}: {event}{action}",
|
||||
routes: "路由",
|
||||
delivery: "投递",
|
||||
route_ok: "✅ {route} → {target}",
|
||||
route_fail: "❌ {route} → {target}: {error}",
|
||||
route_ok: "{route} → {target}",
|
||||
route_fail: "{route} → {target}: {error}",
|
||||
},
|
||||
};
|
||||
|
|
|
|||
|
|
@ -33,8 +33,8 @@ export async function processWebhook(
|
|||
tenantId?: string,
|
||||
): Promise<WebhookResult> {
|
||||
let effectiveEnv = env;
|
||||
const groups = await loadGroups(env.KV);
|
||||
if (tenantId) {
|
||||
const groups = await loadGroups(env.KV);
|
||||
if (!groups.some((g) => g.id === tenantId)) {
|
||||
return { status: 404, body: { error: "Group not found" } };
|
||||
}
|
||||
|
|
@ -51,6 +51,17 @@ export async function processWebhook(
|
|||
}
|
||||
|
||||
if (!(await provider.verify(body, headers, effectiveEnv))) {
|
||||
// Log the actual cause: a missing provider secret is a deployment problem,
|
||||
// while a mismatched signature usually means the sender used the wrong secret.
|
||||
const secret =
|
||||
provider.id === "gitea"
|
||||
? effectiveEnv.GITEA_WEBHOOK_SECRET
|
||||
: effectiveEnv.GITHUB_WEBHOOK_SECRET;
|
||||
if (!secret) {
|
||||
log.warn({ provider: provider.id }, "Webhook rejected: provider secret is not configured");
|
||||
} else {
|
||||
log.warn({ provider: provider.id }, "Webhook rejected: invalid signature");
|
||||
}
|
||||
return { status: 401, body: { error: "Invalid signature" } };
|
||||
}
|
||||
|
||||
|
|
@ -108,7 +119,7 @@ export async function processWebhook(
|
|||
config.routes = config.routes.filter((r) => r.groupId === tenantId);
|
||||
}
|
||||
|
||||
const dispatch = dispatchEvent(config, event, env).catch((err) =>
|
||||
const dispatch = dispatchEvent(config, event, env, groups).catch((err) =>
|
||||
log.error(err, "Dispatch failed"),
|
||||
);
|
||||
waitUntil(dispatch);
|
||||
|
|
|
|||
|
|
@ -1,5 +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 type { Route, WebhookEvent } from "../server/lib/types";
|
||||
|
||||
const route: Route = {
|
||||
|
|
@ -393,3 +394,127 @@ describe("group emoji toggle", () => {
|
|||
expect(msg.fields![1].value).toBe("build");
|
||||
});
|
||||
});
|
||||
|
||||
describe("limits and localization", () => {
|
||||
it("pull_request buttons use localized labels", () => {
|
||||
const msg = formatEvent(
|
||||
route,
|
||||
event("pull_request", {
|
||||
action: "opened",
|
||||
pull_request: {
|
||||
title: "Add feature",
|
||||
number: 7,
|
||||
state: "open",
|
||||
merged: false,
|
||||
html_url: "https://github.com/acme/widget/pull/7",
|
||||
head: { ref: "feat" },
|
||||
base: { ref: "main" },
|
||||
},
|
||||
repository: repo,
|
||||
sender,
|
||||
}),
|
||||
);
|
||||
expect(msg.actions?.map((a) => a.label)).toEqual(["Merge", "Close"]);
|
||||
});
|
||||
|
||||
it("workflow_run job list is capped to the field value limit", () => {
|
||||
const jobs = Array.from({ length: 60 }, (_, i) => ({
|
||||
name: `job-${i}`,
|
||||
conclusion: "success",
|
||||
}));
|
||||
const msg = formatEvent(
|
||||
route,
|
||||
event("workflow_run", {
|
||||
action: "completed",
|
||||
workflow_run: { name: "CI", conclusion: "success", run_number: 1, jobs },
|
||||
repository: repo,
|
||||
sender,
|
||||
}),
|
||||
);
|
||||
const jobField = msg.fields!.find((f) => f.name === "Job");
|
||||
expect(jobField!.value!.length).toBeLessThanOrEqual(1024);
|
||||
});
|
||||
|
||||
it("commit message is truncated at the subject limit", () => {
|
||||
const link = "[`abcd123`](https://github.com/acme/widget/commit/abcd1234ef)";
|
||||
const msg = formatEvent(
|
||||
route,
|
||||
event("push", {
|
||||
ref: "refs/heads/main",
|
||||
created: false,
|
||||
forced: false,
|
||||
commits: [
|
||||
{ id: "abcd1234ef", message: "x".repeat(300), added: [], removed: [], modified: [] },
|
||||
],
|
||||
repository: repo,
|
||||
sender,
|
||||
}),
|
||||
);
|
||||
expect(msg.fields![0].value).toBe(`${link} ${"x".repeat(MAX_COMMIT_SUBJECT)}`);
|
||||
});
|
||||
|
||||
it("tag push created mentions the tag", () => {
|
||||
const msg = formatEvent(
|
||||
route,
|
||||
event("push", {
|
||||
ref: "refs/tags/v1.0",
|
||||
created: true,
|
||||
forced: false,
|
||||
deleted: false,
|
||||
commits: [],
|
||||
repository: repo,
|
||||
sender,
|
||||
}),
|
||||
);
|
||||
expect(msg.description).toBe("🆕 Tag created");
|
||||
});
|
||||
|
||||
it("commit_comment without a commit id omits the sha", () => {
|
||||
const msg = formatEvent(
|
||||
route,
|
||||
event("commit_comment", {
|
||||
action: "created",
|
||||
comment: { body: "why?" },
|
||||
repository: repo,
|
||||
sender,
|
||||
}),
|
||||
);
|
||||
expect(msg.title).toBe("acme/widget: Comment on commit");
|
||||
expect(msg.fields).toBeUndefined();
|
||||
});
|
||||
|
||||
it("sender profile link follows the forge when html_url is missing", () => {
|
||||
const msg = formatEvent(
|
||||
route,
|
||||
event("push", {
|
||||
ref: "refs/heads/main",
|
||||
created: false,
|
||||
forced: false,
|
||||
commits: [{ id: "abcd1234ef", message: "x", added: [], removed: [], modified: [] }],
|
||||
repository: { full_name: "org/repo", html_url: "https://git.example.com/org/repo" },
|
||||
sender: { login: "octo" },
|
||||
}),
|
||||
);
|
||||
expect(msg.author?.url).toBe("https://git.example.com/octo");
|
||||
});
|
||||
|
||||
it("custom payload fields and values are capped", () => {
|
||||
const fields = Array.from({ length: 30 }, (_, i) => ({
|
||||
name: `f${i}`,
|
||||
value: "y".repeat(2000),
|
||||
}));
|
||||
const msg = formatEvent(
|
||||
route,
|
||||
event("custom", {
|
||||
title: "Deploy failed",
|
||||
repo: "acme/widget",
|
||||
color: "red",
|
||||
description: "x".repeat(5000),
|
||||
fields,
|
||||
}),
|
||||
);
|
||||
expect(msg.fields!.length).toBe(25);
|
||||
expect(msg.fields!.every((f) => f.value.length <= 1024)).toBe(true);
|
||||
expect(msg.description!.length).toBe(4096);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue