From f3ab51829b418413f9d135eb3ef7f7d43ebff70f Mon Sep 17 00:00:00 2001 From: Raimond Lume Date: Fri, 1 May 2026 14:31:47 +0100 Subject: [PATCH 1/2] feat(slack): use native markdown_text field for outgoing messages MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Slack now natively renders markdown via the `markdown_text` parameter on chat.postMessage / postEphemeral / update / scheduleMessage and via response_url payloads. The adapter passes markdown through directly instead of converting to mrkdwn. - Tables, headings, code fences, blockquotes, and nested lists render natively in Slack instead of falling back to ASCII / mrkdwn. - `string` and `{ raw }` messages still go to `text` (preserves literal `*`). - `{ markdown }` and `{ ast }` messages go to `markdown_text` (12k char limit). - `renderWithTableBlocks`, `toBlocksWithTable`, `mdastTableToSlackBlock`, and the AST→mrkdwn renderer (`fromAst` / `nodeToMrkdwn`) are removed. - `SlackMarkdownConverter` alias is removed; use `SlackFormatConverter`. - `renderFormatted(ast)` now returns standard markdown (was mrkdwn). - Incoming `message` events still arrive as mrkdwn and are parsed unchanged. Net -473 lines across markdown.ts and the five sender call sites. --- .changeset/slack-native-markdown.md | 22 ++ apps/docs/content/docs/api/markdown.mdx | 14 +- .../content/docs/contributing/building.mdx | 2 +- apps/docs/content/docs/posting-messages.mdx | 4 +- packages/adapter-slack/src/index.test.ts | 4 +- packages/adapter-slack/src/index.ts | 191 +--------- packages/adapter-slack/src/markdown.test.ts | 350 ++++++------------ packages/adapter-slack/src/markdown.ts | 264 ++----------- packages/integration-tests/src/slack.test.ts | 6 +- 9 files changed, 205 insertions(+), 652 deletions(-) create mode 100644 .changeset/slack-native-markdown.md diff --git a/.changeset/slack-native-markdown.md b/.changeset/slack-native-markdown.md new file mode 100644 index 00000000..d2bcca56 --- /dev/null +++ b/.changeset/slack-native-markdown.md @@ -0,0 +1,22 @@ +--- +"@chat-adapter/slack": minor +--- + +use Slack's native `markdown_text` field for outgoing markdown messages + +Slack now natively renders markdown via the `markdown_text` parameter on +`chat.postMessage`, `chat.postEphemeral`, `chat.update`, and +`chat.scheduleMessage`. The adapter passes markdown through directly instead +of converting to mrkdwn, so tables, headings, fenced code blocks, blockquotes, +and other rich formatting now render natively in Slack. + +- Tables are rendered by Slack natively (no more ASCII-table fallback or + Block Kit `table` block fabrication). +- Plain `string` and `{ raw }` messages still go to the `text` field so + literal `*` / `_` characters are preserved. +- `markdown_text` has a 12,000 character limit (vs. ~40,000 for `text`). +- The deprecated `SlackMarkdownConverter` alias has been removed; use + `SlackFormatConverter` instead. +- `renderFormatted(ast)` now returns standard markdown instead of mrkdwn. +- Incoming `message` events are unchanged — they still arrive as mrkdwn + and are parsed as before. diff --git a/apps/docs/content/docs/api/markdown.mdx b/apps/docs/content/docs/api/markdown.mdx index 19cc6b0a..90e8b01c 100644 --- a/apps/docs/content/docs/api/markdown.mdx +++ b/apps/docs/content/docs/api/markdown.mdx @@ -241,7 +241,7 @@ const value = getNodeValue(node); // string | undefined ### tableToAscii -Render an mdast `Table` node as a padded ASCII table string. Used by adapters that lack native table support (Slack, Google Chat, Discord, Telegram). +Render an mdast `Table` node as a padded ASCII table string. Used by adapters that lack native table support (Google Chat, Discord, Telegram). ```typescript import { parseMarkdown, tableToAscii, isTableNode } from "chat"; @@ -280,17 +280,21 @@ The SDK uses mdast as the canonical format and each adapter converts it to the p | Feature | Slack | Teams | Google Chat | |---------|-------|-------|-------------| -| Bold | `*text*` | `**text**` | `*text*` | +| Bold | `**text**` | `**text**` | `*text*` | | Italic | `_text_` | `_text_` | `_text_` | -| Strikethrough | `~text~` | `~~text~~` | `~text~` | +| Strikethrough | `~~text~~` | `~~text~~` | `~text~` | | Code | `` `code` `` | `` `code` `` | `` `code` `` | | Code blocks | ```` ``` ```` | ```` ``` ```` | ```` ``` ```` | -| Links | `` | `[text](url)` | `[text](url)` | +| Links | `[text](url)` | `[text](url)` | `[text](url)` | | Lists | Supported | Supported | Supported | | Blockquotes | `>` | `>` | Simulated with `>` prefix | -| Tables | ASCII fallback | Native GFM | ASCII fallback | +| Tables | Native (markdown_text) | Native GFM | ASCII fallback | | Mentions | `<@USER>` | `name` | `` | + +Slack accepts standard markdown via the `markdown_text` field on `chat.postMessage` and friends, so the SDK passes markdown through directly. Incoming Slack messages still arrive as legacy mrkdwn (`*bold*`, ``) and are parsed transparently. If you need to send mrkdwn yourself, use `{ raw: "..." }`. + + You don't need to worry about these differences when using the SDK — the AST builders and `parseMarkdown` handle conversion automatically. This table is useful if you're working with `raw` platform payloads or debugging formatting issues. diff --git a/apps/docs/content/docs/contributing/building.mdx b/apps/docs/content/docs/contributing/building.mdx index 24719769..d920b7c0 100644 --- a/apps/docs/content/docs/contributing/building.mdx +++ b/apps/docs/content/docs/contributing/building.mdx @@ -535,7 +535,7 @@ export class MatrixFormatConverter extends BaseFormatConverter { } ``` -For platforms with non-standard formatting (e.g., Slack's `mrkdwn`), implement custom parsing in `toAst()` and rendering in `fromAst()`. See the [Discord adapter](https://github.com/vercel/chat/blob/main/packages/adapter-discord/src/markdown.ts) for an example of handling platform-specific mention syntax. +For platforms with non-standard formatting, implement custom parsing in `toAst()` and rendering in `fromAst()`. See the [Discord adapter](https://github.com/vercel/chat/blob/main/packages/adapter-discord/src/markdown.ts) for an example of handling platform-specific mention syntax. ## Optional methods diff --git a/apps/docs/content/docs/posting-messages.mdx b/apps/docs/content/docs/posting-messages.mdx index f9dd9f34..c8f4af6f 100644 --- a/apps/docs/content/docs/posting-messages.mdx +++ b/apps/docs/content/docs/posting-messages.mdx @@ -24,7 +24,7 @@ This sends the string directly without any formatting conversion. ## Markdown -Pass a `{ markdown }` object to have the SDK convert standard markdown to each platform's native format — mrkdwn for Slack, HTML for Teams, and so on. +Pass a `{ markdown }` object to have the SDK render standard markdown on each platform — passed through to Slack's native `markdown_text` field, converted to HTML for Teams, and so on. ```typescript title="lib/bot.ts" lineNumbers await thread.post({ @@ -32,7 +32,7 @@ await thread.post({ }); ``` -Under the hood, the SDK parses the markdown into an mdast AST, then each adapter converts it to the platform's format. +Under the hood, the SDK parses the markdown into an mdast AST, then each adapter handles it natively or converts it to the platform's format. ## AST builders diff --git a/packages/adapter-slack/src/index.test.ts b/packages/adapter-slack/src/index.test.ts index b9639332..575dc37c 100644 --- a/packages/adapter-slack/src/index.test.ts +++ b/packages/adapter-slack/src/index.test.ts @@ -1326,7 +1326,7 @@ describe("renderFormatted", () => { logger: mockLogger, }); - it("renders AST to Slack mrkdwn format", () => { + it("renders AST to standard markdown (Slack now accepts markdown natively)", () => { const ast = { type: "root" as const, children: [ @@ -1343,7 +1343,7 @@ describe("renderFormatted", () => { }; const result = adapter.renderFormatted(ast); - expect(result).toBe("*bold*"); + expect(result.trim()).toBe("**bold**"); }); }); diff --git a/packages/adapter-slack/src/index.ts b/packages/adapter-slack/src/index.ts index 6846176b..3715888d 100644 --- a/packages/adapter-slack/src/index.ts +++ b/packages/adapter-slack/src/index.ts @@ -37,7 +37,6 @@ import type { PlanModel, RawMessage, ReactionEvent, - Root, ScheduledMessage, SelectOptionElement, StreamChunk, @@ -50,7 +49,6 @@ import type { import { ConsoleLogger, - convertEmojiPlaceholders, defaultEmojiResolver, isJSX, Message, @@ -59,7 +57,7 @@ import { toModalElement, toPlainText, } from "chat"; -import { cardToBlockKit, cardToFallbackText, type SlackBlock } from "./cards"; +import { cardToBlockKit, cardToFallbackText } from "./cards"; import type { EncryptedTokenData } from "./crypto"; import { decodeKey, @@ -3039,38 +3037,6 @@ export class SlackAdapter implements Adapter { return message; } - /** - * Try to render a message using native Slack table blocks. - * Returns blocks + fallback text if the message contains tables, null otherwise. - */ - private renderWithTableBlocks( - message: AdapterPostableMessage - ): { text: string; blocks: SlackBlock[] } | null { - let ast: Root | null = null; - if (typeof message === "object" && message !== null) { - if ("ast" in message) { - ast = (message as { ast: Root }).ast; - } else if ("markdown" in message) { - ast = parseMarkdown((message as { markdown: string }).markdown); - } - } - if (!ast) { - return null; - } - - const blocks = this.formatConverter.toBlocksWithTable(ast); - if (!blocks) { - return null; - } - - // Use regular rendering as fallback text for notifications - const fallbackText = convertEmojiPlaceholders( - this.formatConverter.renderPostable(message), - "slack" - ); - return { text: fallbackText, blocks }; - } - async postMessage( threadId: string, _message: AdapterPostableMessage @@ -3144,55 +3110,19 @@ export class SlackAdapter implements Adapter { }; } - // Check for tables in markdown/AST messages → use native table blocks - const tableResult = this.renderWithTableBlocks(message); - if (tableResult) { - this.logger.debug("Slack API: chat.postMessage (table blocks)", { - channel, - threadTs, - blockCount: tableResult.blocks.length, - }); - - const result = await this.client.chat.postMessage( - await this.withToken({ - channel, - thread_ts: threadTs, - text: tableResult.text, - blocks: tableResult.blocks, - unfurl_links: false, - unfurl_media: false, - }) - ); - - this.logger.debug("Slack API: chat.postMessage response", { - messageId: result.ts, - ok: result.ok, - }); - - return { - id: result.ts as string, - threadId, - raw: result, - }; - } - - // Regular text message - const text = convertEmojiPlaceholders( - this.formatConverter.renderPostable(message), - "slack" - ); + const payload = this.formatConverter.toSlackPayload(message); this.logger.debug("Slack API: chat.postMessage", { channel, threadTs, - textLength: text.length, + payloadKey: "markdown_text" in payload ? "markdown_text" : "text", }); const result = await this.client.chat.postMessage( await this.withToken({ channel, thread_ts: threadTs, - text, + ...payload, unfurl_links: false, unfurl_media: false, }) @@ -3261,50 +3191,13 @@ export class SlackAdapter implements Adapter { }; } - // Check for tables in markdown/AST messages → use native table blocks - const tableResult = this.renderWithTableBlocks(message); - if (tableResult) { - this.logger.debug("Slack API: chat.postEphemeral (table blocks)", { - channel, - threadTs, - userId, - blockCount: tableResult.blocks.length, - }); - - const result = await this.client.chat.postEphemeral( - await this.withToken({ - channel, - thread_ts: threadTs, - user: userId, - text: tableResult.text, - blocks: tableResult.blocks, - }) - ); - - this.logger.debug("Slack API: chat.postEphemeral response", { - messageTs: result.message_ts, - ok: result.ok, - }); - - return { - id: result.message_ts || "", - threadId, - usedFallback: false, - raw: result, - }; - } - - // Regular text message - const text = convertEmojiPlaceholders( - this.formatConverter.renderPostable(message), - "slack" - ); + const payload = this.formatConverter.toSlackPayload(message); this.logger.debug("Slack API: chat.postEphemeral", { channel, threadTs, userId, - textLength: text.length, + payloadKey: "markdown_text" in payload ? "markdown_text" : "text", }); const result = await this.client.chat.postEphemeral( @@ -3312,7 +3205,7 @@ export class SlackAdapter implements Adapter { channel, thread_ts: threadTs, user: userId, - text, + ...payload, }) ); @@ -3408,17 +3301,13 @@ export class SlackAdapter implements Adapter { }; } - // Regular text message - const text = convertEmojiPlaceholders( - this.formatConverter.renderPostable(message), - "slack" - ); + const payload = this.formatConverter.toSlackPayload(message); this.logger.debug("Slack API: chat.scheduleMessage", { channel, threadTs, postAt: postAtUnix, - textLength: text.length, + payloadKey: "markdown_text" in payload ? "markdown_text" : "text", }); const result = await this.client.chat.scheduleMessage({ @@ -3426,7 +3315,7 @@ export class SlackAdapter implements Adapter { channel, thread_ts: threadTs, post_at: postAtUnix, - text, + ...payload, unfurl_links: false, unfurl_media: false, }); @@ -3639,53 +3528,19 @@ export class SlackAdapter implements Adapter { }; } - // Check for tables in markdown/AST messages → use native table blocks - const tableResult = this.renderWithTableBlocks(message); - if (tableResult) { - this.logger.debug("Slack API: chat.update (table blocks)", { - channel, - messageId, - blockCount: tableResult.blocks.length, - }); - - const result = await this.client.chat.update( - await this.withToken({ - channel, - ts: messageId, - text: tableResult.text, - blocks: tableResult.blocks, - }) - ); - - this.logger.debug("Slack API: chat.update response", { - messageId: result.ts, - ok: result.ok, - }); - - return { - id: result.ts as string, - threadId, - raw: result, - }; - } - - // Regular text message - const text = convertEmojiPlaceholders( - this.formatConverter.renderPostable(message), - "slack" - ); + const payload = this.formatConverter.toSlackPayload(message); this.logger.debug("Slack API: chat.update", { channel, messageId, - textLength: text.length, + payloadKey: "markdown_text" in payload ? "markdown_text" : "text", }); const result = await this.client.chat.update( await this.withToken({ channel, ts: messageId, - text, + ...payload, }) ); @@ -4982,22 +4837,10 @@ export class SlackAdapter implements Adapter { blocks: cardToBlockKit(card), }; } else { - const tableResult = this.renderWithTableBlocks(message); - if (tableResult) { - payload = { - replace_original: true, - text: tableResult.text, - blocks: tableResult.blocks, - }; - } else { - payload = { - replace_original: true, - text: convertEmojiPlaceholders( - this.formatConverter.renderPostable(message), - "slack" - ), - }; - } + payload = { + replace_original: true, + ...this.formatConverter.toSlackPayload(message), + }; } if (options?.threadTs) { payload.thread_ts = options.threadTs; diff --git a/packages/adapter-slack/src/markdown.test.ts b/packages/adapter-slack/src/markdown.test.ts index 62fd331a..72072cd5 100644 --- a/packages/adapter-slack/src/markdown.test.ts +++ b/packages/adapter-slack/src/markdown.test.ts @@ -1,50 +1,8 @@ -import { parseMarkdown } from "chat"; import { describe, expect, it } from "vitest"; -import { SlackMarkdownConverter } from "./markdown"; +import { SlackFormatConverter } from "./markdown"; -describe("SlackMarkdownConverter", () => { - const converter = new SlackMarkdownConverter(); - - describe("fromMarkdown (markdown -> mrkdwn)", () => { - it("should convert bold", () => { - expect(converter.fromMarkdown("Hello **world**!")).toBe("Hello *world*!"); - }); - - it("should convert italic", () => { - expect(converter.fromMarkdown("Hello _world_!")).toBe("Hello _world_!"); - }); - - it("should convert strikethrough", () => { - expect(converter.fromMarkdown("Hello ~~world~~!")).toBe("Hello ~world~!"); - }); - - it("should convert links", () => { - expect(converter.fromMarkdown("Check [this](https://example.com)")).toBe( - "Check " - ); - }); - - it("should preserve inline code", () => { - expect(converter.fromMarkdown("Use `const x = 1`")).toBe( - "Use `const x = 1`" - ); - }); - - it("should handle code blocks", () => { - const input = "```js\nconst x = 1;\n```"; - const output = converter.fromMarkdown(input); - expect(output).toContain("```"); - expect(output).toContain("const x = 1;"); - }); - - it("should handle mixed formatting", () => { - const input = "**Bold** and _italic_ and [link](https://x.com)"; - const output = converter.fromMarkdown(input); - expect(output).toContain("*Bold*"); - expect(output).toContain("_italic_"); - expect(output).toContain(""); - }); - }); +describe("SlackFormatConverter", () => { + const converter = new SlackFormatConverter(); describe("toMarkdown (mrkdwn -> markdown)", () => { it("should convert bold", () => { @@ -81,70 +39,134 @@ describe("SlackMarkdownConverter", () => { }); }); - describe("mentions", () => { - it("should not double-wrap mentions already in <@user> format", () => { - // renderPostable with string containing existing Slack mention - expect(converter.renderPostable("Hey <@U12345>. Please select")).toBe( - "Hey <@U12345>. Please select" - ); + describe("toSlackPayload", () => { + it("routes plain strings to text (preserves literal markdown chars)", () => { + expect(converter.toSlackPayload("Use *foo* literally")).toEqual({ + text: "Use *foo* literally", + }); }); - it("should not double-wrap mentions in markdown input", () => { - expect( - converter.renderPostable({ markdown: "Hey <@U12345>. Please select" }) - ).toBe("Hey <@U12345>. Please select"); + it("routes raw strings to text", () => { + expect(converter.toSlackPayload({ raw: "*already mrkdwn*" })).toEqual({ + text: "*already mrkdwn*", + }); }); - it("should still convert bare @mentions to Slack format", () => { - expect(converter.renderPostable("Hey @george. Please select")).toBe( - "Hey <@george>. Please select" + it("routes markdown to markdown_text", () => { + expect( + converter.toSlackPayload({ markdown: "## Heading\n\n- a\n- b" }) + ).toEqual({ markdown_text: "## Heading\n\n- a\n- b" }); + }); + + it("routes ast to markdown_text via stringifyMarkdown", () => { + const ast = { + type: "root" as const, + children: [ + { + type: "paragraph" as const, + children: [ + { + type: "strong" as const, + children: [{ type: "text" as const, value: "bold" }], + }, + ], + }, + ], + }; + const result = converter.toSlackPayload({ ast }); + expect(result).toHaveProperty("markdown_text"); + expect((result as { markdown_text: string }).markdown_text).toContain( + "**bold**" ); }); - it("should convert bare @mentions in markdown", () => { - expect( - converter.renderPostable({ markdown: "Hey @george. Please select" }) - ).toBe("Hey <@george>. Please select"); + it("preserves tables when rendering ast to markdown_text", () => { + const ast = { + type: "root" as const, + children: [ + { + type: "table" as const, + align: [null, null] as Array<"left" | "right" | "center" | null>, + children: [ + { + type: "tableRow" as const, + children: [ + { + type: "tableCell" as const, + children: [{ type: "text" as const, value: "A" }], + }, + { + type: "tableCell" as const, + children: [{ type: "text" as const, value: "B" }], + }, + ], + }, + { + type: "tableRow" as const, + children: [ + { + type: "tableCell" as const, + children: [{ type: "text" as const, value: "1" }], + }, + { + type: "tableCell" as const, + children: [{ type: "text" as const, value: "2" }], + }, + ], + }, + ], + }, + ], + }; + const result = converter.toSlackPayload({ ast }); + expect(result).toHaveProperty("markdown_text"); + const text = (result as { markdown_text: string }).markdown_text; + expect(text).toContain("| A | B |"); + expect(text).toContain("| 1 | 2 |"); }); + }); - it("should not double-wrap mentions via fromMarkdown", () => { - expect(converter.fromMarkdown("Hey <@U12345>")).toBe("Hey <@U12345>"); + describe("mentions", () => { + it("does not double-wrap existing <@U123> mentions in plain strings", () => { + expect(converter.toSlackPayload("Hey <@U12345>. Please select")).toEqual({ + text: "Hey <@U12345>. Please select", + }); }); - it("should not mangle email addresses in plain strings", () => { + it("does not double-wrap existing mentions in markdown", () => { expect( - converter.renderPostable("Contact user@example.com for help") - ).toBe("Contact user@example.com for help"); + converter.toSlackPayload({ markdown: "Hey <@U12345>. Please select" }) + ).toEqual({ markdown_text: "Hey <@U12345>. Please select" }); + }); + + it("rewrites bare @mentions in plain strings", () => { + expect(converter.toSlackPayload("Hey @george. Please select")).toEqual({ + text: "Hey <@george>. Please select", + }); }); - it("should not mangle email addresses in markdown input", () => { - // GFM auto-link turns the email into a mailto link; key point is the - // `@example` is not rewritten as a `<@example>` Slack mention. + it("rewrites bare @mentions in markdown", () => { expect( - converter.renderPostable({ - markdown: "Contact user@example.com for help", - }) - ).toBe("Contact for help"); + converter.toSlackPayload({ markdown: "Hey @george. Please select" }) + ).toEqual({ markdown_text: "Hey <@george>. Please select" }); }); - it("should not mangle mailto links in plain strings", () => { - expect(converter.renderPostable("Email ")).toBe( - "Email " - ); + it("does not mangle email addresses in plain strings", () => { + expect( + converter.toSlackPayload("Contact user@example.com for help") + ).toEqual({ text: "Contact user@example.com for help" }); }); - it("should not mangle email addresses inside markdown link text", () => { + it("does not mangle mailto links", () => { expect( - converter.fromMarkdown( - "Email [user@example.com](mailto:user@example.com)" - ) - ).toBe("Email "); + converter.toSlackPayload("Email ") + ).toEqual({ text: "Email " }); }); - it("should still convert mentions adjacent to non-word punctuation", () => { - expect(converter.renderPostable("(cc @george, @anne)")).toBe( - "(cc <@george>, <@anne>)" - ); + it("converts mentions adjacent to non-word punctuation", () => { + expect(converter.toSlackPayload("(cc @george, @anne)")).toEqual({ + text: "(cc <@george>, <@anne>)", + }); }); }); @@ -176,162 +198,8 @@ describe("SlackMarkdownConverter", () => { expect(result).toContain("italic"); expect(result).toContain("link"); expect(result).toContain("user"); - // Should not contain formatting characters expect(result).not.toContain("*"); expect(result).not.toContain("<"); }); }); - - describe("table rendering", () => { - it("should render markdown tables as code blocks in fromMarkdown", () => { - const result = converter.fromMarkdown( - "| Name | Age |\n|------|-----|\n| Alice | 30 |" - ); - expect(result).toContain("```"); - expect(result).toContain("Name"); - expect(result).toContain("Age"); - expect(result).toContain("Alice"); - expect(result).toContain("30"); - }); - - it("should preserve table structure in code block", () => { - const result = converter.fromMarkdown("| A | B |\n|---|---|\n| 1 | 2 |"); - // Should be wrapped in code fences - expect(result.startsWith("```\n")).toBe(true); - expect(result.endsWith("\n```")).toBe(true); - }); - }); - - describe("toBlocksWithTable", () => { - it("should return null when AST has no tables", () => { - const ast = converter.toAst("Hello world"); - expect(converter.toBlocksWithTable(ast)).toBeNull(); - }); - - it("should return native table block for a markdown table", () => { - const ast = converter.toAst( - "| Name | Age |\n|------|-----|\n| Alice | 30 |" - ); - const blocks = converter.toBlocksWithTable(ast); - expect(blocks).toHaveLength(1); - expect(blocks?.[0].type).toBe("table"); - expect(blocks?.[0].rows).toEqual([ - [ - { type: "raw_text", text: "Name" }, - { type: "raw_text", text: "Age" }, - ], - [ - { type: "raw_text", text: "Alice" }, - { type: "raw_text", text: "30" }, - ], - ]); - }); - - it("should include surrounding text as section blocks", () => { - const markdown = - "Here are the results:\n\n| A | B |\n|---|---|\n| 1 | 2 |\n\nAll done."; - const ast = converter.toAst(markdown); - const blocks = converter.toBlocksWithTable(ast); - expect(blocks).toHaveLength(3); - expect(blocks?.[0].type).toBe("section"); - expect(blocks?.[0].text.text).toContain("Here are the results"); - expect(blocks?.[1].type).toBe("table"); - expect(blocks?.[2].type).toBe("section"); - expect(blocks?.[2].text.text).toContain("All done"); - }); - - it("should use native block for first table and ASCII for second", () => { - const markdown = - "| A | B |\n|---|---|\n| 1 | 2 |\n\n| C | D |\n|---|---|\n| 3 | 4 |"; - const ast = converter.toAst(markdown); - const blocks = converter.toBlocksWithTable(ast); - expect(blocks).toHaveLength(2); - expect(blocks?.[0].type).toBe("table"); - // Second table falls back to ASCII in section - expect(blocks?.[1].type).toBe("section"); - expect(blocks?.[1].text.text).toContain("```"); - }); - - it("should replace empty table cells with a space to satisfy Slack API", () => { - const ast = converter.toAst( - "| Kind | Label |\n|------|-------|\n| FORM | Form Submission |\n| and more... | |" - ); - const blocks = converter.toBlocksWithTable(ast); - const tableBlock = blocks?.[0]; - expect(tableBlock?.type).toBe("table"); - // Every cell must have non-empty text (Slack rejects empty strings) - for (const row of tableBlock?.rows ?? []) { - for (const cell of row) { - expect(cell.text.length).toBeGreaterThan(0); - } - } - // The empty cell should be a space - expect(tableBlock?.rows[2][1].text).toBe(" "); - }); - - it("should handle empty header cells with parseMarkdown (production path)", () => { - // This tests the actual production code path where parseMarkdown is used - // instead of toAst (which goes through Slack mrkdwn conversion first) - const markdown = - "Here is a table:\n\n| | Header2 |\n|---------|----------|\n| Data1 | Data2 |"; - const ast = parseMarkdown(markdown); - const blocks = converter.toBlocksWithTable(ast); - expect(blocks).toHaveLength(2); // section + table - expect(blocks?.[0].type).toBe("section"); - expect(blocks?.[1].type).toBe("table"); - - const tableBlock = blocks?.[1]; - // First row, first cell (empty header) should be a space - expect(tableBlock?.rows[0][0].text).toBe(" "); - // All cells must have non-empty text - for (const row of tableBlock?.rows ?? []) { - for (const cell of row) { - expect(cell.text.length).toBeGreaterThan(0); - } - } - }); - }); - - describe("nested lists", () => { - it("should indent nested unordered lists", () => { - const result = converter.fromMarkdown( - "- parent\n - child 1\n - child 2" - ); - expect(result).toBe("• parent\n • child 1\n • child 2"); - }); - - it("should indent nested ordered lists", () => { - const result = converter.fromMarkdown( - "1. first\n 1. sub-first\n 2. sub-second\n2. second" - ); - expect(result).toContain("1. first"); - expect(result).toContain(" 1. sub-first"); - expect(result).toContain(" 2. sub-second"); - expect(result).toContain("2. second"); - }); - - it("should handle deeply nested lists", () => { - const result = converter.fromMarkdown( - "- level 1\n - level 2\n - level 3" - ); - expect(result).toContain("• level 1"); - expect(result).toContain(" • level 2"); - expect(result).toContain(" • level 3"); - }); - - it("should keep sibling items at the same indent level", () => { - const result = converter.fromMarkdown("- item 1\n- item 2\n- item 3"); - expect(result).toBe("• item 1\n• item 2\n• item 3"); - }); - - it("should handle mixed ordered and unordered nesting", () => { - const result = converter.fromMarkdown( - "1. first\n - sub a\n - sub b\n2. second" - ); - expect(result).toContain("1. first"); - expect(result).toContain(" • sub a"); - expect(result).toContain(" • sub b"); - expect(result).toContain("2. second"); - }); - }); }); diff --git a/packages/adapter-slack/src/markdown.ts b/packages/adapter-slack/src/markdown.ts index aaa7de9b..d89e97fc 100644 --- a/packages/adapter-slack/src/markdown.ts +++ b/packages/adapter-slack/src/markdown.ts @@ -1,85 +1,43 @@ /** - * Slack-specific format conversion using AST-based parsing. + * Slack format conversion. * - * Slack uses "mrkdwn" format which is similar but not identical to markdown: - * - Bold: *text* (not **text**) - * - Italic: _text_ (same) - * - Strikethrough: ~text~ (not ~~text~~) - * - Links: (not [text](url)) - * - User mentions: <@U123> - * - Channel mentions: <#C123|name> + * Outgoing: Slack now natively renders markdown via the `markdown_text` field + * on chat.postMessage / postEphemeral / update / scheduleMessage and via + * response_url payloads. We pass markdown through and let Slack handle it. + * + * Incoming: Slack `message` events still deliver text as mrkdwn + * (`*bold*`, `<@U123>`, ``), so the toAst parser stays. */ import { type AdapterPostableMessage, BaseFormatConverter, - type Content, - getNodeChildren, - isBlockquoteNode, - isCodeNode, - isDeleteNode, - isEmphasisNode, - isInlineCodeNode, - isLinkNode, - isListNode, - isParagraphNode, - isStrongNode, - isTableNode, - isTextNode, - type MdastTable, + convertEmojiPlaceholders, parseMarkdown, type Root, - tableToAscii, + stringifyMarkdown, } from "chat"; -import type { SlackBlock } from "./cards"; // Match bare @mentions (e.g. "@george") to rewrite as Slack's `<@george>`. // The lookbehind excludes `<` (already-formatted mentions like `<@U123>`) and // any word character, so email addresses like `user@example.com` are left alone. const BARE_MENTION_REGEX = /(? - */ - private convertMentionsToSlack(text: string): string { - return text.replace(BARE_MENTION_REGEX, "<@$1>"); - } - - /** - * Override renderPostable to convert @mentions in plain strings. - */ - override renderPostable(message: AdapterPostableMessage): string { - if (typeof message === "string") { - return this.convertMentionsToSlack(message); - } - if ("raw" in message) { - return this.convertMentionsToSlack(message.raw); - } - if ("markdown" in message) { - return this.fromAst(parseMarkdown(message.markdown)); - } - if ("ast" in message) { - return this.fromAst(message.ast); - } - return ""; - } +export type SlackTextPayload = { text: string } | { markdown_text: string }; +export class SlackFormatConverter extends BaseFormatConverter { /** - * Render an AST to Slack mrkdwn format. + * Render an AST to standard markdown. Slack accepts this directly via + * `markdown_text` and the `markdown` block. */ fromAst(ast: Root): string { - return this.fromAstWithNodeConverter(ast, (node) => - this.nodeToMrkdwn(node) - ); + return stringifyMarkdown(ast); } /** - * Parse Slack mrkdwn into an AST. + * Parse Slack mrkdwn into an AST. Used for incoming `message` events. */ toAst(mrkdwn: string): Root { - // Convert Slack mrkdwn to standard markdown string, then parse let markdown = mrkdwn; // User mentions: <@U123|name> -> @name or <@U123> -> @U123 @@ -99,8 +57,7 @@ export class SlackFormatConverter extends BaseFormatConverter { // Bare links: -> url markdown = markdown.replace(/<(https?:\/\/[^<>]+)>/g, "$1"); - // Bold: *text* -> **text** (but be careful with emphasis) - // This is tricky because Slack uses * for bold, not emphasis + // Bold: *text* -> **text** (Slack uses single * for bold) markdown = markdown.replace(/(? ~~text~~ @@ -110,178 +67,37 @@ export class SlackFormatConverter extends BaseFormatConverter { } /** - * Convert AST to Slack blocks, using a native table block for the first table. - * Returns null if the AST contains no tables (caller should use regular text). - * Slack allows at most one table block per message; additional tables use ASCII. + * Build the Slack API payload fields for a message. + * + * - `string` / `{ raw }` → `{ text }` (plain — preserves literal `*`, `_`, etc.) + * - `{ markdown }` / `{ ast }` → `{ markdown_text }` (Slack renders natively) + * + * Bare `@user` mentions are rewritten to `<@user>` and `:emoji:` placeholders + * are normalized for Slack in all branches. + * + * Note: `markdown_text` has a 12,000 character limit; `text` allows ~40,000. + * Note: `markdown_text` is mutually exclusive with `text` and `blocks`. */ - toBlocksWithTable(ast: Root): SlackBlock[] | null { - const hasTable = ast.children.some((node) => isTableNode(node as Content)); - if (!hasTable) { - return null; - } - - const blocks: SlackBlock[] = []; - let usedNativeTable = false; - let textBuffer: string[] = []; - - const flushText = () => { - if (textBuffer.length > 0) { - const text = textBuffer.join("\n\n"); - if (text.trim()) { - blocks.push({ - type: "section", - text: { type: "mrkdwn", text }, - }); - } - textBuffer = []; - } - }; - - for (const child of ast.children) { - const node = child as Content; - if (isTableNode(node)) { - flushText(); - if (usedNativeTable) { - // Additional tables fall back to ASCII in a code block - blocks.push({ - type: "section", - text: { - type: "mrkdwn", - text: `\`\`\`\n${tableToAscii(node)}\n\`\`\``, - }, - }); - } else { - blocks.push( - mdastTableToSlackBlock(node, this.nodeToMrkdwn.bind(this)) - ); - usedNativeTable = true; - } - } else { - textBuffer.push(this.nodeToMrkdwn(node)); - } - } - - flushText(); - return blocks; - } - - private nodeToMrkdwn(node: Content): string { - // Use type guards for type-safe node handling - if (isParagraphNode(node)) { - return getNodeChildren(node) - .map((child) => this.nodeToMrkdwn(child)) - .join(""); - } - - if (isTextNode(node)) { - // Convert @mentions to Slack format <@mention> - return node.value.replace(BARE_MENTION_REGEX, "<@$1>"); - } - - if (isStrongNode(node)) { - // Markdown **text** -> Slack *text* - const content = getNodeChildren(node) - .map((child) => this.nodeToMrkdwn(child)) - .join(""); - return `*${content}*`; - } - - if (isEmphasisNode(node)) { - // Both use _text_ - const content = getNodeChildren(node) - .map((child) => this.nodeToMrkdwn(child)) - .join(""); - return `_${content}_`; - } - - if (isDeleteNode(node)) { - // Markdown ~~text~~ -> Slack ~text~ - const content = getNodeChildren(node) - .map((child) => this.nodeToMrkdwn(child)) - .join(""); - return `~${content}~`; - } - - if (isInlineCodeNode(node)) { - return `\`${node.value}\``; - } - - if (isCodeNode(node)) { - return `\`\`\`${node.lang || ""}\n${node.value}\n\`\`\``; - } - - if (isLinkNode(node)) { - const linkText = getNodeChildren(node) - .map((child) => this.nodeToMrkdwn(child)) - .join(""); - // Markdown [text](url) -> Slack - return `<${node.url}|${linkText}>`; - } - - if (isBlockquoteNode(node)) { - return getNodeChildren(node) - .map((child) => `> ${this.nodeToMrkdwn(child)}`) - .join("\n"); - } - - if (isListNode(node)) { - return this.renderList(node, 0, (child) => this.nodeToMrkdwn(child), "•"); + toSlackPayload(message: AdapterPostableMessage): SlackTextPayload { + if (typeof message === "string") { + return { text: this.finalize(message) }; } - - if (node.type === "break") { - return "\n"; + if ("raw" in message) { + return { text: this.finalize(message.raw) }; } - - if (node.type === "thematicBreak") { - return "---"; + if ("markdown" in message) { + return { markdown_text: this.finalize(message.markdown) }; } - - if (isTableNode(node)) { - return `\`\`\`\n${tableToAscii(node)}\n\`\`\``; + if ("ast" in message) { + return { markdown_text: this.finalize(stringifyMarkdown(message.ast)) }; } - - return this.defaultNodeToText(node, (child) => this.nodeToMrkdwn(child)); + return { text: "" }; } -} -/** - * Convert an mdast Table node to a Slack table block. - * Uses the table block schema: first row = headers, cells are raw_text, - * column_settings carries alignment from mdast. - * @see https://docs.slack.dev/reference/block-kit/blocks/table-block/ - */ -function mdastTableToSlackBlock( - node: MdastTable, - cellConverter: (node: Content) => string -): SlackBlock { - const rows: Array> = []; - - for (const row of node.children) { - const cells = getNodeChildren(row).map((cell) => { - // Convert cell children to text, defaulting to space if empty. - // Slack API requires text to be at least 1 character. - // Use explicit length check rather than falsy check to be defensive - // against edge cases (e.g., unusual whitespace or encoding issues). - const rawText = getNodeChildren(cell).map(cellConverter).join(""); - const text = rawText.length > 0 ? rawText : " "; - return { type: "raw_text" as const, text }; - }); - rows.push(cells); - } - - const block: SlackBlock = { type: "table", rows }; - - if (node.align) { - const columnSettings = node.align.map( - (a: "left" | "center" | "right" | null) => ({ - align: a || "left", - }) + private finalize(text: string): string { + return convertEmojiPlaceholders( + text.replace(BARE_MENTION_REGEX, "<@$1>"), + "slack" ); - block.column_settings = columnSettings; } - - return block; } - -// Backwards compatibility alias -export { SlackFormatConverter as SlackMarkdownConverter }; diff --git a/packages/integration-tests/src/slack.test.ts b/packages/integration-tests/src/slack.test.ts index 12b7c52b..3e6a1573 100644 --- a/packages/integration-tests/src/slack.test.ts +++ b/packages/integration-tests/src/slack.test.ts @@ -361,10 +361,10 @@ describe("Slack Integration", () => { }); await tracker.waitForAll(); - // Slack uses *bold*, _italic_, and `code` + // Markdown is passed through to Slack's markdown_text field for native rendering expect(mockClient.chat.postMessage).toHaveBeenCalledWith( expect.objectContaining({ - text: expect.stringContaining("*Bold*"), + markdown_text: "**Bold** and _italic_ and `code`", }) ); }); @@ -769,7 +769,7 @@ describe("Slack Integration", () => { ); expect(mockClient.chat.postMessage).toHaveBeenCalledWith( expect.objectContaining({ - text: expect.stringContaining("Here's your file:"), + markdown_text: expect.stringContaining("Here's your file:"), }) ); }); From f33bea360e68843a3a16e8df7170d51fa764dd1d Mon Sep 17 00:00:00 2001 From: Raimond Lume Date: Tue, 5 May 2026 14:04:32 +0100 Subject: [PATCH 2/2] fix(slack): use mrkdwn fallback for response_url edits --- packages/adapter-slack/src/index.test.ts | 18 ++- packages/adapter-slack/src/index.ts | 2 +- packages/adapter-slack/src/markdown.test.ts | 18 +++ packages/adapter-slack/src/markdown.ts | 122 +++++++++++++++++++- 4 files changed, 152 insertions(+), 8 deletions(-) diff --git a/packages/adapter-slack/src/index.test.ts b/packages/adapter-slack/src/index.test.ts index 575dc37c..200aeec6 100644 --- a/packages/adapter-slack/src/index.test.ts +++ b/packages/adapter-slack/src/index.test.ts @@ -5303,16 +5303,24 @@ describe("editMessage via response_url", () => { .spyOn(global, "fetch") .mockResolvedValue(new Response("ok", { status: 200 })); - await adapter.editMessage( - "slack:C123:1234567890.000000", - ephemeralId, - "Updated text" - ); + await adapter.editMessage("slack:C123:1234567890.000000", ephemeralId, { + markdown: + "**Updated** [text](https://example.com)\n\n| A | B |\n|---|---|\n| 1 | 2 |", + }); expect(fetchSpy).toHaveBeenCalledWith( "https://hooks.slack.com/respond", expect.objectContaining({ method: "POST" }) ); + const callBody = JSON.parse(fetchSpy.mock.calls[0][1]?.body as string); + expect(callBody).toEqual( + expect.objectContaining({ + replace_original: true, + text: expect.stringContaining("*Updated* "), + }) + ); + expect(callBody.text).toContain("```"); + expect(callBody).not.toHaveProperty("markdown_text"); fetchSpy.mockRestore(); }); }); diff --git a/packages/adapter-slack/src/index.ts b/packages/adapter-slack/src/index.ts index 3715888d..dd617141 100644 --- a/packages/adapter-slack/src/index.ts +++ b/packages/adapter-slack/src/index.ts @@ -4839,7 +4839,7 @@ export class SlackAdapter implements Adapter { } else { payload = { replace_original: true, - ...this.formatConverter.toSlackPayload(message), + text: this.formatConverter.toResponseUrlText(message), }; } if (options?.threadTs) { diff --git a/packages/adapter-slack/src/markdown.test.ts b/packages/adapter-slack/src/markdown.test.ts index 72072cd5..1c81aba1 100644 --- a/packages/adapter-slack/src/markdown.test.ts +++ b/packages/adapter-slack/src/markdown.test.ts @@ -126,6 +126,24 @@ describe("SlackFormatConverter", () => { }); }); + describe("toResponseUrlText", () => { + it("renders markdown to Slack mrkdwn text", () => { + expect( + converter.toResponseUrlText({ + markdown: "**Bold** and [link](https://example.com)", + }) + ).toBe("*Bold* and "); + }); + + it("renders markdown tables as ASCII code blocks", () => { + expect( + converter.toResponseUrlText({ + markdown: "| A | B |\n|---|---|\n| 1 | 2 |", + }) + ).toContain("```\n"); + }); + }); + describe("mentions", () => { it("does not double-wrap existing <@U123> mentions in plain strings", () => { expect(converter.toSlackPayload("Hey <@U12345>. Please select")).toEqual({ diff --git a/packages/adapter-slack/src/markdown.ts b/packages/adapter-slack/src/markdown.ts index d89e97fc..eb92c2bd 100644 --- a/packages/adapter-slack/src/markdown.ts +++ b/packages/adapter-slack/src/markdown.ts @@ -2,8 +2,9 @@ * Slack format conversion. * * Outgoing: Slack now natively renders markdown via the `markdown_text` field - * on chat.postMessage / postEphemeral / update / scheduleMessage and via - * response_url payloads. We pass markdown through and let Slack handle it. + * on chat.postMessage / postEphemeral / update / scheduleMessage. We pass + * markdown through there and let Slack handle it. Interactive `response_url` + * payloads do not accept `markdown_text`, so those still use Slack mrkdwn text. * * Incoming: Slack `message` events still deliver text as mrkdwn * (`*bold*`, `<@U123>`, ``), so the toAst parser stays. @@ -12,10 +13,24 @@ import { type AdapterPostableMessage, BaseFormatConverter, + type Content, convertEmojiPlaceholders, + getNodeChildren, + isBlockquoteNode, + isCodeNode, + isDeleteNode, + isEmphasisNode, + isInlineCodeNode, + isLinkNode, + isListNode, + isParagraphNode, + isStrongNode, + isTableNode, + isTextNode, parseMarkdown, type Root, stringifyMarkdown, + tableToAscii, } from "chat"; // Match bare @mentions (e.g. "@george") to rewrite as Slack's `<@george>`. @@ -94,10 +109,113 @@ export class SlackFormatConverter extends BaseFormatConverter { return { text: "" }; } + /** + * Build text for Slack response_url payloads. + * + * Slack rejects `markdown_text` on response_url (`no_text`), so markdown/AST + * messages are rendered to Slack's legacy mrkdwn format for this surface. + */ + toResponseUrlText(message: AdapterPostableMessage): string { + if (typeof message === "string") { + return this.finalize(message); + } + if ("raw" in message) { + return this.finalize(message.raw); + } + if ("markdown" in message) { + return convertEmojiPlaceholders( + this.astToMrkdwn(parseMarkdown(message.markdown)), + "slack" + ); + } + if ("ast" in message) { + return convertEmojiPlaceholders(this.astToMrkdwn(message.ast), "slack"); + } + return ""; + } + private finalize(text: string): string { return convertEmojiPlaceholders( text.replace(BARE_MENTION_REGEX, "<@$1>"), "slack" ); } + + private astToMrkdwn(ast: Root): string { + return this.fromAstWithNodeConverter(ast, (node) => + this.nodeToMrkdwn(node) + ); + } + + private nodeToMrkdwn(node: Content): string { + if (isParagraphNode(node)) { + return getNodeChildren(node) + .map((child) => this.nodeToMrkdwn(child)) + .join(""); + } + + if (isTextNode(node)) { + return node.value.replace(BARE_MENTION_REGEX, "<@$1>"); + } + + if (isStrongNode(node)) { + const content = getNodeChildren(node) + .map((child) => this.nodeToMrkdwn(child)) + .join(""); + return `*${content}*`; + } + + if (isEmphasisNode(node)) { + const content = getNodeChildren(node) + .map((child) => this.nodeToMrkdwn(child)) + .join(""); + return `_${content}_`; + } + + if (isDeleteNode(node)) { + const content = getNodeChildren(node) + .map((child) => this.nodeToMrkdwn(child)) + .join(""); + return `~${content}~`; + } + + if (isInlineCodeNode(node)) { + return `\`${node.value}\``; + } + + if (isCodeNode(node)) { + return `\`\`\`${node.lang || ""}\n${node.value}\n\`\`\``; + } + + if (isLinkNode(node)) { + const linkText = getNodeChildren(node) + .map((child) => this.nodeToMrkdwn(child)) + .join(""); + return `<${node.url}|${linkText}>`; + } + + if (isBlockquoteNode(node)) { + return getNodeChildren(node) + .map((child) => `> ${this.nodeToMrkdwn(child)}`) + .join("\n"); + } + + if (isListNode(node)) { + return this.renderList(node, 0, (child) => this.nodeToMrkdwn(child), "•"); + } + + if (node.type === "break") { + return "\n"; + } + + if (node.type === "thematicBreak") { + return "---"; + } + + if (isTableNode(node)) { + return `\`\`\`\n${tableToAscii(node)}\n\`\`\``; + } + + return this.defaultNodeToText(node, (child) => this.nodeToMrkdwn(child)); + } }