diff --git a/src/slack/format.test.ts b/src/slack/format.test.ts new file mode 100644 index 000000000..05f19dd0a --- /dev/null +++ b/src/slack/format.test.ts @@ -0,0 +1,90 @@ +import { describe, expect, it } from "vitest"; + +import { markdownToSlackMrkdwn } from "./format.js"; + +describe("markdownToSlackMrkdwn", () => { + it("converts bold from double asterisks to single", () => { + const res = markdownToSlackMrkdwn("**bold text**"); + expect(res).toBe("*bold text*"); + }); + + it("preserves italic underscore format", () => { + const res = markdownToSlackMrkdwn("_italic text_"); + expect(res).toBe("_italic text_"); + }); + + it("converts strikethrough from double tilde to single", () => { + const res = markdownToSlackMrkdwn("~~strikethrough~~"); + expect(res).toBe("~strikethrough~"); + }); + + it("renders basic inline formatting together", () => { + const res = markdownToSlackMrkdwn("hi _there_ **boss** `code`"); + expect(res).toBe("hi _there_ *boss* `code`"); + }); + + it("renders inline code", () => { + const res = markdownToSlackMrkdwn("use `npm install`"); + expect(res).toBe("use `npm install`"); + }); + + it("renders fenced code blocks", () => { + const res = markdownToSlackMrkdwn("```js\nconst x = 1;\n```"); + expect(res).toBe("```\nconst x = 1;\n```"); + }); + + it("renders links with URL in parentheses", () => { + const res = markdownToSlackMrkdwn("see [docs](https://example.com)"); + expect(res).toBe("see docs (https://example.com)"); + }); + + it("escapes unsafe characters", () => { + const res = markdownToSlackMrkdwn("a & b < c > d"); + expect(res).toBe("a & b < c > d"); + }); + + it("escapes raw HTML", () => { + const res = markdownToSlackMrkdwn("nope"); + expect(res).toBe("<b>nope</b>"); + }); + + it("renders paragraphs with blank lines", () => { + const res = markdownToSlackMrkdwn("first\n\nsecond"); + expect(res).toBe("first\n\nsecond"); + }); + + it("renders bullet lists", () => { + const res = markdownToSlackMrkdwn("- one\n- two"); + expect(res).toBe("• one\n• two"); + }); + + it("renders ordered lists with numbering", () => { + const res = markdownToSlackMrkdwn("2. two\n3. three"); + expect(res).toBe("2. two\n3. three"); + }); + + it("renders headings as bold text", () => { + const res = markdownToSlackMrkdwn("# Title"); + expect(res).toBe("*Title*"); + }); + + it("renders blockquotes with escaped angle bracket", () => { + const res = markdownToSlackMrkdwn("> Quote"); + expect(res).toBe("> Quote"); + }); + + it("handles adjacent list items", () => { + const res = markdownToSlackMrkdwn("- item\n - nested"); + // markdown-it treats indented items as continuation, not nesting + expect(res).toBe("• item • nested"); + }); + + it("handles complex message with multiple elements", () => { + const res = markdownToSlackMrkdwn( + "**Important:** Check the _docs_ at [link](https://example.com)\n\n- first\n- second", + ); + expect(res).toBe( + "*Important:* Check the _docs_ at link (https://example.com)\n\n• first\n• second", + ); + }); +}); diff --git a/src/slack/format.ts b/src/slack/format.ts new file mode 100644 index 000000000..75281858d --- /dev/null +++ b/src/slack/format.ts @@ -0,0 +1,170 @@ +import MarkdownIt from "markdown-it"; + +type ListState = { + type: "bullet" | "ordered"; + index: number; +}; + +type RenderEnv = { + slackListStack?: ListState[]; + slackLinkStack?: { href: string }[]; +}; + +const md = new MarkdownIt({ + html: false, + linkify: true, + breaks: false, + typographer: false, +}); + +md.enable("strikethrough"); + +/** + * Escape special characters for Slack mrkdwn format. + * Slack requires escaping &, <, > to prevent injection. + */ +function escapeSlackMrkdwn(text: string): string { + return text + .replace(/&/g, "&") + .replace(//g, ">"); +} + +function getListStack(env: RenderEnv): ListState[] { + if (!env.slackListStack) env.slackListStack = []; + return env.slackListStack; +} + +function getLinkStack(env: RenderEnv): { href: string }[] { + if (!env.slackLinkStack) env.slackLinkStack = []; + return env.slackLinkStack; +} + +md.renderer.rules.text = (tokens, idx) => + escapeSlackMrkdwn(tokens[idx]?.content ?? ""); + +md.renderer.rules.softbreak = () => "\n"; +md.renderer.rules.hardbreak = () => "\n"; + +md.renderer.rules.paragraph_open = () => ""; +md.renderer.rules.paragraph_close = (_tokens, _idx, _opts, env) => { + const stack = getListStack(env as RenderEnv); + return stack.length ? "" : "\n\n"; +}; + +md.renderer.rules.heading_open = () => "*"; +md.renderer.rules.heading_close = () => "*\n\n"; + +md.renderer.rules.blockquote_open = () => "> "; +md.renderer.rules.blockquote_close = () => "\n"; + +md.renderer.rules.bullet_list_open = (_tokens, _idx, _opts, env) => { + getListStack(env as RenderEnv).push({ type: "bullet", index: 0 }); + return ""; +}; +md.renderer.rules.bullet_list_close = (_tokens, _idx, _opts, env) => { + getListStack(env as RenderEnv).pop(); + return ""; +}; +md.renderer.rules.ordered_list_open = (tokens, idx, _opts, env) => { + const start = Number(tokens[idx]?.attrGet("start") ?? "1"); + getListStack(env as RenderEnv).push({ type: "ordered", index: start - 1 }); + return ""; +}; +md.renderer.rules.ordered_list_close = (_tokens, _idx, _opts, env) => { + getListStack(env as RenderEnv).pop(); + return ""; +}; +md.renderer.rules.list_item_open = (_tokens, _idx, _opts, env) => { + const stack = getListStack(env as RenderEnv); + const top = stack[stack.length - 1]; + if (!top) return ""; + top.index += 1; + const indent = " ".repeat(Math.max(0, stack.length - 1)); + const prefix = top.type === "ordered" ? `${top.index}. ` : "• "; + return `${indent}${prefix}`; +}; +md.renderer.rules.list_item_close = () => "\n"; + +// Slack mrkdwn uses _text_ for italic (same as markdown) +md.renderer.rules.em_open = () => "_"; +md.renderer.rules.em_close = () => "_"; + +// Slack mrkdwn uses *text* for bold (single asterisk, not double) +md.renderer.rules.strong_open = () => "*"; +md.renderer.rules.strong_close = () => "*"; + +// Slack mrkdwn uses ~text~ for strikethrough (single tilde) +md.renderer.rules.s_open = () => "~"; +md.renderer.rules.s_close = () => "~"; + +md.renderer.rules.code_inline = (tokens, idx) => + `\`${escapeSlackMrkdwn(tokens[idx]?.content ?? "")}\``; + +md.renderer.rules.code_block = (tokens, idx) => + `\`\`\`\n${escapeSlackMrkdwn(tokens[idx]?.content ?? "")}\`\`\`\n`; + +md.renderer.rules.fence = (tokens, idx) => + `\`\`\`\n${escapeSlackMrkdwn(tokens[idx]?.content ?? "")}\`\`\`\n`; + +// Slack links use format +md.renderer.rules.link_open = (tokens, idx, _opts, env) => { + const href = tokens[idx]?.attrGet("href") ?? ""; + const stack = getLinkStack(env as RenderEnv); + stack.push({ href }); + return ""; +}; +md.renderer.rules.link_close = (_tokens, _idx, _opts, env) => { + const stack = getLinkStack(env as RenderEnv); + const link = stack.pop(); + if (link?.href) { + return ` (${escapeSlackMrkdwn(link.href)})`; + } + return ""; +}; + +md.renderer.rules.image = (tokens, idx) => { + const alt = tokens[idx]?.content ?? ""; + return escapeSlackMrkdwn(alt); +}; + +md.renderer.rules.html_block = (tokens, idx) => + escapeSlackMrkdwn(tokens[idx]?.content ?? ""); +md.renderer.rules.html_inline = (tokens, idx) => + escapeSlackMrkdwn(tokens[idx]?.content ?? ""); + +md.renderer.rules.table_open = () => ""; +md.renderer.rules.table_close = () => ""; +md.renderer.rules.thead_open = () => ""; +md.renderer.rules.thead_close = () => ""; +md.renderer.rules.tbody_open = () => ""; +md.renderer.rules.tbody_close = () => ""; +md.renderer.rules.tr_open = () => ""; +md.renderer.rules.tr_close = () => "\n"; +md.renderer.rules.th_open = () => ""; +md.renderer.rules.th_close = () => "\t"; +md.renderer.rules.td_open = () => ""; +md.renderer.rules.td_close = () => "\t"; + +md.renderer.rules.hr = () => "\n"; + +/** + * Convert standard Markdown to Slack mrkdwn format. + * + * Slack mrkdwn differences from standard Markdown: + * - Bold: *text* (single asterisk, not double) + * - Italic: _text_ (same) + * - Strikethrough: ~text~ (single tilde) + * - Code: `code` (same) + * - Links: or plain URL + * - Escape &, <, > as &, <, > + */ +export function markdownToSlackMrkdwn(markdown: string): string { + const env: RenderEnv = {}; + const rendered = md.render(markdown ?? "", env); + return rendered + .replace(/[ \t]+\n/g, "\n") + .replace(/\t+\n/g, "\n") + .replace(/\n{3,}/g, "\n\n") + .trimEnd(); +} diff --git a/src/slack/send.ts b/src/slack/send.ts index 3323186a6..077130550 100644 --- a/src/slack/send.ts +++ b/src/slack/send.ts @@ -9,6 +9,7 @@ import { logVerbose } from "../globals.js"; import { loadWebMedia } from "../web/media.js"; import type { SlackTokenSource } from "./accounts.js"; import { resolveSlackAccount } from "./accounts.js"; +import { markdownToSlackMrkdwn } from "./format.js"; import { resolveSlackBotToken } from "./token.js"; const SLACK_TEXT_LIMIT = 4000; @@ -169,7 +170,8 @@ export async function sendMessageSlack( const { channelId } = await resolveChannelId(client, recipient); const textLimit = resolveTextChunkLimit(cfg, "slack", account.accountId); const chunkLimit = Math.min(textLimit, SLACK_TEXT_LIMIT); - const chunks = chunkMarkdownText(trimmedMessage, chunkLimit); + const slackFormatted = markdownToSlackMrkdwn(trimmedMessage); + const chunks = chunkMarkdownText(slackFormatted, chunkLimit); const mediaMaxBytes = typeof account.config.mediaMaxMb === "number" ? account.config.mediaMaxMb * 1024 * 1024