diff --git a/apps/webapp/app/services/channels/slack/outbound.ts b/apps/webapp/app/services/channels/slack/outbound.ts index 2c20dc42d..7a0e0772d 100644 --- a/apps/webapp/app/services/channels/slack/outbound.ts +++ b/apps/webapp/app/services/channels/slack/outbound.ts @@ -2,129 +2,10 @@ import { prisma } from "~/db.server"; import { sendSlackDM, sendSlackMessage } from "./client"; import { logger } from "~/services/logger.service"; import type { ReplyMetadata } from "../types"; - -// --------------------------------------------------------------------------- -// Markdown → Slack Block Kit conversion -// --------------------------------------------------------------------------- - -function convertInlineMd(text: string): string { - return text - .replace(/\*\*(.+?)\*\*/g, "*$1*") // **bold** → *bold* - .replace(/(?"); // [text](url) → -} - -function stripInlineMd(text: string): string { - return text - .replace(/\*\*(.+?)\*\*/g, "$1") - .replace(/\*(.+?)\*/g, "$1") - .replace(/_(.+?)_/g, "$1") - .replace(/~~(.+?)~~/g, "$1") - .replace(/\[(.+?)\]\(.+?\)/g, "$1") - .replace(/`(.+?)`/g, "$1"); -} - -function chunkText(text: string, max: number): string[] { - if (text.length <= max) return [text]; - const chunks: string[] = []; - let remaining = text; - while (remaining.length > max) { - const idx = remaining.lastIndexOf("\n\n", max); - const at = idx > 0 ? idx : max; - chunks.push(remaining.slice(0, at).trim()); - remaining = remaining.slice(at).trim(); - } - if (remaining) chunks.push(remaining); - return chunks; -} - -function markdownToSlackBlocks(markdown: string): unknown[] { - const blocks: unknown[] = []; - const lines = markdown.split("\n"); - let sectionLines: string[] = []; - - function flushSection() { - if (sectionLines.length === 0) return; - const text = convertInlineMd(sectionLines.join("\n").trimEnd()); - sectionLines = []; - if (!text.trim()) return; - for (const chunk of chunkText(text, 3000)) { - blocks.push({ type: "section", text: { type: "mrkdwn", text: chunk } }); - } - } - - for (const line of lines) { - // H1/H2 → header block - const h2 = line.match(/^#{1,2}\s+(.+)/); - if (h2) { - flushSection(); - blocks.push({ - type: "header", - text: { - type: "plain_text", - text: stripInlineMd(h2[1]).slice(0, 150), - emoji: true, - }, - }); - continue; - } - - // H3–H6 → bold section - const h3 = line.match(/^#{3,6}\s+(.+)/); - if (h3) { - flushSection(); - blocks.push({ - type: "section", - text: { type: "mrkdwn", text: `*${convertInlineMd(h3[1])}*` }, - }); - continue; - } - - // Horizontal rule → divider - if (line.match(/^[-*_]{3,}\s*$/)) { - flushSection(); - blocks.push({ type: "divider" }); - continue; - } - - // Table separator row — skip - if (line.match(/^\|[\s:|-]+\|/)) { - continue; - } - - // Table data row — flatten to text - if (line.startsWith("|")) { - const cells = line - .split("|") - .filter((c) => c.trim()) - .map((c) => convertInlineMd(c.trim())); - sectionLines.push(cells.join(" | ")); - continue; - } - - sectionLines.push(line); - } - - flushSection(); - - // Slack enforces max 50 blocks - return blocks.slice(0, 50); -} - -function markdownToPlainText(markdown: string): string { - return markdown - .replace(/^#{1,6}\s+/gm, "") - .replace(/\*\*(.+?)\*\*/g, "$1") - .replace(/\*(.+?)\*/g, "$1") - .replace(/_(.+?)_/g, "$1") - .replace(/~~(.+?)~~/g, "$1") - .replace(/\[(.+?)\]\(.+?\)/g, "$1") - .replace(/`(.+?)`/g, "$1") - .replace(/^\|[\s:|-]+\|.*$/gm, "") - .replace(/^[-*_]{3,}\s*$/gm, "") - .trim(); -} +import { + markdownToSlackBlocks, + markdownToPlainText, +} from "./slack-format"; /** * Look up the Slack bot token for a given Slack user ID. diff --git a/apps/webapp/app/services/channels/slack/slack-format.ts b/apps/webapp/app/services/channels/slack/slack-format.ts new file mode 100644 index 000000000..ef8f300cf --- /dev/null +++ b/apps/webapp/app/services/channels/slack/slack-format.ts @@ -0,0 +1,258 @@ +// --------------------------------------------------------------------------- +// Slack mrkdwn formatting utilities +// --------------------------------------------------------------------------- + +/** + * Convert common markdown inline syntax to Slack mrkdwn equivalents. + */ +export function convertInlineMd(text: string): string { + // Use a placeholder to protect bold markers from being re-processed as italic. + const BOLD_PLACEHOLDER = "\x00BOLD\x00"; + return text + .replace(/\*\*(.+?)\*\*/g, `${BOLD_PLACEHOLDER}$1${BOLD_PLACEHOLDER}`) // **bold** → placeholder + .replace(/(?") // [text](url) → + .replace(new RegExp(BOLD_PLACEHOLDER, "g"), "*"); // restore bold markers +} + +/** + * Strip all markdown inline syntax from text, leaving plain content. + */ +export function stripInlineMd(text: string): string { + return text + .replace(/\*\*(.+?)\*\*/g, "$1") + .replace(/\*(.+?)\*/g, "$1") + .replace(/_(.+?)_/g, "$1") + .replace(/~~(.+?)~~/g, "$1") + .replace(/\[(.+?)\]\(.+?\)/g, "$1") + .replace(/`(.+?)`/g, "$1"); +} + +/** + * Split text into chunks no larger than `max` characters, preferring paragraph breaks. + */ +export function chunkText(text: string, max: number): string[] { + if (text.length <= max) return [text]; + const chunks: string[] = []; + let remaining = text; + while (remaining.length > max) { + const idx = remaining.lastIndexOf("\n\n", max); + const at = idx > 0 ? idx : max; + chunks.push(remaining.slice(0, at).trim()); + remaining = remaining.slice(at).trim(); + } + if (remaining) chunks.push(remaining); + return chunks; +} + +/** + * Convert markdown list items (`- item` or `* item`) to Slack bullet points (`• item`). + * Skips content inside fenced code blocks. + */ +export function convertMarkdownListsToSlackBullets(text: string): string { + const lines = text.split("\n"); + const result: string[] = []; + let inCodeFence = false; + + for (const line of lines) { + if (line.match(/^```/)) { + inCodeFence = !inCodeFence; + result.push(line); + continue; + } + if (inCodeFence) { + result.push(line); + continue; + } + // Match list items: optional leading whitespace, then `- ` or `* ` with content. + // The {3,} horizontal rule check happens upstream; here we rely on requiring a space + // after the marker so `---` and `***` are not affected. + const listMatch = line.match(/^(\s*)[-*] (.+)/); + if (listMatch) { + result.push(`${listMatch[1]}• ${listMatch[2]}`); + } else { + result.push(line); + } + } + return result.join("\n"); +} + +/** + * Apply Slack-specific bolding to key elements in a text segment. + * Skips content inside inline code spans and Slack URL tokens to avoid corruption. + * + * Bolded patterns: + * - "Action Required" (case-insensitive) + * - Standalone priority markers: P1, P2, P3 + * - Key: Value patterns at the start of a line or after a bullet point (• ) + */ +export function boldKeyElements(text: string): string { + // Split by inline code spans (`...`) and Slack URL tokens (<...>), transform only plain segments. + const parts: string[] = []; + const safePattern = /(`[^`\n]+`|<[^>]+>)/g; + let lastIndex = 0; + let match: RegExpExecArray | null; + + while ((match = safePattern.exec(text)) !== null) { + if (match.index > lastIndex) { + parts.push(applyBoldingToSegment(text.slice(lastIndex, match.index))); + } + parts.push(match[0]); // preserve code span / URL token as-is + lastIndex = match.index + match[0].length; + } + if (lastIndex < text.length) { + parts.push(applyBoldingToSegment(text.slice(lastIndex))); + } + return parts.join(""); +} + +function applyBoldingToSegment(text: string): string { + return ( + text + // "Action Required" — whole phrase, case-insensitive + .replace(/\b(Action\s+Required)\b/gi, "*$1*") + // Priority markers P1 / P2 / P3 as standalone words + .replace(/\b(P[123])\b/g, "*$1*") + // Key: Value — capital-led word(s) at start of line followed by a colon + .replace(/^([A-Z][A-Za-z][A-Za-z\s]{0,20}):/gm, "*$1*:") + // Key: Value — capital-led word(s) right after a Slack bullet (• ) + .replace(/(• )([A-Z][A-Za-z][A-Za-z\s]{0,20}):/g, "$1*$2*:") + ); +} + +/** + * Convert a markdown string into Slack Block Kit blocks. + * + * Enhancements over raw markdown pass-through: + * - Markdown list items (`-` / `*`) become Slack bullet points (•) + * - Key phrases (Action Required, P1/P2/P3, Key: Value) are bolded + * - Code blocks are passed through without any transformation + */ +export function markdownToSlackBlocks(markdown: string): unknown[] { + const blocks: unknown[] = []; + const lines = markdown.split("\n"); + let sectionLines: string[] = []; + let codeBlockLines: string[] = []; + let inCodeFence = false; + + function flushSection() { + if (sectionLines.length === 0) return; + const raw = sectionLines.join("\n").trimEnd(); + sectionLines = []; + if (!raw.trim()) return; + // Pipeline: convert lists → inline markdown → bold key elements + const withBullets = convertMarkdownListsToSlackBullets(raw); + const withMd = convertInlineMd(withBullets); + const text = boldKeyElements(withMd); + for (const chunk of chunkText(text, 3000)) { + blocks.push({ type: "section", text: { type: "mrkdwn", text: chunk } }); + } + } + + function flushCodeBlock() { + if (codeBlockLines.length === 0) return; + const text = codeBlockLines.join("\n"); + codeBlockLines = []; + if (!text.trim()) return; + // Code blocks are passed through as-is (no list/bold transformation) + for (const chunk of chunkText(text, 3000)) { + blocks.push({ type: "section", text: { type: "mrkdwn", text: chunk } }); + } + } + + for (const line of lines) { + // Fenced code block boundary + if (line.match(/^```/)) { + if (!inCodeFence) { + flushSection(); + codeBlockLines.push(line); + inCodeFence = true; + } else { + codeBlockLines.push(line); + inCodeFence = false; + flushCodeBlock(); + } + continue; + } + + if (inCodeFence) { + codeBlockLines.push(line); + continue; + } + + // H1/H2 → header block + const h2 = line.match(/^#{1,2}\s+(.+)/); + if (h2) { + flushSection(); + blocks.push({ + type: "header", + text: { + type: "plain_text", + text: stripInlineMd(h2[1]).slice(0, 150), + emoji: true, + }, + }); + continue; + } + + // H3–H6 → bold section + const h3 = line.match(/^#{3,6}\s+(.+)/); + if (h3) { + flushSection(); + blocks.push({ + type: "section", + text: { type: "mrkdwn", text: `*${convertInlineMd(h3[1])}*` }, + }); + continue; + } + + // Horizontal rule → divider (checked before list detection) + if (line.match(/^[-*_]{3,}\s*$/)) { + flushSection(); + blocks.push({ type: "divider" }); + continue; + } + + // Table separator row — skip + if (line.match(/^\|[\s:|-]+\|/)) { + continue; + } + + // Table data row — flatten to text + if (line.startsWith("|")) { + const cells = line + .split("|") + .filter((c) => c.trim()) + .map((c) => convertInlineMd(c.trim())); + sectionLines.push(cells.join(" | ")); + continue; + } + + sectionLines.push(line); + } + + flushSection(); + // Handle unclosed code fence gracefully + if (codeBlockLines.length > 0) flushCodeBlock(); + + // Slack enforces max 50 blocks + return blocks.slice(0, 50); +} + +/** + * Convert markdown to plain text by stripping all markup. + */ +export function markdownToPlainText(markdown: string): string { + return markdown + .replace(/^#{1,6}\s+/gm, "") + .replace(/\*\*(.+?)\*\*/g, "$1") + .replace(/\*(.+?)\*/g, "$1") + .replace(/_(.+?)_/g, "$1") + .replace(/~~(.+?)~~/g, "$1") + .replace(/\[(.+?)\]\(.+?\)/g, "$1") + .replace(/`(.+?)`/g, "$1") + .replace(/^\|[\s:|-]+\|.*$/gm, "") + .replace(/^[-*_]{3,}\s*$/gm, "") + .trim(); +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 96d49c0a7..d55aa6f59 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -2917,9 +2917,6 @@ packages: '@jridgewell/source-map@0.3.6': resolution: {integrity: sha512-1ZJTZebgqllO79ue2bm3rIGud/bOe0pP5BjSRCRxxYkEZS8STV7zN84UBbiYu7jy+eCKSnVIUgoWWE/tt+shMQ==} - '@jridgewell/sourcemap-codec@1.5.0': - resolution: {integrity: sha512-gv3ZRaISU3fjPAgNsriBRqGWQL6quFx04YMPW/zD8XMLsU32mhCCbfbO6KZFLjvYpCZ8zyDEgqsgf+PwPaM7GQ==} - '@jridgewell/sourcemap-codec@1.5.5': resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==} @@ -16765,7 +16762,7 @@ snapshots: '@jridgewell/gen-mapping@0.3.8': dependencies: '@jridgewell/set-array': 1.2.1 - '@jridgewell/sourcemap-codec': 1.5.0 + '@jridgewell/sourcemap-codec': 1.5.5 '@jridgewell/trace-mapping': 0.3.25 '@jridgewell/remapping@2.3.5': @@ -16783,14 +16780,12 @@ snapshots: '@jridgewell/trace-mapping': 0.3.31 optional: true - '@jridgewell/sourcemap-codec@1.5.0': {} - '@jridgewell/sourcemap-codec@1.5.5': {} '@jridgewell/trace-mapping@0.3.25': dependencies: '@jridgewell/resolve-uri': 3.1.2 - '@jridgewell/sourcemap-codec': 1.5.0 + '@jridgewell/sourcemap-codec': 1.5.5 '@jridgewell/trace-mapping@0.3.31': dependencies: @@ -25901,7 +25896,7 @@ snapshots: magic-string@0.30.17: dependencies: - '@jridgewell/sourcemap-codec': 1.5.0 + '@jridgewell/sourcemap-codec': 1.5.5 magic-string@0.30.21: dependencies: