diff --git a/AGENTS.md b/AGENTS.md index 57ac662..0573269 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -29,7 +29,7 @@ src/ ├── discord.ts # Dispatch via REST (or DO RPC when gateway enabled), initGateway (scheduled) ├── discord-rest.ts # Discord REST sendMessage with retry + rate-limit handling ├── discord-gateway.ts # Optional Durable Object: Discord Gateway WS, heartbeat, channel cache, send -├── formatter.ts # 23 event formatters + generic fallback (~1380 lines) +├── formatter.ts # 24 event formatters + generic fallback (~1570 lines) ├── github-oauth.ts # OAuth URL, callback token exchange, getUserOctokit ├── oauth-routes.ts # GET /auth/github, callback (sets admin session if redirect=/admin), DELETE /token/:userId ├── action-routes.ts # POST /api/comment|merge|react (Bearer token auth via KV lookup) @@ -48,6 +48,20 @@ src/ - Route messages to Discord channels/threads via Durable Object RPC - Maintain Discord Gateway connection with heartbeat and alarm-based keepalive +## Message Format Spec + +- Every embed title must start with the repo, then optional `#number`, then `: subject`: + `{repo}{#number}: {subject}` (e.g. `acme/widget#7: Add feature`). Repo comes from + `payload.repository.full_name`; fall back to `t("common.repository")` when missing. +- Do NOT use `"Comment on org/repo"` / `"Review on org/repo"` prefixes. Comments, reviews + and inline comments use the same `{repo}{#number}: {title}` title as their parent object. +- All event-specific emoji live in `src/formatter.ts` (via the `em()` helper), never in the + locale files. Emoji is controlled per group through the `Group.emoji` toggle (default true); + `showEmoji=false` must strip every emoji from titles, descriptions, fields and links. +- Milestone progress bars (🟢🟡🟠⬜) are data visualization and are exempt from the emoji toggle. +- Locale templates use a `{emoji}` placeholder immediately followed by the text (no space); + the formatter injects `em(...)` which carries the trailing space. + ## Development ```bash @@ -60,7 +74,7 @@ npm run lint # ESLint - **Local dev**: `.dev.vars` (wrangler reads this for env bindings) - **Production**: `wrangler secret put ` for each secret -- **Routes**: KV key `config:routes` (JSON array); 7 defaults on first boot +- **Routes**: KV key `config:routes` (JSON array, empty until configured) - **KV namespace**: Required binding for token/state/config storage ## Deployment diff --git a/admin/components/GroupEditor.vue b/admin/components/GroupEditor.vue index 30505dc..8d878f5 100644 --- a/admin/components/GroupEditor.vue +++ b/admin/components/GroupEditor.vue @@ -56,6 +56,10 @@ />
{{ t("groupEditor.ownersHint") }}
+
{{ formError }}
@@ -91,6 +95,7 @@ const form = reactive({ name: "", adminIds: "", owners: "", + emoji: true, }); function splitList(text: string): string[] { @@ -109,6 +114,7 @@ watch( form.name = g?.name ?? ""; form.adminIds = (g?.adminIds ?? []).join(", "); form.owners = (g?.owners ?? []).join(", "); + form.emoji = g?.emoji ?? true; formError.value = ""; }, ); @@ -135,6 +141,7 @@ function save(): void { name, adminIds: splitList(form.adminIds), owners: owners.length ? owners : undefined, + emoji: form.emoji, }); } diff --git a/admin/composables/useI18n.ts b/admin/composables/useI18n.ts index 2090e98..7737bff 100644 --- a/admin/composables/useI18n.ts +++ b/admin/composables/useI18n.ts @@ -88,6 +88,7 @@ const en: Dict = { "groupEditor.ownersPlaceholder": "my-org, some-user", "groupEditor.ownersHint": "Only webhook events from these orgs/users enter this group's routes. Leave empty for no restriction.", + "groupEditor.emoji": "Show emojis in messages", "groupEditor.cancel": "Cancel", "groupEditor.save": "Save group", "groupEditor.errIdFormat": "ID must be a-z / 0-9 / dashes", @@ -194,6 +195,7 @@ const zh: Dict = { "groupEditor.ownersPlaceholder": "my-org, some-user", "groupEditor.ownersHint": "只有来自这些组织/用户的 webhook 事件才会进入本分组的路由。留空表示不限制。", + "groupEditor.emoji": "消息中显示表情符号", "groupEditor.cancel": "取消", "groupEditor.save": "保存分组", "groupEditor.errIdFormat": "ID 只能是 a-z / 0-9 / 短横线", diff --git a/admin/types.ts b/admin/types.ts index bd1c90c..63d9726 100644 --- a/admin/types.ts +++ b/admin/types.ts @@ -23,6 +23,7 @@ export interface Group { name: string; adminIds: string[]; owners?: string[]; + emoji?: boolean; } export interface Me { diff --git a/src/__tests__/formatter.test.ts b/src/__tests__/formatter.test.ts new file mode 100644 index 0000000..57c6f5f --- /dev/null +++ b/src/__tests__/formatter.test.ts @@ -0,0 +1,185 @@ +import { describe, it, expect } from "bun:test"; +import { formatEvent } from "../formatter"; +import type { Route, WebhookEvent } from "../types"; + +const route: Route = { + id: "test", + name: "Test", + enabled: true, + filters: [], + target: { channelId: "111" }, +}; + +function event(ev: string, payload: Record): WebhookEvent { + return { event: ev, payload }; +} + +const repo = { full_name: "acme/widget", html_url: "https://github.com/acme/widget" }; +const sender = { login: "octocat" }; + +describe("message title spec", () => { + it("push title starts with the repo", () => { + const msg = formatEvent( + route, + event("push", { + ref: "refs/heads/main", + compare: "https://github.com/acme/widget/compare/abc...def", + created: false, + forced: false, + commits: [{ id: "abcd1234ef", message: "fix stuff", added: [], removed: [], modified: [] }], + repository: repo, + sender, + }), + ); + expect(msg.embeds![0].title).toBe("acme/widget: Pushed 1 commit"); + }); + + it("pull_request title is repo#number: title", () => { + const msg = formatEvent( + route, + event("pull_request", { + action: "opened", + number: 7, + pull_request: { + title: "Add feature", + number: 7, + state: "open", + merged: false, + draft: false, + html_url: "https://github.com/acme/widget/pull/7", + body: null, + user: sender, + head: { ref: "feat", repo: { full_name: "acme/widget" } }, + base: { ref: "main" }, + }, + repository: repo, + sender, + }), + ); + expect(msg.embeds![0].title).toBe("acme/widget#7: Add feature"); + }); + + it("issue_comment title has no 'Comment on' prefix", () => { + const msg = formatEvent( + route, + event("issue_comment", { + action: "created", + issue: { + number: 3, + title: "Bug report", + html_url: "https://github.com/acme/widget/issues/3", + }, + comment: { + body: "thanks", + html_url: "https://github.com/acme/widget/issues/3#issuecomment-1", + }, + repository: repo, + sender, + }), + ); + expect(msg.embeds![0].title).toBe("acme/widget#3: Bug report"); + expect(msg.embeds![0].title).not.toContain("Comment on"); + }); + + it("workflow_run title is repo: name — conclusion", () => { + const msg = formatEvent( + route, + event("workflow_run", { + action: "completed", + workflow_run: { + name: "CI", + conclusion: "success", + html_url: "https://github.com/acme/widget/actions/runs/42", + head_branch: "main", + run_number: 42, + jobs: [{ name: "build", conclusion: "success" }], + }, + repository: repo, + sender, + }), + ); + expect(msg.embeds![0].title).toBe("acme/widget: CI — success"); + expect(msg.embeds![0].fields![1].value).toBe("✅ build"); + }); + + it("unknown events fall back to repo: event: action", () => { + const msg = formatEvent( + route, + event("custom_event", { action: "ran", repository: repo, sender }), + ); + expect(msg.embeds![0].title).toBe("acme/widget: custom_event: ran"); + }); +}); + +describe("group emoji toggle", () => { + it("includes emoji by default", () => { + const msg = formatEvent( + route, + event("repository", { + action: "created", + repository: { ...repo, visibility: "public", description: "a widget", fork: false }, + sender, + }), + ); + expect(msg.embeds![0].title).toBe("acme/widget: 📦 Repository Created"); + expect(msg.embeds![0].description).toContain("🔗"); + }); + + it("strips emoji when showEmoji is false", () => { + const msg = formatEvent( + route, + event("repository", { + action: "created", + repository: { ...repo, visibility: "public", description: "a widget", fork: false }, + sender, + }), + undefined, + false, + ); + expect(msg.embeds![0].title).toBe("acme/widget: Repository Created"); + expect(msg.embeds![0].title).not.toContain("📦"); + expect(msg.embeds![0].description).not.toContain("🔗"); + }); + + it("strips emoji from push description when disabled", () => { + const msg = formatEvent( + route, + event("push", { + ref: "refs/heads/main", + compare: "https://github.com/acme/widget/compare/abc...def", + created: true, + forced: true, + commits: [{ id: "abcd1234ef", message: "fix stuff", added: [], removed: [], modified: [] }], + repository: repo, + sender, + }), + undefined, + false, + ); + expect(msg.embeds![0].description).not.toContain("⚠️"); + expect(msg.embeds![0].description).not.toContain("🆕"); + }); + + it("strips emoji from workflow_run status when disabled", () => { + const msg = formatEvent( + route, + event("workflow_run", { + action: "completed", + workflow_run: { + name: "CI", + conclusion: "failure", + html_url: "https://github.com/acme/widget/actions/runs/42", + head_branch: "main", + run_number: 42, + jobs: [{ name: "build", conclusion: "failure" }], + }, + repository: repo, + sender, + }), + undefined, + false, + ); + expect(msg.embeds![0].fields![0].value).toBe("failure"); + expect(msg.embeds![0].fields![1].value).toBe("build"); + }); +}); diff --git a/src/admin-routes.ts b/src/admin-routes.ts index e906145..2f0e1d6 100644 --- a/src/admin-routes.ts +++ b/src/admin-routes.ts @@ -141,6 +141,9 @@ function validateGroups( ) { return { ok: false, error: `group "${g.id}".owners must be a list of strings` }; } + if (g.emoji !== undefined && typeof g.emoji !== "boolean") { + return { ok: false, error: `group "${g.id}".emoji must be a boolean` }; + } } return { ok: true, groups: groups as Group[] }; } diff --git a/src/discord.ts b/src/discord.ts index 9088be1..4f2b6d5 100644 --- a/src/discord.ts +++ b/src/discord.ts @@ -69,7 +69,9 @@ export async function dispatchEvent(config: Config, event: WebhookEvent, env: En try { const tr = trMap.get(route.lang ?? "en")!; - const message = formatEvent(route, event, tr); + const group = route.groupId ? groupById.get(route.groupId) : undefined; + const showEmoji = group?.emoji !== false; + const message = formatEvent(route, event, tr, showEmoji); await sendToChannel(route.target.channelId, message, env, route.target.threadId); await recordSend(env.KV, { ts: Date.now(), diff --git a/src/formatter.ts b/src/formatter.ts index ac6102b..7025a0b 100644 --- a/src/formatter.ts +++ b/src/formatter.ts @@ -65,12 +65,17 @@ const WORKFLOW_CONCLUSION_EMOJI: Record = { stale: "♻️", }; +function emojiPrefix(emoji: string, show: boolean): string { + return show ? `${emoji} ` : ""; +} + type T = (key: string, params?: Record) => string; export function formatEvent( route: Route, event: WebhookEvent, tr?: Translations, + showEmoji = true, ): FormattedMessage { const { event: eventType, payload } = event; const repo = (payload.repository as { full_name?: string })?.full_name; @@ -88,53 +93,53 @@ export function formatEvent( switch (eventType) { case "push": - return formatPush(payload, repo, author, t); + return formatPush(payload, repo, author, t, showEmoji); case "pull_request": - return formatPullRequest(payload, repo, author, t); + return formatPullRequest(payload, repo, author, t, showEmoji); case "pull_request_review": - return formatPullRequestReview(payload, repo, author, t); + return formatPullRequestReview(payload, repo, author, t, showEmoji); case "pull_request_review_comment": - return formatPullRequestReviewComment(payload, repo, author, t); + return formatPullRequestReviewComment(payload, repo, author, t, showEmoji); case "issues": - return formatIssues(payload, repo, author, t); + return formatIssues(payload, repo, author, t, showEmoji); case "issue_comment": - return formatIssueComment(payload, repo, author, t); + return formatIssueComment(payload, repo, author, t, showEmoji); case "workflow_run": - return formatWorkflowRun(payload, repo, author, t); + return formatWorkflowRun(payload, repo, author, t, showEmoji); case "release": - return formatRelease(payload, repo, author, t); + return formatRelease(payload, repo, author, t, showEmoji); case "create": - return formatCreate(payload, repo, author, t); + return formatCreate(payload, repo, author, t, showEmoji); case "delete": - return formatDelete(payload, repo, author, t); + return formatDelete(payload, repo, author, t, showEmoji); case "star": - return formatStar(payload, repo, repoUrl, author, t); + return formatStar(payload, repo, repoUrl, author, t, showEmoji); case "fork": - return formatFork(payload, repo, repoUrl, author, t); + return formatFork(payload, repo, repoUrl, author, t, showEmoji); case "check_run": - return formatCheckRun(payload, repo, author, t); + return formatCheckRun(payload, repo, author, t, showEmoji); case "commit_comment": - return formatCommitComment(payload, repo, author, t); + return formatCommitComment(payload, repo, author, t, showEmoji); case "deployment_status": - return formatDeploymentStatus(payload, repo, author, t); + return formatDeploymentStatus(payload, repo, author, t, showEmoji); case "member": - return formatMember(payload, repo, author, t); + return formatMember(payload, repo, author, t, showEmoji); case "label": - return formatLabel(payload, repo, author, t); + return formatLabel(payload, repo, author, t, showEmoji); case "milestone": - return formatMilestone(payload, repo, author, t); + return formatMilestone(payload, repo, author, t, showEmoji); case "discussion": - return formatDiscussion(payload, repo, author, t); + return formatDiscussion(payload, repo, author, t, showEmoji); case "discussion_comment": - return formatDiscussionComment(payload, repo, author, t); + return formatDiscussionComment(payload, repo, author, t, showEmoji); case "repository": - return formatRepository(payload, repo, repoUrl, author, t); + return formatRepository(payload, repo, repoUrl, author, t, showEmoji); case "code_scanning_alert": - return formatCodeScanningAlert(payload, repo, author, t); + return formatCodeScanningAlert(payload, repo, author, t, showEmoji); case "dependabot_alert": - return formatDependabotAlert(payload, repo, author, t); + return formatDependabotAlert(payload, repo, author, t, showEmoji); default: - return formatGeneric(eventType, payload, repo, author, t); + return formatGeneric(eventType, payload, repo, author, t, showEmoji); } } @@ -143,6 +148,7 @@ function formatPush( repo: string | undefined, author: { name: string; icon_url?: string; url?: string }, t: T, + showEmoji: boolean, ): FormattedMessage { const ref = (payload.ref as string)?.replace("refs/heads/", "").replace("refs/tags/", "tag: "); const commits = (payload.commits ?? []) as Array<{ @@ -157,14 +163,15 @@ function formatPush( const compareUrl = payload.compare as string | undefined; const forced = payload.forced as boolean | undefined; const created = payload.created as boolean | undefined; + const em = (e: string): string => emojiPrefix(e, showEmoji); const descriptionParts: string[] = []; if (forced) { - descriptionParts.push(t("events.push.force_push")); + descriptionParts.push(em("⚠️") + t("events.push.force_push")); } if (created) { - descriptionParts.push(t("events.push.branch_created")); + descriptionParts.push(em("🆕") + t("events.push.branch_created")); } descriptionParts.push( @@ -246,6 +253,7 @@ function formatPullRequest( repo: string | undefined, author: { name: string; icon_url?: string; url?: string }, t: T, + showEmoji: boolean, ): FormattedMessage { const action = (payload.action as string) ?? "opened"; const pr = payload.pull_request as { @@ -282,9 +290,10 @@ function formatPullRequest( : action === "opened" ? "🟢" : "🔵"; + const em = (e: string): string => emojiPrefix(e, showEmoji); const descriptionParts: string[] = []; - descriptionParts.push(t("events.pr.action_pr", { emoji: stateEmoji, action: al })); + descriptionParts.push(t("events.pr.action_pr", { emoji: em(stateEmoji), action: al })); if (pr.body) { const truncated = pr.body.slice(0, 300); @@ -363,6 +372,7 @@ function formatIssues( repo: string | undefined, author: { name: string; icon_url?: string; url?: string }, t: T, + showEmoji: boolean, ): FormattedMessage { const action = (payload.action as string) ?? "opened"; const issue = payload.issue as { @@ -387,9 +397,10 @@ function formatIssues( const al = t("actions." + action) ?? action; const stateEmoji = action === "closed" ? "🔴" : action === "opened" ? "🟢" : "🟣"; + const em = (e: string): string => emojiPrefix(e, showEmoji); const descriptionParts: string[] = []; - descriptionParts.push(t("events.issues.action_issue", { emoji: stateEmoji, action: al })); + descriptionParts.push(t("events.issues.action_issue", { emoji: em(stateEmoji), action: al })); if (issue.body) { const truncated = issue.body.slice(0, 300); @@ -447,6 +458,7 @@ function formatIssueComment( repo: string | undefined, author: { name: string; icon_url?: string; url?: string }, t: T, + showEmoji: boolean, ): FormattedMessage { const action = (payload.action as string) ?? "created"; const issue = payload.issue as { @@ -460,6 +472,7 @@ function formatIssueComment( }; const al = t("actions." + action) ?? action; + const em = (e: string): string => emojiPrefix(e, showEmoji); const commentBody = comment.body?.slice(0, 500) ?? ""; const truncated = comment.body && comment.body.length > 500; @@ -467,14 +480,14 @@ function formatIssueComment( embeds: [ { author, - title: t("events.issue_comment.comment_on", { + title: t("events.issue_comment.title", { repo: repo ?? t("common.repository"), number: issue.number ?? "?", title: issue.title ?? t("common.untitled"), }), url: comment.html_url ?? issue.html_url, color: GITHUB_COLORS.issue_comment, - description: `${t("events.issue_comment.action_comment", { action: al })}\n\n> ${commentBody}${truncated ? "..." : ""}`, + description: `${t("events.issue_comment.action_comment", { emoji: em("💬"), action: al })}\n\n> ${commentBody}${truncated ? "..." : ""}`, footer: { text: t("common.footer", { repo: repo ?? t("common.github") }) }, timestamp: new Date().toISOString(), }, @@ -487,6 +500,7 @@ function formatWorkflowRun( repo: string | undefined, author: { name: string; icon_url?: string; url?: string }, t: T, + showEmoji: boolean, ): FormattedMessage { const workflow = payload.workflow_run as { name?: string; @@ -502,6 +516,7 @@ function formatWorkflowRun( const conclusion = workflow.conclusion ?? "pending"; const emoji = WORKFLOW_CONCLUSION_EMOJI[conclusion] ?? "⏳"; + const em = (e: string): string => emojiPrefix(e, showEmoji); const colorKey = conclusion === "success" ? "workflow_run_success" @@ -513,13 +528,13 @@ function formatWorkflowRun( fields.push({ name: t("fields.status"), - value: `${emoji} ${conclusion}`, + value: `${em(emoji)}${conclusion}`, inline: true, }); if (workflow.jobs?.length) { const jobLines = workflow.jobs.map( - (j) => `${WORKFLOW_CONCLUSION_EMOJI[j.conclusion ?? ""] ?? "⏳"} ${j.name ?? ""}`, + (j) => `${em(WORKFLOW_CONCLUSION_EMOJI[j.conclusion ?? ""] ?? "⏳")}${j.name ?? ""}`, ); fields.push({ name: t("fields.job"), @@ -558,7 +573,11 @@ function formatWorkflowRun( embeds: [ { author, - title: t("events.workflow_run.title", { name: workflow.name ?? "Workflow", conclusion }), + title: t("events.workflow_run.title", { + repo: repo ?? t("common.repository"), + name: workflow.name ?? "Workflow", + conclusion, + }), url: workflow.html_url, color: GITHUB_COLORS[colorKey], fields, @@ -574,6 +593,7 @@ function formatRelease( repo: string | undefined, author: { name: string; icon_url?: string; url?: string }, t: T, + showEmoji: boolean, ): FormattedMessage { const action = (payload.action as string) ?? "published"; const release = payload.release as { @@ -595,11 +615,12 @@ function formatRelease( : "release_published"; const al = t("actions." + action) ?? action; const emoji = action === "deleted" ? "🗑️" : isPrerelease ? "⚠️" : "🚀"; + const em = (e: string): string => emojiPrefix(e, showEmoji); const descriptionParts: string[] = []; descriptionParts.push( t("events.release.action_release", { - emoji, + emoji: em(emoji), action: al, tag: release.tag_name ?? t("common.unknown"), }), @@ -615,8 +636,8 @@ function formatRelease( { author, title: t("events.release.title", { - name: release.name ?? release.tag_name ?? "Release", repo: repo ?? t("common.repository"), + name: release.name ?? release.tag_name ?? "Release", }), url: release.html_url, color: GITHUB_COLORS[colorKey], @@ -633,11 +654,13 @@ function formatCreate( repo: string | undefined, author: { name: string; icon_url?: string; url?: string }, t: T, + showEmoji: boolean, ): FormattedMessage { const refType = (payload.ref_type as string) ?? "branch"; const ref = (payload.ref as string) ?? t("common.unknown"); const emoji = refType === "tag" ? "🏷️" : "🌿"; + const em = (e: string): string => emojiPrefix(e, showEmoji); const fields: Array<{ name: string; value: string; inline?: boolean }> = []; @@ -666,10 +689,10 @@ function formatCreate( { author, title: t("events.create.title", { - emoji, + repo: repo ?? t("common.repository"), + emoji: em(emoji), type: refType, ref, - repo: repo ?? t("common.repository"), }), color: GITHUB_COLORS.create, fields, @@ -685,21 +708,23 @@ function formatDelete( repo: string | undefined, author: { name: string; icon_url?: string; url?: string }, t: T, + showEmoji: boolean, ): FormattedMessage { const refType = (payload.ref_type as string) ?? "branch"; const ref = (payload.ref as string) ?? t("common.unknown"); const emoji = refType === "tag" ? "🏷️" : "🌿"; + const em = (e: string): string => emojiPrefix(e, showEmoji); return { embeds: [ { author, title: t("events.delete.title", { - emoji, + repo: repo ?? t("common.repository"), + emoji: em(emoji), type: refType, ref, - repo: repo ?? t("common.repository"), }), color: GITHUB_COLORS.delete, footer: { text: t("common.footer", { repo: repo ?? t("common.github") }) }, @@ -715,15 +740,21 @@ function formatStar( repoUrl: string | undefined, author: { name: string; icon_url?: string; url?: string }, t: T, + showEmoji: boolean, ): FormattedMessage { const action = (payload.action as string) ?? "created"; const actionLabel = action === "created" ? t("events.star.starred") : t("events.star.unstarred"); + const em = (e: string): string => emojiPrefix(e, showEmoji); return { embeds: [ { author, - title: `${actionLabel} ${repo ?? t("common.repository")}`, + title: t("events.star.title", { + repo: repo ?? t("common.repository"), + emoji: em(action === "created" ? "⭐" : "💫"), + label: actionLabel, + }), url: repoUrl, color: GITHUB_COLORS.star, footer: { text: t("common.footer", { repo: repo ?? t("common.github") }) }, @@ -739,8 +770,10 @@ function formatFork( repoUrl: string | undefined, author: { name: string; icon_url?: string; url?: string }, t: T, + showEmoji: boolean, ): FormattedMessage { const forkee = payload.forkee as { full_name?: string; html_url?: string } | undefined; + const em = (e: string): string => emojiPrefix(e, showEmoji); return { embeds: [ @@ -748,6 +781,7 @@ function formatFork( author, title: t("events.fork.title", { repo: repo ?? t("common.repository"), + emoji: em("🍴"), forkee: forkee?.full_name ?? t("common.unknown"), }), url: forkee?.html_url ?? repoUrl, @@ -764,6 +798,7 @@ function formatCheckRun( repo: string | undefined, author: { name: string; icon_url?: string; url?: string }, t: T, + showEmoji: boolean, ): FormattedMessage { const checkRun = payload.check_run as { name?: string; @@ -775,6 +810,7 @@ function formatCheckRun( const conclusion = checkRun.conclusion ?? "pending"; const emoji = WORKFLOW_CONCLUSION_EMOJI[conclusion] ?? "⏳"; + const em = (e: string): string => emojiPrefix(e, showEmoji); const colorKey = conclusion === "success" ? "check_run_success" @@ -786,7 +822,7 @@ function formatCheckRun( fields.push({ name: t("fields.status"), - value: `${emoji} ${conclusion}`, + value: `${em(emoji)}${conclusion}`, inline: true, }); @@ -802,7 +838,11 @@ function formatCheckRun( embeds: [ { author, - title: t("events.check_run.title", { name: checkRun.name ?? "Check Run", conclusion }), + title: t("events.check_run.title", { + repo: repo ?? t("common.repository"), + name: checkRun.name ?? "Check Run", + conclusion, + }), url: checkRun.html_url, color: GITHUB_COLORS[colorKey], fields, @@ -818,6 +858,7 @@ function formatPullRequestReview( repo: string | undefined, author: { name: string; icon_url?: string; url?: string }, t: T, + showEmoji: boolean, ): FormattedMessage { const action = (payload.action as string) ?? "submitted"; const review = payload.review as { @@ -841,8 +882,9 @@ function formatPullRequestReview( const stateEmoji = state === "approved" ? "✅" : state === "changes_requested" ? "🔴" : "💬"; const al = t("actions." + action) ?? state; + const em = (e: string): string => emojiPrefix(e, showEmoji); const descriptionParts: string[] = []; - descriptionParts.push(t("events.pr_review.action_review", { emoji: stateEmoji, action: al })); + descriptionParts.push(t("events.pr_review.action_review", { emoji: em(stateEmoji), action: al })); if (review.body) { const truncated = review.body.slice(0, 500); @@ -873,6 +915,7 @@ function formatPullRequestReviewComment( repo: string | undefined, author: { name: string; icon_url?: string; url?: string }, t: T, + showEmoji: boolean, ): FormattedMessage { const action = (payload.action as string) ?? "created"; const comment = payload.comment as { @@ -887,6 +930,7 @@ function formatPullRequestReviewComment( }; const al = t("actions." + action) ?? action; + const em = (e: string): string => emojiPrefix(e, showEmoji); const commentBody = comment.body?.slice(0, 400) ?? ""; const truncated = comment.body && comment.body.length > 400; @@ -911,10 +955,11 @@ function formatPullRequestReviewComment( title: t("events.pr_review_comment.title", { repo: repo ?? t("common.repository"), number: pr.number ?? "?", + title: pr.title ?? t("common.untitled"), }), url: comment.html_url, color: GITHUB_COLORS.pull_request_review_commented, - description: `${t("events.pr_review_comment.action_inline", { action: al })}\n\n> ${commentBody}${truncated ? "..." : ""}`, + description: `${t("events.pr_review_comment.action_inline", { emoji: em("💬"), action: al })}\n\n> ${commentBody}${truncated ? "..." : ""}`, fields: fields.length > 0 ? fields : undefined, footer: { text: t("common.footer", { repo: repo ?? t("common.github") }) }, timestamp: new Date().toISOString(), @@ -928,6 +973,7 @@ function formatCommitComment( repo: string | undefined, author: { name: string; icon_url?: string; url?: string }, t: T, + showEmoji: boolean, ): FormattedMessage { const action = (payload.action as string) ?? "created"; const comment = payload.comment as { @@ -937,6 +983,7 @@ function formatCommitComment( }; const al = t("actions." + action) ?? action; + const em = (e: string): string => emojiPrefix(e, showEmoji); const commentBody = comment.body?.slice(0, 500) ?? ""; const truncated = comment.body && comment.body.length > 500; const shortSha = comment.commit_id?.slice(0, 7) ?? "???????"; @@ -956,12 +1003,12 @@ function formatCommitComment( { author, title: t("events.commit_comment.title", { - sha: shortSha, repo: repo ?? t("common.repository"), + sha: shortSha, }), url: comment.html_url, color: GITHUB_COLORS.commit_comment, - description: `${t("events.commit_comment.action_comment", { action: al })}\n\n> ${commentBody}${truncated ? "..." : ""}`, + description: `${t("events.commit_comment.action_comment", { emoji: em("💬"), action: al })}\n\n> ${commentBody}${truncated ? "..." : ""}`, fields: fields.length > 0 ? fields : undefined, footer: { text: t("common.footer", { repo: repo ?? t("common.github") }) }, timestamp: new Date().toISOString(), @@ -975,6 +1022,7 @@ function formatDeploymentStatus( repo: string | undefined, author: { name: string; icon_url?: string; url?: string }, t: T, + showEmoji: boolean, ): FormattedMessage { const status = payload.deployment_status as { state?: string; @@ -996,6 +1044,7 @@ function formatDeploymentStatus( ? "deployment_failure" : "deployment_pending"; 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) ?? "???????"; @@ -1003,7 +1052,7 @@ function formatDeploymentStatus( fields.push({ name: t("fields.status"), - value: `${emoji} ${state}`, + value: `${em(emoji)}${state}`, inline: true, }); @@ -1049,7 +1098,7 @@ function formatDeploymentStatus( embeds: [ { author, - title: t("events.deployment.title", { env, state }), + title: t("events.deployment.title", { repo: repo ?? t("common.repository"), env, state }), color: GITHUB_COLORS[colorKey], fields, footer: { text: t("common.footer", { repo: repo ?? t("common.github") }) }, @@ -1064,19 +1113,26 @@ function formatMember( repo: string | undefined, author: { name: string; icon_url?: string; url?: string }, t: T, + showEmoji: boolean, ): FormattedMessage { const action = (payload.action as string) ?? "added"; const member = payload.member as { login?: string } | undefined; const al = t("actions." + action) ?? action; const emoji = action === "added" ? "➕" : action === "removed" ? "➖" : "👤"; + const em = (e: string): string => emojiPrefix(e, showEmoji); const memberName = member?.login ?? t("common.unknown"); return { embeds: [ { author, - title: t("events.member.title", { emoji, action: al, name: memberName }), + title: t("events.member.title", { + repo: repo ?? t("common.repository"), + emoji: em(emoji), + action: al, + name: memberName, + }), color: action === "added" ? GITHUB_COLORS.member_added : GITHUB_COLORS.member_removed, footer: { text: t("common.footer", { repo: repo ?? t("common.github") }) }, timestamp: new Date().toISOString(), @@ -1090,6 +1146,7 @@ function formatLabel( repo: string | undefined, author: { name: string; icon_url?: string; url?: string }, t: T, + showEmoji: boolean, ): FormattedMessage { const action = (payload.action as string) ?? "created"; const label = payload.label as { @@ -1100,6 +1157,7 @@ function formatLabel( const al = t("actions." + action) ?? action; const emoji = action === "deleted" ? "🗑️" : action === "edited" ? "✏️" : "🏷️"; + const em = (e: string): string => emojiPrefix(e, showEmoji); const fields: Array<{ name: string; value: string; inline?: boolean }> = []; @@ -1132,7 +1190,8 @@ function formatLabel( { author, title: t("events.label.title", { - emoji, + repo: repo ?? t("common.repository"), + emoji: em(emoji), action: al, name: label.name ?? t("common.unknown"), }), @@ -1150,6 +1209,7 @@ function formatMilestone( repo: string | undefined, author: { name: string; icon_url?: string; url?: string }, t: T, + showEmoji: boolean, ): FormattedMessage { const action = (payload.action as string) ?? "created"; const milestone = payload.milestone as { @@ -1164,6 +1224,7 @@ function formatMilestone( const al = t("actions." + action) ?? action; const stateEmoji = milestone.state === "closed" ? "✅" : "🔵"; + const em = (e: string): string => emojiPrefix(e, showEmoji); const fields: Array<{ name: string; value: string; inline?: boolean }> = []; @@ -1207,7 +1268,8 @@ function formatMilestone( { author, title: t("events.milestone.title", { - emoji: stateEmoji, + repo: repo ?? t("common.repository"), + emoji: em(stateEmoji), action: al, title: milestone.title ?? t("common.unknown"), }), @@ -1229,6 +1291,7 @@ function formatDiscussion( repo: string | undefined, author: { name: string; icon_url?: string; url?: string }, t: T, + showEmoji: boolean, ): FormattedMessage { const action = (payload.action as string) ?? "created"; const discussion = payload.discussion as { @@ -1239,6 +1302,7 @@ function formatDiscussion( }; const al = t("actions." + action) ?? action; + const em = (e: string): string => emojiPrefix(e, showEmoji); const stateEmoji = action === "answered" ? "✅" @@ -1257,7 +1321,7 @@ function formatDiscussion( { author, title: t("events.discussion.title", { - emoji: stateEmoji, + repo: repo ?? t("common.repository"), number: discussion.number ?? "?", title: discussion.title ?? t("common.untitled"), }), @@ -1267,6 +1331,7 @@ function formatDiscussion( ? GITHUB_COLORS.discussion_answered : GITHUB_COLORS.discussion_created, description: t("events.discussion.action_discussion", { + emoji: em(stateEmoji), action: al, category: category ? ` in **${category}**` : "", }), @@ -1282,6 +1347,7 @@ function formatDiscussionComment( repo: string | undefined, author: { name: string; icon_url?: string; url?: string }, t: T, + showEmoji: boolean, ): FormattedMessage { const action = (payload.action as string) ?? "created"; const comment = payload.comment as { @@ -1294,6 +1360,7 @@ function formatDiscussionComment( }; const al = t("actions." + action) ?? action; + const em = (e: string): string => emojiPrefix(e, showEmoji); const commentBody = comment.body?.slice(0, 500) ?? ""; const truncated = comment.body && comment.body.length > 500; @@ -1301,13 +1368,14 @@ function formatDiscussionComment( embeds: [ { author, - title: t("events.discussion_comment.comment_on", { + title: t("events.discussion_comment.title", { + repo: repo ?? t("common.repository"), number: discussion.number ?? "?", title: discussion.title ?? t("common.untitled"), }), url: comment.html_url, color: GITHUB_COLORS.discussion_comment, - description: `${t("events.discussion_comment.action_comment", { action: al })}\n\n> ${commentBody}${truncated ? "..." : ""}`, + description: `${t("events.discussion_comment.action_comment", { emoji: em("💬"), action: al })}\n\n> ${commentBody}${truncated ? "..." : ""}`, footer: { text: t("common.footer", { repo: repo ?? t("common.github") }) }, timestamp: new Date().toISOString(), }, @@ -1321,10 +1389,12 @@ function formatRepository( repoUrl: string | undefined, author: { name: string; icon_url?: string; url?: string }, t: T, + showEmoji: boolean, ): FormattedMessage { const action = (payload.action as string) ?? "created"; const al = t("actions." + action) ?? action; + const em = (e: string): string => emojiPrefix(e, showEmoji); const fields: Array<{ name: string; value: string; inline?: boolean }> = []; @@ -1380,7 +1450,7 @@ function formatRepository( const descriptionParts: string[] = []; if (isCreateOrVisibility && repoUrl) { - descriptionParts.push(`[${t("events.repository.open")}](${repoUrl})`); + descriptionParts.push(`[${em("🔗")}${t("events.repository.open")}](${repoUrl})`); } if (isCreateOrVisibility && repoData.description) { descriptionParts.push(`> ${repoData.description}`); @@ -1390,7 +1460,11 @@ function formatRepository( embeds: [ { author, - title: t("events.repository.title", { action: al, repo: repo ?? t("common.repository") }), + title: t("events.repository.title", { + repo: repo ?? t("common.repository"), + emoji: em("📦"), + action: al, + }), url: repoUrl, color: GITHUB_COLORS.repository, description: descriptionParts.length > 0 ? descriptionParts.join("\n") : undefined, @@ -1407,6 +1481,7 @@ function formatCodeScanningAlert( repo: string | undefined, author: { name: string; icon_url?: string; url?: string }, t: T, + showEmoji: boolean, ): FormattedMessage { const action = (payload.action as string) ?? "created"; const alert = payload.alert as { @@ -1434,12 +1509,13 @@ function formatCodeScanningAlert( ? "🟡" : "⚪"; const al = t("actions." + action) ?? action; + const em = (e: string): string => emojiPrefix(e, showEmoji); const fields: Array<{ name: string; value: string; inline?: boolean }> = []; fields.push({ name: t("fields.severity"), - value: `${severityEmoji} ${severity}`, + value: `${em(severityEmoji)}${severity}`, inline: true, }); @@ -1471,7 +1547,10 @@ function formatCodeScanningAlert( embeds: [ { author, - title: t("events.code_scanning.title", { action: al }), + title: t("events.code_scanning.title", { + repo: repo ?? t("common.repository"), + action: al, + }), color: GITHUB_COLORS[colorKey], fields, footer: { text: t("common.footer", { repo: repo ?? t("common.github") }) }, @@ -1486,6 +1565,7 @@ function formatDependabotAlert( repo: string | undefined, author: { name: string; icon_url?: string; url?: string }, t: T, + showEmoji: boolean, ): FormattedMessage { const action = (payload.action as string) ?? "created"; const alert = payload.alert as { @@ -1519,12 +1599,13 @@ function formatDependabotAlert( ? "🟡" : "⚪"; const al = t("actions." + action) ?? action; + const em = (e: string): string => emojiPrefix(e, showEmoji); const fields: Array<{ name: string; value: string; inline?: boolean }> = []; fields.push({ name: t("fields.severity"), - value: `${severityEmoji} ${severity}`, + value: `${em(severityEmoji)}${severity}`, inline: true, }); @@ -1558,7 +1639,7 @@ function formatDependabotAlert( embeds: [ { author, - title: t("events.dependabot.title", { action: al }), + title: t("events.dependabot.title", { repo: repo ?? t("common.repository"), action: al }), url: alert.html_url, color: GITHUB_COLORS[colorKey], fields, @@ -1575,12 +1656,14 @@ function formatGeneric( repo: string | undefined, author: { name: string; icon_url?: string; url?: string }, t: T, + _showEmoji: boolean, ): FormattedMessage { return { embeds: [ { author, title: t("events.generic.title", { + repo: repo ?? t("common.repository"), event: eventType, action: payload.action ? `: ${payload.action}` : "", }), diff --git a/src/locales/en.ts b/src/locales/en.ts index 6a011cc..2a81235 100644 --- a/src/locales/en.ts +++ b/src/locales/en.ts @@ -83,86 +83,87 @@ export const en = { }, events: { push: { - force_push: "⚠️ **Force push**", - branch_created: "🆕 Branch created", + force_push: "**Force push**", + branch_created: "Branch created", commits_pushed: "**{count}** commit{s} pushed to `{ref}`", view_comparison: "[View comparison]({url})", added: "+{count} added", removed: "-{count} removed", modified: "~{count} modified", - title: "Pushed {count} commit{s} to {repo}", + title: "{repo}: Pushed {count} commit{s}", }, pr: { - action_pr: "{emoji} **{action}** pull request", + action_pr: "{emoji}**{action}** pull request", title: "{repo}#{number}: {title}", }, issues: { - action_issue: "{emoji} **{action}** issue", + action_issue: "{emoji}**{action}** issue", title: "{repo}#{number}: {title}", }, issue_comment: { - comment_on: "Comment on {repo}#{number}: {title}", - action_comment: "💬 **{action}** comment", + title: "{repo}#{number}: {title}", + action_comment: "{emoji}**{action}** comment", }, workflow_run: { - title: "{name} — {conclusion}", + title: "{repo}: {name} — {conclusion}", }, release: { - action_release: "{emoji} **{action}** release `{tag}`", - title: "{name} — {repo}", + action_release: "{emoji}**{action}** release `{tag}`", + title: "{repo}: {name}", }, create: { - title: "{emoji} Created {type} `{ref}` in {repo}", + title: "{repo}: {emoji}Created {type} `{ref}`", }, delete: { - title: "{emoji} Deleted {type} `{ref}` in {repo}", + title: "{repo}: {emoji}Deleted {type} `{ref}`", }, star: { - starred: "⭐ starred", - unstarred: "💫 unstarred", + starred: "Starred", + unstarred: "Unstarred", + title: "{repo}: {emoji}{label}", }, fork: { - title: "🍴 Forked {repo} to {forkee}", + title: "{repo}: {emoji}Forked to {forkee}", }, check_run: { - title: "{name} — {conclusion}", + title: "{repo}: {name} — {conclusion}", }, pr_review: { - action_review: "{emoji} **{action}** review", - title: "Review on {repo}#{number}: {title}", + action_review: "{emoji}**{action}** review", + title: "{repo}#{number}: {title}", }, pr_review_comment: { - action_inline: "💬 **{action}** inline comment", - title: "Review comment on {repo}#{number}", + action_inline: "{emoji}**{action}** inline comment", + title: "{repo}#{number}: {title}", line: " (line {position})", }, commit_comment: { - action_comment: "💬 **{action}**", - title: "Comment on commit `{sha}` in {repo}", + action_comment: "{emoji}**{action}**", + title: "{repo}: Comment on commit `{sha}`", }, deployment: { - title: "Deployment to `{env}` — {state}", + title: "{repo}: Deployment to `{env}` — {state}", }, member: { - title: "{emoji} {action} collaborator: {name}", + title: "{repo}: {emoji}{action} collaborator: {name}", }, label: { - title: "{emoji} Label {action}: {name}", + title: "{repo}: {emoji}Label {action}: {name}", }, milestone: { - title: "{emoji} Milestone {action}: {title}", + title: "{repo}: {emoji}Milestone {action}: {title}", }, discussion: { - title: "{emoji} Discussion #{number}: {title}", - action_discussion: "{action} discussion{category}", + title: "{repo}#{number}: {title}", + action_discussion: "{emoji}{action} discussion{category}", }, discussion_comment: { - comment_on: "Comment on Discussion #{number}: {title}", - action_comment: "💬 **{action}** comment", + title: "{repo}#{number}: {title}", + action_comment: "{emoji}**{action}** comment", }, repository: { - title: "📦 Repository {action}: {repo}", - open: "🔗 Open repository", + title: "{repo}: {emoji}Repository {action}", + open: "Open repository", public: "public", private: "private", internal: "internal", @@ -170,13 +171,13 @@ export const en = { visibility: "Visibility", }, code_scanning: { - title: "🔍 Code Scanning: {action}", + title: "{repo}: Code Scanning {action}", }, dependabot: { - title: "🛡️ Dependabot: {action}", + title: "{repo}: Dependabot {action}", }, generic: { - title: "{event}{action}", + title: "{repo}: {event}{action}", }, }, }; diff --git a/src/locales/zh.ts b/src/locales/zh.ts index 1303ead..e6a1361 100644 --- a/src/locales/zh.ts +++ b/src/locales/zh.ts @@ -83,86 +83,87 @@ export const zh = { }, events: { push: { - force_push: "⚠️ **强制推送**", - branch_created: "🆕 分支已创建", + force_push: "**强制推送**", + branch_created: "分支已创建", commits_pushed: "**{count}** 个提交已推送到 `{ref}`", view_comparison: "[查看比较]({url})", added: "+{count} 新增", removed: "-{count} 删除", modified: "~{count} 修改", - title: "推送了 {count} 个提交到 {repo}", + title: "{repo}: 推送了 {count} 个提交", }, pr: { - action_pr: "{emoji} **{action}** 拉取请求", + action_pr: "{emoji}**{action}** 拉取请求", title: "{repo}#{number}: {title}", }, issues: { - action_issue: "{emoji} **{action}** 议题", + action_issue: "{emoji}**{action}** 议题", title: "{repo}#{number}: {title}", }, issue_comment: { - comment_on: "{repo}#{number} 的评论: {title}", - action_comment: "💬 **{action}** 评论", + title: "{repo}#{number}: {title}", + action_comment: "{emoji}**{action}** 评论", }, workflow_run: { - title: "{name} — {conclusion}", + title: "{repo}: {name} — {conclusion}", }, release: { - action_release: "{emoji} **{action}** 发布 `{tag}`", - title: "{name} — {repo}", + action_release: "{emoji}**{action}** 发布 `{tag}`", + title: "{repo}: {name}", }, create: { - title: "{emoji} 已创建{type} `{ref}` 于 {repo}", + title: "{repo}: {emoji}已创建{type} `{ref}`", }, delete: { - title: "{emoji} 已删除{type} `{ref}` 于 {repo}", + title: "{repo}: {emoji}已删除{type} `{ref}`", }, star: { - starred: "⭐ 已加星标", - unstarred: "💫 已取消星标", + starred: "已加星标", + unstarred: "已取消星标", + title: "{repo}: {emoji}{label}", }, fork: { - title: "🍴 已将 {repo} 复刻到 {forkee}", + title: "{repo}: {emoji}复刻到 {forkee}", }, check_run: { - title: "{name} — {conclusion}", + title: "{repo}: {name} — {conclusion}", }, pr_review: { - action_review: "{emoji} **{action}** 审查", - title: "{repo}#{number} 的审查: {title}", + action_review: "{emoji}**{action}** 审查", + title: "{repo}#{number}: {title}", }, pr_review_comment: { - action_inline: "💬 **{action}** 行内评论", - title: "{repo}#{number} 的审查评论", + action_inline: "{emoji}**{action}** 行内评论", + title: "{repo}#{number}: {title}", line: " (第 {position} 行)", }, commit_comment: { - action_comment: "💬 **{action}**", - title: "{repo} 中提交 `{sha}` 的评论", + action_comment: "{emoji}**{action}**", + title: "{repo}: 提交 `{sha}` 的评论", }, deployment: { - title: "部署到 `{env}` — {state}", + title: "{repo}: 部署到 `{env}` — {state}", }, member: { - title: "{emoji} {action} 协作者: {name}", + title: "{repo}: {emoji}{action} 协作者: {name}", }, label: { - title: "{emoji} 标签 {action}: {name}", + title: "{repo}: {emoji}标签 {action}: {name}", }, milestone: { - title: "{emoji} 里程碑 {action}: {title}", + title: "{repo}: {emoji}里程碑 {action}: {title}", }, discussion: { - title: "{emoji} 讨论 #{number}: {title}", - action_discussion: "{action} 讨论{category}", + title: "{repo}#{number}: {title}", + action_discussion: "{emoji}{action} 讨论{category}", }, discussion_comment: { - comment_on: "讨论 #{number} 的评论: {title}", - action_comment: "💬 **{action}** 评论", + title: "{repo}#{number}: {title}", + action_comment: "{emoji}**{action}** 评论", }, repository: { - title: "📦 仓库 {action}: {repo}", - open: "🔗 打开仓库", + title: "{repo}: {emoji}仓库 {action}", + open: "打开仓库", public: "公开", private: "私有", internal: "内部", @@ -170,13 +171,13 @@ export const zh = { visibility: "可见性", }, code_scanning: { - title: "🔍 代码扫描: {action}", + title: "{repo}: 代码扫描 {action}", }, dependabot: { - title: "🛡️ Dependabot: {action}", + title: "{repo}: Dependabot {action}", }, generic: { - title: "{event}{action}", + title: "{repo}: {event}{action}", }, }, }; diff --git a/src/types.ts b/src/types.ts index 181f599..54614f1 100644 --- a/src/types.ts +++ b/src/types.ts @@ -65,6 +65,11 @@ export interface Group { * Only super admins may edit this field. */ owners?: string[]; + /** + * Whether to include emoji in messages sent through this group's routes. + * Defaults to true when omitted. + */ + emoji?: boolean; } export interface Filter {