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:
RhenCloud 2026-08-14 07:41:19 +08:00
parent e59b10f739
commit a5324fb0ea
No known key found for this signature in database
GPG key ID: A574A617378C4E0B
20 changed files with 425 additions and 136 deletions

View file

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

View file

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

View file

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

View file

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

View file

@ -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 ? "..." : ""}`,

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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