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