import type { EventSummary, EventTone } from "./summarize"; const SLACK_TEXT_LIMIT = 2900; const GCHAT_TEXT_LIMIT = 3500; const DIGEST_MAX_ITEMS = 50; const SLACK_EMOJI: Record = { good: ":large_green_circle:", bad: ":red_circle:", info: ":large_blue_circle:", neutral: ":white_circle:", }; const GCHAT_ICON: Record = { good: "STAR", bad: "BOOKMARK", info: "DESCRIPTION", neutral: "DESCRIPTION", }; /** Slack mrkdwn treats `&`, `<` and `>` as control characters; everything user-authored is escaped. */ export function escapeSlack(text: string): string { return text.replaceAll("&", "&").replaceAll("<", "<").replaceAll(">", ">"); } function slackLink(url: string, label: string): string { return `<${url.replaceAll("|", "%7C").replaceAll(">", "%3E")}|${escapeSlack(label)}>`; } function limit(text: string, max: number): string { return text.length > max ? `${text.slice(0, max - 1)}…` : text; } type SlackBlock = Record; export type SlackMessage = { text: string; blocks: SlackBlock[]; unfurl_links: false; unfurl_media: false }; /** Block Kit message: linked title, optional excerpt and fields, repo/actor context, and a "View" button. */ export function slackMessage(summary: EventSummary): SlackMessage { const heading = `${SLACK_EMOJI[summary.tone]} *${slackLink(summary.url, summary.title)}*`; const blocks: SlackBlock[] = [ { type: "section", text: { type: "mrkdwn", text: limit(summary.text ? `${heading}\n${escapeSlack(summary.text)}` : heading, SLACK_TEXT_LIMIT), }, }, ]; if (summary.fields.length > 0) { blocks.push({ type: "section", fields: summary.fields.map((field) => ({ type: "mrkdwn", text: `*${escapeSlack(field.label)}*\n${escapeSlack(field.value)}`, })), }); } const context = [slackLink(summary.repoUrl, summary.repo)]; if (summary.actor) context.push(`by ${escapeSlack(summary.actor)}`); blocks.push({ type: "context", elements: [{ type: "mrkdwn", text: context.join(" · ") }] }); blocks.push({ type: "actions", elements: [{ type: "button", text: { type: "plain_text", text: "View" }, url: summary.url }], }); return { text: `${summary.repo}: ${summary.title}`, blocks, unfurl_links: false, unfurl_media: false }; } export function slackDigest(summaries: EventSummary[], label: string): SlackMessage { const shown = summaries.slice(0, DIGEST_MAX_ITEMS); const lines = shown.map((summary) => `${SLACK_EMOJI[summary.tone]} ${slackLink(summary.url, summary.title)}`); if (summaries.length > shown.length) lines.push(`… and ${summaries.length - shown.length} more`); return { text: `${label}: ${summaries.length} updates`, blocks: [ { type: "header", text: { type: "plain_text", text: limit(`${label} digest`, 150) } }, { type: "section", text: { type: "mrkdwn", text: limit(lines.join("\n"), SLACK_TEXT_LIMIT) } }, ], unfurl_links: false, unfurl_media: false, }; } /** Google Chat text widgets accept a small HTML subset, so user text is entity-escaped. */ export function escapeChat(text: string): string { return text.replaceAll("&", "&").replaceAll("<", "<").replaceAll(">", ">"); } export type GoogleChatMessage = { text: string; cardsV2: Array<{ cardId: string; card: Record }> }; export function googleChatMessage(summary: EventSummary, cardId: string): GoogleChatMessage { const widgets: Array> = [ { decoratedText: { startIcon: { knownIcon: GCHAT_ICON[summary.tone] }, text: `${escapeChat(summary.title)}`, wrapText: true, }, }, ]; if (summary.text) widgets.push({ textParagraph: { text: escapeChat(limit(summary.text, GCHAT_TEXT_LIMIT)) } }); for (const field of summary.fields) { widgets.push({ decoratedText: { topLabel: escapeChat(field.label), text: escapeChat(field.value) } }); } widgets.push({ buttonList: { buttons: [{ text: "View", onClick: { openLink: { url: summary.url } } }] }, }); return { text: `${summary.repo}: ${summary.title}`, cardsV2: [ { cardId, card: { header: { title: escapeChat(summary.repo), subtitle: summary.actor ? escapeChat(`by ${summary.actor}`) : undefined, }, sections: [{ widgets }], }, }, ], }; } export function googleChatDigest(summaries: EventSummary[], label: string, cardId: string): GoogleChatMessage { const shown = summaries.slice(0, DIGEST_MAX_ITEMS); const widgets = shown.map((summary) => ({ decoratedText: { text: `${escapeChat(summary.title)}`, wrapText: true, }, })); return { text: `${label}: ${summaries.length} updates`, cardsV2: [{ cardId, card: { header: { title: escapeChat(`${label} digest`) }, sections: [{ widgets }] } }], }; } export function genericPayload( summary: EventSummary | null, event: string, payload: Record, ): Record { return { event, summary, payload, delivered_at: new Date().toISOString() }; }