diff --git a/AGENTS.md b/AGENTS.md index 3fda18c..bb4993f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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. diff --git a/docs/guide/commands.md b/docs/guide/commands.md index fe468f5..3748f13 100644 --- a/docs/guide/commands.md +++ b/docs/guide/commands.md @@ -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. diff --git a/docs/zh/guide/commands.md b/docs/zh/guide/commands.md index 2d34ad5..20fa7ca 100644 --- a/docs/zh/guide/commands.md +++ b/docs/zh/guide/commands.md @@ -36,7 +36,7 @@ Discord 命令为**斜杠命令**与**消息右键菜单命令**,由定时任 ### 合并 / 关闭 PR -开放 PR 的通知附带 **合并 / 关闭** 按钮: +开放 PR 的通知附带 **合并 / 关闭** 按钮(文案跟随分组的消息语言): - 点击按钮即以你绑定的 GitHub 账号合并(squash)或关闭 PR;权限由 GitHub 强制校验。 - 成功后按钮会从通知中移除,结果以临时消息展示。 diff --git a/server/lib/core/dispatch.ts b/server/lib/core/dispatch.ts index 8a79669..09802c5 100644 --- a/server/lib/core/dispatch.ts +++ b/server/lib/core/dispatch.ts @@ -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 { - 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 { + 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(); 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, diff --git a/server/lib/drivers/discord/render.ts b/server/lib/drivers/discord/render.ts index 46c7a48..02aea57 100644 --- a/server/lib/drivers/discord/render.ts +++ b/server/lib/drivers/discord/render.ts @@ -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, }, ], diff --git a/server/lib/drivers/telegram/render.ts b/server/lib/drivers/telegram/render.ts index f9e55a2..f2d611f 100644 --- a/server/lib/drivers/telegram/render.ts +++ b/server/lib/drivers/telegram/render.ts @@ -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) => ``) + .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(`${meta.join(" · ")}`); } - return parts.join("\n"); + return capHtml(parts.join("\n"), MAX_TEXT); } diff --git a/server/lib/formatters/check.ts b/server/lib/formatters/check.ts index 45ff0d6..f4551b0 100644 --- a/server/lib/formatters/check.ts +++ b/server/lib/formatters/check.ts @@ -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, @@ -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, }); } diff --git a/server/lib/formatters/commit-comment.ts b/server/lib/formatters/commit-comment.ts index ec8906f..7efb113 100644 --- a/server/lib/formatters/commit-comment.ts +++ b/server/lib/formatters/commit-comment.ts @@ -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 ? "..." : ""}`, diff --git a/server/lib/formatters/custom.ts b/server/lib/formatters/custom.ts index 1a1e9e0..75927bd 100644 --- a/server/lib/formatters/custom.ts +++ b/server/lib/formatters/custom.ts @@ -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 = { red: 0xf85149, @@ -29,12 +37,12 @@ function parseFields(raw: unknown): NeutralField[] | undefined { const value = (f as Record).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).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; diff --git a/server/lib/formatters/deployment.ts b/server/lib/formatters/deployment.ts index e5ef4a8..3fc7c50 100644 --- a/server/lib/formatters/deployment.ts +++ b/server/lib/formatters/deployment.ts @@ -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, 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({ diff --git a/server/lib/formatters/helpers.ts b/server/lib/formatters/helpers.ts index a3d19f2..6f312a5 100644 --- a/server/lib/formatters/helpers.ts +++ b/server/lib/formatters/helpers.ts @@ -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}`; +} diff --git a/server/lib/formatters/index.ts b/server/lib/formatters/index.ts index 3ed5283..f3063cf 100644 --- a/server/lib/formatters/index.ts +++ b/server/lib/formatters/index.ts @@ -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) { diff --git a/server/lib/formatters/issues.ts b/server/lib/formatters/issues.ts index abc8cc6..29a1f3d 100644 --- a/server/lib/formatters/issues.ts +++ b/server/lib/formatters/issues.ts @@ -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, @@ -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, }); } diff --git a/server/lib/formatters/pull-request.ts b/server/lib/formatters/pull-request.ts index 6862ddd..0d611c6 100644 --- a/server/lib/formatters/pull-request.ts +++ b/server/lib/formatters/pull-request.ts @@ -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, @@ -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; diff --git a/server/lib/formatters/push.ts b/server/lib/formatters/push.ts index 8615ca2..f3defbc 100644 --- a/server/lib/formatters/push.ts +++ b/server/lib/formatters/push.ts @@ -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, @@ -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}` }; diff --git a/server/lib/formatters/workflow.ts b/server/lib/formatters/workflow.ts index 64ec077..9b02928 100644 --- a/server/lib/formatters/workflow.ts +++ b/server/lib/formatters/workflow.ts @@ -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, @@ -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, }); } diff --git a/server/lib/lib/locales/en.ts b/server/lib/lib/locales/en.ts index ccf968f..9d75165 100644 --- a/server/lib/lib/locales/en.ts +++ b/server/lib/lib/locales/en.ts @@ -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}", }, }; diff --git a/server/lib/lib/locales/zh.ts b/server/lib/lib/locales/zh.ts index 3130be0..9a2a7d4 100644 --- a/server/lib/lib/locales/zh.ts +++ b/server/lib/lib/locales/zh.ts @@ -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}", }, }; diff --git a/server/lib/webhook.ts b/server/lib/webhook.ts index bc3acea..fc0b63f 100644 --- a/server/lib/webhook.ts +++ b/server/lib/webhook.ts @@ -33,8 +33,8 @@ export async function processWebhook( tenantId?: string, ): Promise { 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); diff --git a/tests/formatter.test.ts b/tests/formatter.test.ts index e6bb360..2bf5a82 100644 --- a/tests/formatter.test.ts +++ b/tests/formatter.test.ts @@ -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); + }); +});