From ab1ac4cafd0a8db2180d3a05aa4063f2e8c3d8f0 Mon Sep 17 00:00:00 2001 From: "roboeve[bot]" <311457182+roboeve[bot]@users.noreply.github.com> Date: Wed, 7 Oct 2026 13:59:47 +0000 Subject: [PATCH 1/7] fix(slack): parse composer lists from rich_text blocks Slack flattens composer lists in event.text to lines starting with bullet characters, which are not markdown list syntax. Build the message body from the rich_text blocks when they contain a list so message.formatted gets list nodes, including nested items. Fixes #956. --- .changeset/slack-composer-lists.md | 5 + packages/adapter-slack/src/index.test.ts | 204 ++++++++++++++++++++++- packages/adapter-slack/src/index.ts | 192 ++++++++++++++++++++- 3 files changed, 395 insertions(+), 6 deletions(-) create mode 100644 .changeset/slack-composer-lists.md diff --git a/.changeset/slack-composer-lists.md b/.changeset/slack-composer-lists.md new file mode 100644 index 000000000..3601ab971 --- /dev/null +++ b/.changeset/slack-composer-lists.md @@ -0,0 +1,5 @@ +--- +"@chat-adapter/slack": patch +--- + +Parse bulleted and numbered lists from Slack's message composer into mdast `list` nodes, including nested items. Slack flattens these lists in `event.text` to lines starting with `•` or `◦`, so the adapter now builds the message body from the `rich_text` blocks when they contain a list. Messages without a list, or with blocks other than `rich_text` and tables, still use `event.text`. diff --git a/packages/adapter-slack/src/index.test.ts b/packages/adapter-slack/src/index.test.ts index b20993300..179afe6ba 100644 --- a/packages/adapter-slack/src/index.test.ts +++ b/packages/adapter-slack/src/index.test.ts @@ -22,12 +22,13 @@ import type { Message, StateAdapter, } from "chat"; -import { Chat } from "chat"; +import { Chat, stringifyMarkdown } from "chat"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import type { SlackAdapterConfig, SlackEvent, SlackInstallation, + SlackMessageBlock, SlackThreadId, } from "./index"; import { @@ -1301,6 +1302,207 @@ describe("parseMessage", () => { ]); }); + describe("composer lists", () => { + const section = (...elements: SlackMessageBlock[]): SlackMessageBlock => ({ + type: "rich_text_section", + elements, + }); + const text = ( + value: string, + style?: SlackMessageBlock["style"] + ): SlackMessageBlock => ({ + type: "text", + text: value, + ...(style ? { style } : {}), + }); + const internals = adapter as unknown as { + parseSlackMessage( + value: SlackEvent, + threadId: string + ): Promise>; + }; + + // Shape from https://github.com/vercel/chat/issues/956: Slack flattens + // the list in `text` to `•`/`◦` lines and keeps its structure in blocks. + const listEvent: SlackEvent = { + type: "message", + channel: "D0123456789", + channel_type: "im", + user: "U0123456789", + username: "jane", + ts: "1726600000.000100", + text: "Steps:\n• one\n• two\n ◦ nested\n and ", + blocks: [ + { + type: "rich_text", + elements: [ + section(text("Steps:\n")), + { + type: "rich_text_list", + style: "bullet", + indent: 0, + elements: [section(text("one")), section(text("two"))], + }, + { + type: "rich_text_list", + style: "bullet", + indent: 1, + elements: [section(text("nested"))], + }, + section({ type: "broadcast", range: "here" }, text(" and "), { + type: "usergroup", + usergroup_id: "S0123456789", + }), + ], + }, + ], + }; + + it("builds nested lists from rich_text_list blocks in both parsing paths", async () => { + for (const message of [ + adapter.parseMessage(listEvent), + await internals.parseSlackMessage( + listEvent, + "slack:D0123456789:1726600000.000100" + ), + ]) { + expect(message.formatted.children.map((node) => node.type)).toEqual([ + "paragraph", + "list", + "paragraph", + ]); + expect(stringifyMarkdown(message.formatted)).toBe( + "Steps:\n\n* one\n* two\n * nested\n\n@here and @devs\n" + ); + expect(message.text).toBe( + "Steps:\n\none\ntwo\nnested\n\n@here and @devs" + ); + } + }); + + it("continues ordered lists across a nested list and keeps inline styles", () => { + const message = adapter.parseMessage({ + type: "message", + user: "U123", + channel: "C456", + text: "1. *first* item\n • see \n2. run `pnpm test`", + ts: "1234567890.123456", + blocks: [ + { + type: "rich_text", + elements: [ + { + type: "rich_text_list", + style: "ordered", + indent: 0, + elements: [ + section(text("first", { bold: true }), text(" item")), + ], + }, + { + type: "rich_text_list", + style: "bullet", + indent: 1, + elements: [ + section(text("see "), { + type: "link", + url: "https://example.com", + text: "docs", + }), + ], + }, + { + type: "rich_text_list", + style: "ordered", + indent: 0, + offset: 1, + elements: [ + section(text("run "), text("pnpm test", { code: true })), + ], + }, + ], + }, + ], + }); + + expect(stringifyMarkdown(message.formatted)).toBe( + "1. **first** item\n * see [docs](https://example.com)\n2. run `pnpm test`\n" + ); + }); + + it("keeps quotes and code blocks around a list as their own blocks", () => { + const message = adapter.parseMessage({ + type: "message", + user: "U123", + channel: "C456", + text: "> quoted\n• item\n```a *c*```\nafter", + ts: "1234567890.123456", + blocks: [ + { + type: "rich_text", + elements: [ + { type: "rich_text_quote", elements: [text("quoted")] }, + { + type: "rich_text_list", + style: "bullet", + elements: [section(text("item"))], + }, + { + type: "rich_text_preformatted", + elements: [text("a *c*")], + }, + section(text("after")), + ], + }, + ], + }); + + expect(message.formatted.children.map((node) => node.type)).toEqual([ + "blockquote", + "list", + "code", + "paragraph", + ]); + expect(message.formatted.children[2]).toMatchObject({ + type: "code", + value: "a *c*", + }); + }); + + it("keeps event.text when blocks have no list or carry non-rich-text content", () => { + const cases: SlackMessageBlock[][] = [ + [{ type: "rich_text", elements: [section(text("other words"))] }], + [ + { + type: "section", + fields: [{ type: "mrkdwn", text: "app content" }], + }, + { + type: "rich_text", + elements: [ + { + type: "rich_text_list", + style: "bullet", + elements: [section(text("item"))], + }, + ], + }, + ], + ]; + for (const blocks of cases) { + const message = adapter.parseMessage({ + type: "message", + user: "U123", + channel: "C456", + text: "fallback text", + ts: "1234567890.123456", + blocks, + }); + expect(message.text).toBe("fallback text"); + } + }); + }); + it("preserves special mention tokens in an inbound inline code span", () => { const text = " "; const message = adapter.parseMessage({ diff --git a/packages/adapter-slack/src/index.ts b/packages/adapter-slack/src/index.ts index ea128d8b1..6b157f773 100644 --- a/packages/adapter-slack/src/index.ts +++ b/packages/adapter-slack/src/index.ts @@ -324,6 +324,10 @@ const STREAM_EXPIRED_ERROR = "message_not_in_streaming_state"; const FENCE_LINE_PATTERN = /^ {0,3}(`{3,}|~{3,})(.*)$/; const TABLE_ROW_PATTERN = /^\|.*\|$/; const TABLE_SEPARATOR_PATTERN = /^\|[\s:]*-+[\s:]*(?:\|[\s:]*-+[\s:]*)*\|$/; +/** A labeled user, channel, or user group token: ``. */ +const LABELED_SLACK_TOKEN = /<([@#][A-Z0-9_]+|!subteam\^[A-Z0-9_]+)\|[^<>]*>/g; +const UNLABELED_SLACK_TOKEN = /<(?:[@#][A-Z0-9_]+|!subteam\^[A-Z0-9_]+)>/g; +const TRAILING_NEWLINES_PATTERN = /\n+$/; interface OpenFence { /** The fence run that opened the block, e.g. "```" or "~~~~". */ @@ -548,8 +552,13 @@ export interface SlackThreadId { export interface SlackMessageBlock extends SlackBlock { elements?: SlackMessageBlock[]; rows?: unknown; - /** `{ code: true }` marks an inline-code text element. */ - style?: { code?: boolean }; + /** + * Text styling on a rich text element (`{ code: true }` marks inline code), + * or `"bullet"` / `"ordered"` on a `rich_text_list`. + */ + style?: + | { bold?: boolean; code?: boolean; italic?: boolean; strike?: boolean } + | string; text?: string; url?: string; user_id?: string; @@ -794,6 +803,172 @@ function blocktext(value: unknown): string { return value.elements.map(blocktext).join(separator); } +/** + * Render the message body from its `rich_text` blocks when they contain a + * list. Slack flattens composer lists in `event.text` to lines starting with + * `•` or `◦`, which are not markdown list syntax, so the body is rebuilt from + * the blocks as mrkdwn with markdown list markers. Returns `undefined` for + * every other message, which keeps using `event.text`. + * + * Only messages whose non-table blocks are all `rich_text` qualify: other + * block types (such as an app's `section` blocks) carry content that + * `event.text` summarizes and this renderer would drop. + */ +function richTextBody(event: SlackEvent): string | undefined { + const blocks = (event.blocks ?? []).filter( + (block) => !TABLE_BLOCK_TYPES.has(block.type) + ); + if ( + blocks.length === 0 || + !blocks.every((block) => block.type === "rich_text") || + !blocks.some((block) => + block.elements?.some((element) => element.type === "rich_text_list") + ) + ) { + return; + } + const body = blocks.map((block) => richTextBlockMrkdwn(block)).join("\n"); + + // Block elements carry only IDs. `event.text` labels the same tokens + // (``, `<#C…|name>`), so reuse those labels. + const labeled = new Map(); + for (const match of (event.text ?? "").matchAll(LABELED_SLACK_TOKEN)) { + labeled.set(`<${match[1]}>`, match[0]); + } + return body.replace( + UNLABELED_SLACK_TOKEN, + (token) => labeled.get(token) ?? token + ); +} + +/** + * Render one `rich_text` block to mrkdwn. Lists and quotes are separated + * from the following text by a blank line so it does not become a lazy + * continuation of the last list item or quote line. + */ +function richTextBlockMrkdwn(block: SlackMessageBlock): string { + let result = ""; + let previous: SlackMessageBlock | undefined; + // Content column of the most recent item at each list nesting level. + let columns: number[] = []; + + for (const element of block.elements ?? []) { + const isList = element.type === "rich_text_list"; + let chunk: string; + if (isList) { + chunk = richTextListMrkdwn(element, columns); + } else { + columns = []; + chunk = richTextElementMrkdwn(element); + } + + if (previous) { + const previousIsList = previous.type === "rich_text_list"; + const required = + isList !== previousIsList || previous.type === "rich_text_quote" + ? 2 + : 1; + const newlines = + result.length - result.replace(TRAILING_NEWLINES_PATTERN, "").length; + result += "\n".repeat(Math.max(0, required - newlines)); + } + result += chunk; + previous = element; + } + return result.replace(TRAILING_NEWLINES_PATTERN, ""); +} + +/** + * Render one `rich_text_list` element. Nested lists arrive as sibling + * elements with a higher `indent`, so each item is indented to the content + * column of its parent item, tracked across siblings in `columns`. + */ +function richTextListMrkdwn( + list: SlackMessageBlock, + columns: number[] +): string { + const indent = Math.min( + typeof list.indent === "number" ? list.indent : 0, + columns.length + ); + const base = indent === 0 ? 0 : (columns[indent - 1] ?? 0); + const offset = typeof list.offset === "number" ? list.offset : 0; + const lines: string[] = []; + + for (const [index, item] of (list.elements ?? []).entries()) { + const marker = list.style === "ordered" ? `${offset + index + 1}. ` : "- "; + const column = base + marker.length; + columns.length = indent; + columns.push(column); + const [first = "", ...rest] = richTextElementMrkdwn(item) + .replace(TRAILING_NEWLINES_PATTERN, "") + .split("\n"); + lines.push(`${" ".repeat(base)}${marker}${first}`); + for (const line of rest) { + lines.push(`${" ".repeat(column)}${line}`); + } + } + return lines.join("\n"); +} + +/** Render a section, quote, or preformatted rich text element to mrkdwn. */ +function richTextElementMrkdwn(element: SlackMessageBlock): string { + const inline = (element.elements ?? []).map(richTextInlineMrkdwn).join(""); + switch (element.type) { + case "rich_text_preformatted": { + const code = (element.elements ?? []) + .map((child) => + child.type === "text" + ? escapeSlackText(child.text ?? "") + : blocktext(child) + ) + .join(""); + return `\`\`\`\n${code}\n\`\`\``; + } + case "rich_text_quote": + return inline + .replace(TRAILING_NEWLINES_PATTERN, "") + .split("\n") + .map((line) => `> ${line}`) + .join("\n"); + default: + return inline; + } +} + +/** + * Render an inline rich text element to mrkdwn. Text is entity-escaped the + * way Slack escapes `event.text`, and text styles become mrkdwn markers with + * surrounding whitespace kept outside them. + */ +function richTextInlineMrkdwn(element: SlackMessageBlock): string { + if (element.type !== "text") { + return blocktext(element); + } + const text = escapeSlackText(element.text ?? ""); + const style = element.style; + if (!(isRecord(style) && text.trim())) { + return text; + } + const leading = text.length - text.trimStart().length; + const trailing = text.length - text.trimEnd().length; + let core = text.trim(); + if (style.code) { + core = `\`${core}\``; + } else { + if (style.strike) { + core = `~${core}~`; + } + if (style.italic) { + core = `_${core}_`; + } + if (style.bold) { + core = `*${core}*`; + } + } + return text.slice(0, leading) + core + text.slice(text.length - trailing); +} + function hasBoldText(value: unknown): boolean { if (!isRecord(value)) { return false; @@ -4646,8 +4821,11 @@ export class SlackAdapter implements Adapter { const attachments = authorAttachments(event).map(attachmentContent); const isMention = this.detectSelfMention(event, rawText, attachments); - // Resolve inline @mentions to display names. - const text = await this.resolveInlineMentions(rawText); + // Resolve inline @mentions to display names. Composer lists are only + // structured in the blocks, so render the body from them when present. + const text = await this.resolveInlineMentions( + richTextBody(event) ?? rawText + ); const formatted = await this.resolvedContent(event, text, attachments); return new Message({ @@ -6923,7 +7101,11 @@ export class SlackAdapter implements Adapter { // Classify the mention the same way the async path does, so an edit's // pre-edit snapshot cannot disagree with the edited message about it. const isMention = this.detectSelfMention(event, text, attachments); - const formatted = this.content(event, text, attachments); + const formatted = this.content( + event, + richTextBody(event) ?? text, + attachments + ); // Without async lookup, fall back to user ID for human users const userName = event.username || event.user || "unknown"; const fullName = event.username || event.user || "unknown"; From bd2d451533711afdb49fb7a51f7835257a0acaa7 Mon Sep 17 00:00:00 2001 From: "roboeve[bot]" <311457182+roboeve[bot]@users.noreply.github.com> Date: Mon, 26 Oct 2026 14:29:15 +0000 Subject: [PATCH 2/7] revert(slack): back out the first composer list parser main has since moved the Slack message parsing this change edited, so the original diff conflicts. Back it out so main merges cleanly; a later commit reapplies the fix on top of the current parsing code. --- packages/adapter-slack/src/index.ts | 192 +--------------------------- 1 file changed, 5 insertions(+), 187 deletions(-) diff --git a/packages/adapter-slack/src/index.ts b/packages/adapter-slack/src/index.ts index 6b157f773..ea128d8b1 100644 --- a/packages/adapter-slack/src/index.ts +++ b/packages/adapter-slack/src/index.ts @@ -324,10 +324,6 @@ const STREAM_EXPIRED_ERROR = "message_not_in_streaming_state"; const FENCE_LINE_PATTERN = /^ {0,3}(`{3,}|~{3,})(.*)$/; const TABLE_ROW_PATTERN = /^\|.*\|$/; const TABLE_SEPARATOR_PATTERN = /^\|[\s:]*-+[\s:]*(?:\|[\s:]*-+[\s:]*)*\|$/; -/** A labeled user, channel, or user group token: ``. */ -const LABELED_SLACK_TOKEN = /<([@#][A-Z0-9_]+|!subteam\^[A-Z0-9_]+)\|[^<>]*>/g; -const UNLABELED_SLACK_TOKEN = /<(?:[@#][A-Z0-9_]+|!subteam\^[A-Z0-9_]+)>/g; -const TRAILING_NEWLINES_PATTERN = /\n+$/; interface OpenFence { /** The fence run that opened the block, e.g. "```" or "~~~~". */ @@ -552,13 +548,8 @@ export interface SlackThreadId { export interface SlackMessageBlock extends SlackBlock { elements?: SlackMessageBlock[]; rows?: unknown; - /** - * Text styling on a rich text element (`{ code: true }` marks inline code), - * or `"bullet"` / `"ordered"` on a `rich_text_list`. - */ - style?: - | { bold?: boolean; code?: boolean; italic?: boolean; strike?: boolean } - | string; + /** `{ code: true }` marks an inline-code text element. */ + style?: { code?: boolean }; text?: string; url?: string; user_id?: string; @@ -803,172 +794,6 @@ function blocktext(value: unknown): string { return value.elements.map(blocktext).join(separator); } -/** - * Render the message body from its `rich_text` blocks when they contain a - * list. Slack flattens composer lists in `event.text` to lines starting with - * `•` or `◦`, which are not markdown list syntax, so the body is rebuilt from - * the blocks as mrkdwn with markdown list markers. Returns `undefined` for - * every other message, which keeps using `event.text`. - * - * Only messages whose non-table blocks are all `rich_text` qualify: other - * block types (such as an app's `section` blocks) carry content that - * `event.text` summarizes and this renderer would drop. - */ -function richTextBody(event: SlackEvent): string | undefined { - const blocks = (event.blocks ?? []).filter( - (block) => !TABLE_BLOCK_TYPES.has(block.type) - ); - if ( - blocks.length === 0 || - !blocks.every((block) => block.type === "rich_text") || - !blocks.some((block) => - block.elements?.some((element) => element.type === "rich_text_list") - ) - ) { - return; - } - const body = blocks.map((block) => richTextBlockMrkdwn(block)).join("\n"); - - // Block elements carry only IDs. `event.text` labels the same tokens - // (``, `<#C…|name>`), so reuse those labels. - const labeled = new Map(); - for (const match of (event.text ?? "").matchAll(LABELED_SLACK_TOKEN)) { - labeled.set(`<${match[1]}>`, match[0]); - } - return body.replace( - UNLABELED_SLACK_TOKEN, - (token) => labeled.get(token) ?? token - ); -} - -/** - * Render one `rich_text` block to mrkdwn. Lists and quotes are separated - * from the following text by a blank line so it does not become a lazy - * continuation of the last list item or quote line. - */ -function richTextBlockMrkdwn(block: SlackMessageBlock): string { - let result = ""; - let previous: SlackMessageBlock | undefined; - // Content column of the most recent item at each list nesting level. - let columns: number[] = []; - - for (const element of block.elements ?? []) { - const isList = element.type === "rich_text_list"; - let chunk: string; - if (isList) { - chunk = richTextListMrkdwn(element, columns); - } else { - columns = []; - chunk = richTextElementMrkdwn(element); - } - - if (previous) { - const previousIsList = previous.type === "rich_text_list"; - const required = - isList !== previousIsList || previous.type === "rich_text_quote" - ? 2 - : 1; - const newlines = - result.length - result.replace(TRAILING_NEWLINES_PATTERN, "").length; - result += "\n".repeat(Math.max(0, required - newlines)); - } - result += chunk; - previous = element; - } - return result.replace(TRAILING_NEWLINES_PATTERN, ""); -} - -/** - * Render one `rich_text_list` element. Nested lists arrive as sibling - * elements with a higher `indent`, so each item is indented to the content - * column of its parent item, tracked across siblings in `columns`. - */ -function richTextListMrkdwn( - list: SlackMessageBlock, - columns: number[] -): string { - const indent = Math.min( - typeof list.indent === "number" ? list.indent : 0, - columns.length - ); - const base = indent === 0 ? 0 : (columns[indent - 1] ?? 0); - const offset = typeof list.offset === "number" ? list.offset : 0; - const lines: string[] = []; - - for (const [index, item] of (list.elements ?? []).entries()) { - const marker = list.style === "ordered" ? `${offset + index + 1}. ` : "- "; - const column = base + marker.length; - columns.length = indent; - columns.push(column); - const [first = "", ...rest] = richTextElementMrkdwn(item) - .replace(TRAILING_NEWLINES_PATTERN, "") - .split("\n"); - lines.push(`${" ".repeat(base)}${marker}${first}`); - for (const line of rest) { - lines.push(`${" ".repeat(column)}${line}`); - } - } - return lines.join("\n"); -} - -/** Render a section, quote, or preformatted rich text element to mrkdwn. */ -function richTextElementMrkdwn(element: SlackMessageBlock): string { - const inline = (element.elements ?? []).map(richTextInlineMrkdwn).join(""); - switch (element.type) { - case "rich_text_preformatted": { - const code = (element.elements ?? []) - .map((child) => - child.type === "text" - ? escapeSlackText(child.text ?? "") - : blocktext(child) - ) - .join(""); - return `\`\`\`\n${code}\n\`\`\``; - } - case "rich_text_quote": - return inline - .replace(TRAILING_NEWLINES_PATTERN, "") - .split("\n") - .map((line) => `> ${line}`) - .join("\n"); - default: - return inline; - } -} - -/** - * Render an inline rich text element to mrkdwn. Text is entity-escaped the - * way Slack escapes `event.text`, and text styles become mrkdwn markers with - * surrounding whitespace kept outside them. - */ -function richTextInlineMrkdwn(element: SlackMessageBlock): string { - if (element.type !== "text") { - return blocktext(element); - } - const text = escapeSlackText(element.text ?? ""); - const style = element.style; - if (!(isRecord(style) && text.trim())) { - return text; - } - const leading = text.length - text.trimStart().length; - const trailing = text.length - text.trimEnd().length; - let core = text.trim(); - if (style.code) { - core = `\`${core}\``; - } else { - if (style.strike) { - core = `~${core}~`; - } - if (style.italic) { - core = `_${core}_`; - } - if (style.bold) { - core = `*${core}*`; - } - } - return text.slice(0, leading) + core + text.slice(text.length - trailing); -} - function hasBoldText(value: unknown): boolean { if (!isRecord(value)) { return false; @@ -4821,11 +4646,8 @@ export class SlackAdapter implements Adapter { const attachments = authorAttachments(event).map(attachmentContent); const isMention = this.detectSelfMention(event, rawText, attachments); - // Resolve inline @mentions to display names. Composer lists are only - // structured in the blocks, so render the body from them when present. - const text = await this.resolveInlineMentions( - richTextBody(event) ?? rawText - ); + // Resolve inline @mentions to display names. + const text = await this.resolveInlineMentions(rawText); const formatted = await this.resolvedContent(event, text, attachments); return new Message({ @@ -7101,11 +6923,7 @@ export class SlackAdapter implements Adapter { // Classify the mention the same way the async path does, so an edit's // pre-edit snapshot cannot disagree with the edited message about it. const isMention = this.detectSelfMention(event, text, attachments); - const formatted = this.content( - event, - richTextBody(event) ?? text, - attachments - ); + const formatted = this.content(event, text, attachments); // Without async lookup, fall back to user ID for human users const userName = event.username || event.user || "unknown"; const fullName = event.username || event.user || "unknown"; From 0642a2dd161491bf97f1b0bbd4851e0f30c5c3f3 Mon Sep 17 00:00:00 2001 From: "roboeve[bot]" <311457182+roboeve[bot]@users.noreply.github.com> Date: Mon, 26 Oct 2026 14:29:31 +0000 Subject: [PATCH 3/7] test(slack): back out the first composer list tests Pairs with the previous commit so main merges cleanly; the reworked fix brings its own tests. --- packages/adapter-slack/src/index.test.ts | 204 +---------------------- 1 file changed, 1 insertion(+), 203 deletions(-) diff --git a/packages/adapter-slack/src/index.test.ts b/packages/adapter-slack/src/index.test.ts index 179afe6ba..b20993300 100644 --- a/packages/adapter-slack/src/index.test.ts +++ b/packages/adapter-slack/src/index.test.ts @@ -22,13 +22,12 @@ import type { Message, StateAdapter, } from "chat"; -import { Chat, stringifyMarkdown } from "chat"; +import { Chat } from "chat"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import type { SlackAdapterConfig, SlackEvent, SlackInstallation, - SlackMessageBlock, SlackThreadId, } from "./index"; import { @@ -1302,207 +1301,6 @@ describe("parseMessage", () => { ]); }); - describe("composer lists", () => { - const section = (...elements: SlackMessageBlock[]): SlackMessageBlock => ({ - type: "rich_text_section", - elements, - }); - const text = ( - value: string, - style?: SlackMessageBlock["style"] - ): SlackMessageBlock => ({ - type: "text", - text: value, - ...(style ? { style } : {}), - }); - const internals = adapter as unknown as { - parseSlackMessage( - value: SlackEvent, - threadId: string - ): Promise>; - }; - - // Shape from https://github.com/vercel/chat/issues/956: Slack flattens - // the list in `text` to `•`/`◦` lines and keeps its structure in blocks. - const listEvent: SlackEvent = { - type: "message", - channel: "D0123456789", - channel_type: "im", - user: "U0123456789", - username: "jane", - ts: "1726600000.000100", - text: "Steps:\n• one\n• two\n ◦ nested\n and ", - blocks: [ - { - type: "rich_text", - elements: [ - section(text("Steps:\n")), - { - type: "rich_text_list", - style: "bullet", - indent: 0, - elements: [section(text("one")), section(text("two"))], - }, - { - type: "rich_text_list", - style: "bullet", - indent: 1, - elements: [section(text("nested"))], - }, - section({ type: "broadcast", range: "here" }, text(" and "), { - type: "usergroup", - usergroup_id: "S0123456789", - }), - ], - }, - ], - }; - - it("builds nested lists from rich_text_list blocks in both parsing paths", async () => { - for (const message of [ - adapter.parseMessage(listEvent), - await internals.parseSlackMessage( - listEvent, - "slack:D0123456789:1726600000.000100" - ), - ]) { - expect(message.formatted.children.map((node) => node.type)).toEqual([ - "paragraph", - "list", - "paragraph", - ]); - expect(stringifyMarkdown(message.formatted)).toBe( - "Steps:\n\n* one\n* two\n * nested\n\n@here and @devs\n" - ); - expect(message.text).toBe( - "Steps:\n\none\ntwo\nnested\n\n@here and @devs" - ); - } - }); - - it("continues ordered lists across a nested list and keeps inline styles", () => { - const message = adapter.parseMessage({ - type: "message", - user: "U123", - channel: "C456", - text: "1. *first* item\n • see \n2. run `pnpm test`", - ts: "1234567890.123456", - blocks: [ - { - type: "rich_text", - elements: [ - { - type: "rich_text_list", - style: "ordered", - indent: 0, - elements: [ - section(text("first", { bold: true }), text(" item")), - ], - }, - { - type: "rich_text_list", - style: "bullet", - indent: 1, - elements: [ - section(text("see "), { - type: "link", - url: "https://example.com", - text: "docs", - }), - ], - }, - { - type: "rich_text_list", - style: "ordered", - indent: 0, - offset: 1, - elements: [ - section(text("run "), text("pnpm test", { code: true })), - ], - }, - ], - }, - ], - }); - - expect(stringifyMarkdown(message.formatted)).toBe( - "1. **first** item\n * see [docs](https://example.com)\n2. run `pnpm test`\n" - ); - }); - - it("keeps quotes and code blocks around a list as their own blocks", () => { - const message = adapter.parseMessage({ - type: "message", - user: "U123", - channel: "C456", - text: "> quoted\n• item\n```a *c*```\nafter", - ts: "1234567890.123456", - blocks: [ - { - type: "rich_text", - elements: [ - { type: "rich_text_quote", elements: [text("quoted")] }, - { - type: "rich_text_list", - style: "bullet", - elements: [section(text("item"))], - }, - { - type: "rich_text_preformatted", - elements: [text("a *c*")], - }, - section(text("after")), - ], - }, - ], - }); - - expect(message.formatted.children.map((node) => node.type)).toEqual([ - "blockquote", - "list", - "code", - "paragraph", - ]); - expect(message.formatted.children[2]).toMatchObject({ - type: "code", - value: "a *c*", - }); - }); - - it("keeps event.text when blocks have no list or carry non-rich-text content", () => { - const cases: SlackMessageBlock[][] = [ - [{ type: "rich_text", elements: [section(text("other words"))] }], - [ - { - type: "section", - fields: [{ type: "mrkdwn", text: "app content" }], - }, - { - type: "rich_text", - elements: [ - { - type: "rich_text_list", - style: "bullet", - elements: [section(text("item"))], - }, - ], - }, - ], - ]; - for (const blocks of cases) { - const message = adapter.parseMessage({ - type: "message", - user: "U123", - channel: "C456", - text: "fallback text", - ts: "1234567890.123456", - blocks, - }); - expect(message.text).toBe("fallback text"); - } - }); - }); - it("preserves special mention tokens in an inbound inline code span", () => { const text = " "; const message = adapter.parseMessage({ From b5bb938c56f91434c13a8bb28097b06fb8db02f8 Mon Sep 17 00:00:00 2001 From: "roboeve[bot]" <311457182+roboeve[bot]@users.noreply.github.com> Date: Mon, 26 Oct 2026 14:30:05 +0000 Subject: [PATCH 4/7] fix(slack): parse composer lists from rich_text blocks Slack flattens composer lists in event.text to lines starting with bullet characters, so they never became mdast list nodes. When a message's rich_text blocks contain a list, render the body from the blocks instead. Inline styles share markers across elements, and a style change mrkdwn cannot express mid-word keeps the text and drops only that formatting. Trailing newlines are trimmed with a loop instead of a quadratic regex. Link labels in rich text blocks, including table cells, are escaped so a > cannot end the token early. --- .changeset/slack-composer-lists.md | 4 +- packages/adapter-slack/src/event.ts | 18 +- packages/adapter-slack/src/index.ts | 19 +- .../adapter-slack/src/rich-text.test-d.ts | 21 ++ packages/adapter-slack/src/rich-text.test.ts | 296 +++++++++++++++ packages/adapter-slack/src/rich-text.ts | 352 ++++++++++++++++++ 6 files changed, 699 insertions(+), 11 deletions(-) create mode 100644 packages/adapter-slack/src/rich-text.test-d.ts create mode 100644 packages/adapter-slack/src/rich-text.test.ts create mode 100644 packages/adapter-slack/src/rich-text.ts diff --git a/.changeset/slack-composer-lists.md b/.changeset/slack-composer-lists.md index 3601ab971..1644501f0 100644 --- a/.changeset/slack-composer-lists.md +++ b/.changeset/slack-composer-lists.md @@ -2,4 +2,6 @@ "@chat-adapter/slack": patch --- -Parse bulleted and numbered lists from Slack's message composer into mdast `list` nodes, including nested items. Slack flattens these lists in `event.text` to lines starting with `•` or `◦`, so the adapter now builds the message body from the `rich_text` blocks when they contain a list. Messages without a list, or with blocks other than `rich_text` and tables, still use `event.text`. +Parse bulleted and numbered lists from Slack's message composer into mdast `list` nodes, including nested items. Slack flattens these lists in `event.text` to lines starting with `•` or `◦`, so when a message's `rich_text` blocks contain a list, the adapter now builds the message body from the blocks. `message.text` for such a list reads `one\ntwo` instead of `• one\n• two`. Messages without a list, or with blocks other than `rich_text` and tables, still use `event.text`. + +Link labels that contain `>` in rich text blocks, including table cells, now stay inside the link instead of ending it early. diff --git a/packages/adapter-slack/src/event.ts b/packages/adapter-slack/src/event.ts index 94c2d4ac1..a0596b7b7 100644 --- a/packages/adapter-slack/src/event.ts +++ b/packages/adapter-slack/src/event.ts @@ -55,8 +55,17 @@ export function collectMentionIds( export interface SlackMessageBlock extends SlackBlock { elements?: SlackMessageBlock[]; rows?: unknown; - /** `{ code: true }` marks an inline-code text element. */ - style?: { code?: boolean }; + /** + * Inline styling on a rich text element. `{ code: true }` marks inline code. + * On a `rich_text_list`, Slack sends `"bullet"` or `"ordered"` here instead, + * so check the runtime type before reading it. + */ + style?: { + bold?: boolean; + code?: boolean; + italic?: boolean; + strike?: boolean; + }; text?: string; url?: string; user_id?: string; @@ -151,7 +160,7 @@ function str(value: unknown): string | undefined { * Mentions, channels, and links are emitted as mrkdwn tokens so the format * converter renders them the same way it renders body text. */ -function blocktext(value: unknown): string { +export function blocktext(value: unknown): string { if (!(isRecord(value) && typeof value.type === "string")) { return ""; } @@ -168,7 +177,8 @@ function blocktext(value: unknown): string { if (!url) { return text ?? ""; } - return text ? `<${url}|${text}>` : `<${url}>`; + // Escape the label so a `>` in it cannot close the token early. + return text ? `<${url}|${escapeSlackText(text)}>` : `<${url}>`; } case "emoji": { const name = str(value.name); diff --git a/packages/adapter-slack/src/index.ts b/packages/adapter-slack/src/index.ts index f59b3d95c..d89a7ef62 100644 --- a/packages/adapter-slack/src/index.ts +++ b/packages/adapter-slack/src/index.ts @@ -108,6 +108,7 @@ import { type SlackModalResponse, selectOptionToSlackOption, } from "./modals"; +import { richTextBody } from "./rich-text"; import { verifySlackRequest } from "./webhook/index"; // Enterprise Grid users can have W-prefixed IDs anywhere a U-prefixed ID appears @@ -6654,7 +6655,9 @@ export class SlackAdapter implements Adapter { ): FormattedContent { const body = layoutBlockBody(event); return this.assembleContent( - body ? this.bodyNodes(body) : this.textNodes(text, eventTables(event)), + body + ? this.bodyNodes(body) + : this.textNodes(richTextBody(event) ?? text, eventTables(event)), attachments.flatMap((attachment) => this.attachmentNodes(attachment)) ); } @@ -6664,7 +6667,8 @@ export class SlackAdapter implements Adapter { * `resolveInlineMentions`, and inside table cells, layout block text, and * attachment content the same way. Mention IDs outside `rawText` are * collected up front and looked up in a single parallel wave so no ID is - * fetched twice. + * fetched twice. Like `content`, a composer list replaces `rawText` with the + * body rendered from its `rich_text` blocks. */ protected async resolvedContent( event: SlackEvent, @@ -6711,10 +6715,13 @@ export class SlackAdapter implements Adapter { : resolvePart(item) ) ) - : this.textNodes(await this.resolveInlineMentions(rawText), { - leading: leading.map(resolveTable), - trailing: trailing.map(resolveTable), - }), + : this.textNodes( + await this.resolveInlineMentions(richTextBody(event) ?? rawText), + { + leading: leading.map(resolveTable), + trailing: trailing.map(resolveTable), + } + ), attachments.flatMap((attachment) => this.attachmentNodes({ ...attachment, diff --git a/packages/adapter-slack/src/rich-text.test-d.ts b/packages/adapter-slack/src/rich-text.test-d.ts new file mode 100644 index 000000000..2ed9ce24f --- /dev/null +++ b/packages/adapter-slack/src/rich-text.test-d.ts @@ -0,0 +1,21 @@ +import { describe, expectTypeOf, it } from "vitest"; +import type { SlackEvent, SlackMessageBlock } from "./index"; +import { richTextBody } from "./rich-text"; + +describe("rich text types", () => { + it("describes inline styles without breaking reads of style.code", () => { + // A union with the list's string style would make `block.style?.code` + // a type error for existing consumers of the exported type. + expectTypeOf>().toEqualTypeOf<{ + bold?: boolean; + code?: boolean; + italic?: boolean; + strike?: boolean; + }>(); + }); + + it("returns a body only when the blocks produce one", () => { + expectTypeOf(richTextBody).parameters.toEqualTypeOf<[SlackEvent]>(); + expectTypeOf(richTextBody).returns.toEqualTypeOf(); + }); +}); diff --git a/packages/adapter-slack/src/rich-text.test.ts b/packages/adapter-slack/src/rich-text.test.ts new file mode 100644 index 000000000..c148b4175 --- /dev/null +++ b/packages/adapter-slack/src/rich-text.test.ts @@ -0,0 +1,296 @@ +import { stringifyMarkdown } from "chat"; +import { describe, expect, it } from "vitest"; +import type { SlackEvent, SlackMessageBlock } from "./event"; +import { SlackFormatConverter } from "./markdown"; +import { richTextBody } from "./rich-text"; + +const converter = new SlackFormatConverter(); + +type Style = SlackMessageBlock["style"]; + +const text = (value: string, style?: Style): SlackMessageBlock => ({ + type: "text", + text: value, + ...(style ? { style } : {}), +}); +const section = (...elements: SlackMessageBlock[]): SlackMessageBlock => ({ + type: "rich_text_section", + elements, +}); +const list = ( + items: SlackMessageBlock[], + options: { indent?: number; offset?: number; style?: string } = {} +): SlackMessageBlock => + // Slack sends a list's `style` as a string, which the inline style type + // does not describe. + ({ + type: "rich_text_list", + style: options.style ?? "bullet", + indent: options.indent ?? 0, + ...(options.offset === undefined ? {} : { offset: options.offset }), + elements: items, + }) as unknown as SlackMessageBlock; +const event = ( + elements: SlackMessageBlock[], + overrides: Partial = {} +): SlackEvent => ({ + type: "message", + text: "fallback", + blocks: [{ type: "rich_text", elements }], + ...overrides, +}); + +/** The body as mrkdwn, and as markdown after the adapter's format converter. */ +function render(value: SlackEvent): { markdown: string; mrkdwn: string } { + const mrkdwn = richTextBody(value); + if (mrkdwn === undefined) { + throw new Error("expected a rich text body"); + } + return { mrkdwn, markdown: stringifyMarkdown(converter.toAst(mrkdwn)) }; +} + +/** Render one list item made of `elements`. */ +function item(...elements: SlackMessageBlock[]) { + return render(event([list([section(...elements)])])); +} + +describe("richTextBody", () => { + describe("when it applies", () => { + it("returns undefined without a list, so event.text stays the body", () => { + expect(richTextBody(event([section(text("plain"))]))).toBeUndefined(); + expect(richTextBody({ type: "message", text: "no blocks" })).toBe( + undefined + ); + }); + + it("returns undefined when a non-rich-text block carries content", () => { + expect( + richTextBody({ + type: "message", + text: "fallback", + blocks: [ + { type: "section", fields: [{ type: "mrkdwn", text: "app text" }] }, + { type: "rich_text", elements: [list([section(text("item"))])] }, + ], + }) + ).toBeUndefined(); + }); + + it("ignores table blocks, which keep their own handling", () => { + const body = richTextBody({ + type: "message", + text: "fallback", + blocks: [ + { type: "rich_text", elements: [list([section(text("item"))])] }, + { type: "table", rows: [[{ type: "raw_text", text: "cell" }]] }, + ], + }); + expect(body).toBe("- item"); + }); + }); + + describe("lists", () => { + it("nests a list under the content column of its parent item", () => { + const { mrkdwn, markdown } = render( + event([ + list([section(text("one"))], { style: "ordered", offset: 9 }), + list([section(text("nested"))], { indent: 1 }), + list([section(text("deeper"))], { indent: 2 }), + ]) + ); + // "10. " is four columns wide, so the child starts at column 4. + expect(mrkdwn).toBe("10. one\n - nested\n - deeper"); + expect(markdown).toBe("10. one\n * nested\n * deeper\n"); + }); + + it("continues an ordered list after a nested list", () => { + const { markdown } = render( + event([ + list([section(text("first"))], { style: "ordered" }), + list([section(text("child"))], { indent: 1 }), + list([section(text("second"))], { style: "ordered", offset: 1 }), + ]) + ); + expect(markdown).toBe("1. first\n * child\n2. second\n"); + }); + + it("clamps an indent deeper than any open parent", () => { + expect( + render(event([list([section(text("a"))], { indent: 3 })])).mrkdwn + ).toBe("- a"); + }); + + it("indents the continuation lines of a multi-line item", () => { + const { markdown } = item(text("line one\nline two")); + expect(markdown).toBe("* line one\n line two\n"); + }); + + it("separates lists from surrounding text with a blank line", () => { + const { mrkdwn } = render( + event([ + section(text("Steps:\n")), + list([section(text("one"))]), + section(text("after")), + ]) + ); + expect(mrkdwn).toBe("Steps:\n\n- one\n\nafter"); + }); + }); + + describe("inline styles", () => { + it("nests a style that starts inside another at a space", () => { + const { mrkdwn, markdown } = item( + text("make ", { bold: true }), + text("this", { bold: true, italic: true }), + text(" work", { bold: true }) + ); + expect(mrkdwn).toBe("- *make _this_ work*"); + expect(markdown).toBe("* **make *this* work**\n"); + }); + + it("keeps the shared style when another changes mid-word", () => { + const { mrkdwn, markdown } = item( + text("foo", { bold: true }), + text("bar", { bold: true, italic: true }) + ); + expect(mrkdwn).toBe("- *foobar*"); + expect(markdown).toBe("* **foobar**\n"); + }); + + it("drops a style that mrkdwn cannot open mid-word but keeps the text", () => { + const { markdown } = item( + text("un"), + text("bold", { bold: true }), + text("ed") + ); + expect(markdown).toBe("* unbolded\n"); + }); + + it("closes a style before punctuation", () => { + const { markdown } = item(text("Note", { bold: true }), text(": read")); + expect(markdown).toBe("* **Note**: read\n"); + }); + + it("keeps whitespace outside markers", () => { + const { mrkdwn } = item( + text("a "), + text(" spaced ", { italic: true }), + text("b") + ); + expect(mrkdwn).toBe("- a _spaced_ b"); + }); + + it("closes and reopens markers around a line break", () => { + const { markdown } = render( + event([ + section(text("x")), + list([section(text("a\nb", { bold: true }))]), + ]) + ); + expect(markdown).toBe("x\n\n* **a**\n **b**\n"); + }); + + it("renders strike and code, with code innermost", () => { + const { mrkdwn, markdown } = item( + text("old", { strike: true }), + text(" "), + text("run", { bold: true, code: true }) + ); + expect(mrkdwn).toBe("- ~old~ *`run`*"); + expect(markdown).toBe("* ~~old~~ **`run`**\n"); + }); + + it("entity-escapes text the way Slack escapes event.text", () => { + expect(item(text("a & c")).mrkdwn).toBe("- a <b> & c"); + }); + }); + + describe("mentions and links", () => { + it("keeps special mentions literal inside inline code", () => { + const { mrkdwn, markdown } = item( + { type: "broadcast", range: "here", style: { code: true } }, + text(" and "), + { type: "broadcast", range: "channel" } + ); + expect(mrkdwn).toBe("- `` and "); + expect(markdown).toBe("* `` and @channel\n"); + }); + + it("applies styles to links", () => { + const { markdown } = item(text("see "), { + type: "link", + url: "https://example.com", + text: "docs", + style: { bold: true }, + }); + expect(markdown).toBe("* see **[docs](https://example.com)**\n"); + }); + + it("escapes a link label so a > cannot end the token", () => { + const { markdown } = item({ + type: "link", + url: "https://example.com", + text: "a|b > c", + }); + expect(markdown).toBe("* [a|b > c](https://example.com)\n"); + }); + + it("copies user group and channel labels from event.text", () => { + const body = richTextBody( + event( + [ + list([ + section( + { type: "usergroup", usergroup_id: "S0123" }, + text(" in "), + { type: "channel", channel_id: "C0456" }, + text(" cc "), + { type: "user", user_id: "U0789" } + ), + ]), + ], + { text: "• in <#C0456|general> cc <@U0789>" } + ) + ); + expect(body).toBe( + "- in <#C0456|general> cc <@U0789>" + ); + }); + }); + + describe("quotes and code blocks", () => { + it("renders quotes and preformatted text as their own blocks", () => { + const value = event([ + { type: "rich_text_quote", elements: [text("quoted\nlines")] }, + list([section(text("item"))]), + { type: "rich_text_preformatted", elements: [text("a *c*")] }, + section(text("after")), + ]); + const root = converter.toAst(richTextBody(value) ?? ""); + expect(root.children.map((node) => node.type)).toEqual([ + "blockquote", + "list", + "code", + "paragraph", + ]); + expect(root.children[2]).toMatchObject({ value: "a *c*" }); + }); + + it("trims long runs of trailing newlines in linear time", () => { + const newlines = "\n".repeat(200_000); + const value = event([ + list([section(text("a"))]), + { + type: "rich_text_quote", + elements: [text(`${newlines}x${newlines}`)], + }, + ]); + const started = performance.now(); + const body = richTextBody(value) ?? ""; + // The quadratic regex this replaces took tens of seconds here. + expect(performance.now() - started).toBeLessThan(1000); + expect(body.startsWith("- a\n\n> \n")).toBe(true); + expect(body.endsWith("> x")).toBe(true); + }); + }); +}); diff --git a/packages/adapter-slack/src/rich-text.ts b/packages/adapter-slack/src/rich-text.ts new file mode 100644 index 000000000..6bf5d7e18 --- /dev/null +++ b/packages/adapter-slack/src/rich-text.ts @@ -0,0 +1,352 @@ +/** + * Message bodies rebuilt from `rich_text` blocks. + * + * Slack flattens composer lists in `event.text` to lines starting with `•` or + * `◦`, which are not markdown list syntax. The list structure exists only in + * the blocks, as `rich_text_list` elements with `style`, `indent`, and + * `offset`. `richTextBody` renders those blocks to the same mrkdwn dialect + * `event.text` uses, with markdown list markers, so the rest of the parse + * pipeline (mention resolution, the format converter) stays unchanged. + */ +import { + blocktext, + isRecord, + type SlackEvent, + type SlackMessageBlock, + TABLE_BLOCK_TYPES, +} from "./event"; +import { escapeSlackText } from "./format"; + +/** A labeled user, channel, or user group token: ``. */ +const LABELED_TOKEN_PATTERN = + /<([@#][A-Z0-9_]+|!subteam\^[A-Z0-9_]+)\|[^<>]*>/g; +const UNLABELED_TOKEN_PATTERN = /<(?:[@#][A-Z0-9_]+|!subteam\^[A-Z0-9_]+)>/g; +const WHITESPACE_PATTERN = /\s/; +const PUNCTUATION_PATTERN = /[\p{P}\p{S}]/u; + +type Mark = "bold" | "italic" | "strike" | "code"; + +/** Marks in nesting order, outermost first. Code must be innermost. */ +const MARKS: readonly Mark[] = ["bold", "italic", "strike", "code"]; +const MARKERS: Record = { + bold: "*", + code: "`", + italic: "_", + strike: "~", +}; + +/** + * A piece of inline content with one set of marks. `first` and `last` are + * the characters a neighboring marker would touch, used to decide whether a + * marker can open or close there. + */ +interface Run { + first: string; + last: string; + marks: Mark[]; + text: string; +} + +/** + * Render the message body from its `rich_text` blocks when they contain a + * list. Returns `undefined` for every other message, which keeps using + * `event.text`. + * + * Only messages whose non-table blocks are all `rich_text` qualify: other + * block types (such as an app's `section` blocks) carry content this renderer + * would drop. Tables keep their existing handling. + */ +export function richTextBody(event: SlackEvent): string | undefined { + const blocks = (event.blocks ?? []).filter( + (block) => !TABLE_BLOCK_TYPES.has(block.type) + ); + if ( + blocks.length === 0 || + !blocks.every((block) => block.type === "rich_text") || + !blocks.some((block) => + block.elements?.some((element) => element.type === "rich_text_list") + ) + ) { + return; + } + const body = blocks.map(richTextBlockMrkdwn).join("\n"); + + // Block elements carry only IDs. `event.text` labels the same tokens + // (``, `<#C…|name>`), so reuse those labels. + const labeled = new Map(); + for (const match of (event.text ?? "").matchAll(LABELED_TOKEN_PATTERN)) { + labeled.set(`<${match[1]}>`, match[0]); + } + return body.replace( + UNLABELED_TOKEN_PATTERN, + (token) => labeled.get(token) ?? token + ); +} + +/** Remove trailing newlines without a regex, which is quadratic on long runs. */ +function trimTrailingNewlines(text: string): string { + let end = text.length; + while (end > 0 && text[end - 1] === "\n") { + end -= 1; + } + return text.slice(0, end); +} + +/** + * Render one `rich_text` block. Lists and quotes are separated from the + * following text by a blank line so it does not become a lazy continuation + * of the last list item or quote line. + */ +function richTextBlockMrkdwn(block: SlackMessageBlock): string { + let result = ""; + let previous: SlackMessageBlock | undefined; + // Content column of the most recent item at each list nesting level. + let columns: number[] = []; + + for (const element of block.elements ?? []) { + const isList = element.type === "rich_text_list"; + let chunk: string; + if (isList) { + chunk = richTextListMrkdwn(element, columns); + } else { + columns = []; + chunk = richTextElementMrkdwn(element); + } + + if (previous) { + const previousIsList = previous.type === "rich_text_list"; + const required = + isList !== previousIsList || previous.type === "rich_text_quote" + ? 2 + : 1; + const newlines = result.length - trimTrailingNewlines(result).length; + result += "\n".repeat(Math.max(0, required - newlines)); + } + result += chunk; + previous = element; + } + return trimTrailingNewlines(result); +} + +/** + * Render one `rich_text_list`. Nested lists arrive as sibling elements with a + * higher `indent`, so each item is indented to the content column of its + * parent item, tracked across siblings in `columns`. + */ +function richTextListMrkdwn( + list: SlackMessageBlock, + columns: number[] +): string { + const indent = Math.min( + typeof list.indent === "number" ? list.indent : 0, + columns.length + ); + const base = indent === 0 ? 0 : (columns[indent - 1] ?? 0); + const offset = typeof list.offset === "number" ? list.offset : 0; + // `style` is typed for inline elements; on a list it is a string. + const ordered = (list.style as unknown) === "ordered"; + const lines: string[] = []; + + for (const [index, item] of (list.elements ?? []).entries()) { + const marker = ordered ? `${offset + index + 1}. ` : "- "; + const column = base + marker.length; + columns.length = indent; + columns.push(column); + const [first = "", ...rest] = trimTrailingNewlines( + richTextElementMrkdwn(item) + ).split("\n"); + lines.push(`${" ".repeat(base)}${marker}${first}`); + for (const line of rest) { + lines.push(`${" ".repeat(column)}${line}`); + } + } + return lines.join("\n"); +} + +/** Render a section, quote, or preformatted element. */ +function richTextElementMrkdwn(element: SlackMessageBlock): string { + switch (element.type) { + case "rich_text_preformatted": { + const code = (element.elements ?? []) + .map((child) => + child.type === "text" + ? escapeSlackText(child.text ?? "") + : blocktext(child) + ) + .join(""); + return `\`\`\`\n${code}\n\`\`\``; + } + case "rich_text_quote": + return trimTrailingNewlines(inlineMrkdwn(element.elements ?? [])) + .split("\n") + .map((line) => `> ${line}`) + .join("\n"); + default: + return inlineMrkdwn(element.elements ?? []); + } +} + +/** + * Render inline elements to mrkdwn. Consecutive elements share markers, so + * bold `a` followed by bold italic `b` renders as `*a _b_*` rather than two + * marker pairs pressed together, which neither Slack nor CommonMark parses. + */ +function inlineMrkdwn(elements: SlackMessageBlock[]): string { + return renderRuns(expressibleRuns(elements.flatMap(elementRuns))); +} + +function elementRuns(element: SlackMessageBlock): Run[] { + const style = element.style; + const marks = isRecord(style) + ? MARKS.filter((mark) => style[mark] === true) + : []; + + if (element.type === "text") { + // Markers never span a line break, so each line is its own run and the + // break itself carries no marks. + const runs: Run[] = []; + for (const [index, line] of escapeSlackText(element.text ?? "") + .split("\n") + .entries()) { + if (index > 0) { + runs.push({ first: "\n", last: "\n", marks: [], text: "\n" }); + } + if (line) { + runs.push({ + first: line[0], + last: line.at(-1) ?? "", + marks, + text: line, + }); + } + } + return runs; + } + + // `event.text` carries ``, which the format converter turns into + // `@here` outside code and keeps literal inside it. + const text = + element.type === "broadcast" && typeof element.range === "string" + ? `` + : blocktext(element); + if (!text) { + return []; + } + // Mentions and links render as words after conversion, so treat their + // edges as word characters. Emoji keep their colons. + const edge = element.type === "emoji" ? ":" : "a"; + return [{ first: edge, last: edge, marks, text }]; +} + +function commonPrefix(a: readonly Mark[], b: readonly Mark[]): number { + let index = 0; + while (index < a.length && index < b.length && a[index] === b[index]) { + index += 1; + } + return index; +} + +function isSpace(char: string): boolean { + return WHITESPACE_PATTERN.test(char); +} + +function isPunctuation(char: string): boolean { + return PUNCTUATION_PATTERN.test(char); +} + +/** + * Whether the markers that change between two runs can be written where + * they meet. Emphasis markers need a space or punctuation on their outer + * side (`foo*bar*` is not bold in Slack or CommonMark), and a closing marker + * pressed against an opening one (`*a*_b_`) parses as neither. Code spans + * open and close anywhere. + */ +function canTransition(previous: Run, next: Run): boolean { + const shared = commonPrefix(previous.marks, next.marks); + const closing = previous.marks.slice(shared); + const opening = next.marks.slice(shared); + if (isSpace(previous.last) || isSpace(next.first)) { + return true; + } + const closesEmphasis = closing.some((mark) => mark !== "code"); + const opensEmphasis = opening.some((mark) => mark !== "code"); + if (closesEmphasis && opensEmphasis) { + return false; + } + if (closesEmphasis) { + return opening.includes("code") || isPunctuation(next.first); + } + if (opensEmphasis) { + return closing.includes("code") || isPunctuation(previous.last); + } + return true; +} + +/** + * Drop marks that change where they cannot be written, such as bold that + * starts mid-word, keeping the marks both runs share. The text is always + * kept; only formatting that mrkdwn cannot express is lost. + */ +function expressibleRuns(runs: Run[]): Run[] { + const result = runs.map((run) => ({ ...run, marks: [...run.marks] })); + let changed = true; + while (changed) { + changed = false; + for (let index = 1; index < result.length; index += 1) { + const previous = result[index - 1]; + const next = result[index]; + if (!canTransition(previous, next)) { + const shared = previous.marks.filter((mark) => + next.marks.includes(mark) + ); + previous.marks = shared; + next.marks = [...shared]; + changed = true; + } + } + } + return result; +} + +/** + * Write runs with their markers. Whitespace stays outside markers, since + * `*bold *` is not bold, and every marker closes before a line break. + */ +function renderRuns(runs: Run[]): string { + let output = ""; + let pending = ""; + let open: Mark[] = []; + const closeFrom = (depth: number) => { + for (let index = open.length - 1; index >= depth; index -= 1) { + output += MARKERS[open[index]]; + } + open = open.slice(0, depth); + }; + + for (const run of runs) { + const core = run.text.trim(); + if (!core) { + if (run.text.includes("\n")) { + closeFrom(0); + } + pending += run.text; + continue; + } + const leading = run.text.length - run.text.trimStart().length; + const trailing = run.text.length - run.text.trimEnd().length; + pending += run.text.slice(0, leading); + + const shared = commonPrefix(open, run.marks); + closeFrom(shared); + output += pending; + pending = ""; + for (const mark of run.marks.slice(shared)) { + output += MARKERS[mark]; + } + open = [...run.marks]; + output += core; + pending = run.text.slice(run.text.length - trailing); + } + closeFrom(0); + return output + pending; +} From fdb41d961fc542ab6ead2643e44d23d08d7bb6a4 Mon Sep 17 00:00:00 2001 From: "roboeve[bot]" <311457182+roboeve[bot]@users.noreply.github.com> Date: Mon, 26 Oct 2026 14:30:17 +0000 Subject: [PATCH 5/7] test(slack): cover composer lists through both parse paths Run the issue's payload through parseMessage and the async parser, check mention resolution and bot-mention detection inside list items, tables next to a list, the event.text fallback, and a > in a table cell's link label. --- packages/adapter-slack/src/index.test.ts | 246 +++++++++++++++++++++++ 1 file changed, 246 insertions(+) diff --git a/packages/adapter-slack/src/index.test.ts b/packages/adapter-slack/src/index.test.ts index 93da6d32a..8e8de4f5d 100644 --- a/packages/adapter-slack/src/index.test.ts +++ b/packages/adapter-slack/src/index.test.ts @@ -31,6 +31,7 @@ import { Field, Fields, Image, + stringifyMarkdown, } from "chat"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { cardToBlockKit, cardToFallbackText } from "./cards"; @@ -38,6 +39,7 @@ import type { SlackAdapterConfig, SlackEvent, SlackInstallation, + SlackMessageBlock, SlackThreadId, } from "./index"; import { @@ -2679,6 +2681,250 @@ describe("parseMessage", () => { expect(message.text).toBe("On call\t@UBOT123"); expect(message.text).not.toContain("<@"); }); + + it("keeps a > in a table cell's link label inside the link", () => { + const message = adapter.parseMessage({ + type: "message", + user: "U123", + channel: "C456", + text: "", + ts: "1786120899.208429", + blocks: [ + { + type: "table", + rows: [ + [ + { + type: "rich_text", + elements: [ + { + type: "rich_text_section", + elements: [ + { + type: "link", + url: "https://example.com", + text: "p95 > 2s", + }, + ], + }, + ], + }, + ], + ], + }, + ], + }); + + expect(message.formatted.children[0]).toMatchObject({ + type: "table", + children: [ + {}, + { + children: [ + { + children: [ + { + type: "link", + url: "https://example.com", + children: [{ type: "text", value: "p95 > 2s" }], + }, + ], + }, + ], + }, + ], + }); + }); + + describe("composer lists", () => { + const section = (...elements: SlackMessageBlock[]): SlackMessageBlock => ({ + type: "rich_text_section", + elements, + }); + const text = ( + value: string, + style?: SlackMessageBlock["style"] + ): SlackMessageBlock => ({ + type: "text", + text: value, + ...(style ? { style } : {}), + }); + // Slack sends a list's `style` as a string. + const list = ( + elements: SlackMessageBlock[], + style = "bullet", + indent = 0 + ): SlackMessageBlock => + ({ + type: "rich_text_list", + style, + indent, + elements, + }) as unknown as SlackMessageBlock; + + // The payload from https://github.com/vercel/chat/issues/956: Slack + // flattens the list in `text` and keeps its structure in the blocks. + const issueEvent: SlackEvent = { + type: "message", + channel: "D0123456789", + channel_type: "im", + user: "U0123456789", + username: "jane", + ts: "1726600000.000100", + text: "Steps:\n• one\n• two\n ◦ nested\n and ", + blocks: [ + { + type: "rich_text", + elements: [ + section(text("Steps:")), + list([section(text("one")), section(text("two"))]), + list([section(text("nested"))], "bullet", 1), + section({ type: "broadcast", range: "here" }, text(" and "), { + type: "usergroup", + usergroup_id: "S0123456789", + }), + ], + }, + ], + }; + + const asyncParse = (target: SlackAdapter, event: SlackEvent) => + ( + target as unknown as { + parseSlackMessage( + value: SlackEvent, + threadId: string + ): Promise>; + } + ).parseSlackMessage(event, `slack:${event.channel}:${event.ts}`); + + it("parses the issue's list into nested list nodes in both parsing paths", async () => { + for (const message of [ + adapter.parseMessage(issueEvent), + await asyncParse(adapter, issueEvent), + ]) { + expect(message.formatted.children.map((node) => node.type)).toEqual([ + "paragraph", + "list", + "paragraph", + ]); + expect(message.formatted.children[1]).toMatchObject({ + type: "list", + ordered: false, + children: [ + { type: "listItem" }, + { + type: "listItem", + children: [ + { type: "paragraph" }, + { type: "list", children: [{ type: "listItem" }] }, + ], + }, + ], + }); + expect(stringifyMarkdown(message.formatted)).toBe( + "Steps:\n\n* one\n* two\n * nested\n\n@here and @devs\n" + ); + expect(message.text).toBe( + "Steps:\n\none\ntwo\nnested\n\n@here and @devs" + ); + expect(message.isMention).toBe(false); + } + }); + + it("resolves user mentions inside list items and still detects the bot mention", async () => { + const localAdapter = createSlackAdapter({ + botToken: "xoxb-test-token", + signingSecret: "test-secret", + logger: mockLogger, + botUserId: "U_BOT", + }); + await localAdapter.initialize( + createMockChatInstance({ state: createMockState() }) + ); + const usersInfo = vi.fn(({ user }: { user: string }) => + Promise.resolve({ + user: { + name: user.toLowerCase(), + profile: { display_name: user === "U777" ? "jane" : "bot" }, + }, + }) + ); + ( + localAdapter as unknown as { _client: { users: { info: unknown } } } + )._client.users.info = usersInfo; + + const message = await asyncParse(localAdapter, { + type: "message", + user: "U123", + username: "alice", + channel: "C456", + ts: "1786120899.208429", + text: "<@U_BOT> please:\n• ask <@U777>\n• *ship* it", + blocks: [ + { + type: "rich_text", + elements: [ + section({ type: "user", user_id: "U_BOT" }, text(" please:")), + list([ + section(text("ask "), { type: "user", user_id: "U777" }), + section(text("ship", { bold: true }), text(" it")), + ]), + ], + }, + ], + }); + + expect(message.isMention).toBe(true); + expect(stringifyMarkdown(message.formatted)).toBe( + "@bot please:\n\n* ask @jane\n* **ship** it\n" + ); + }); + + it("keeps a table next to a list", () => { + const message = adapter.parseMessage({ + type: "message", + user: "U123", + channel: "C456", + text: "• item", + ts: "1786120899.208429", + blocks: [ + { type: "rich_text", elements: [list([section(text("item"))])] }, + { type: "table", rows: [[{ type: "raw_text", text: "cell" }]] }, + ], + }); + + expect(message.formatted.children.map((node) => node.type)).toEqual([ + "list", + "table", + ]); + }); + + it("keeps event.text when blocks have no list or carry other block types", () => { + const cases: SlackMessageBlock[][] = [ + [{ type: "rich_text", elements: [section(text("other words"))] }], + [ + { type: "section", fields: [{ type: "mrkdwn", text: "app text" }] }, + { type: "rich_text", elements: [list([section(text("item"))])] }, + ], + [ + { type: "rich_text", elements: [list([section(text("item"))])] }, + { type: "call", call_id: "R123" }, + ], + ]; + for (const blocks of cases) { + const message = adapter.parseMessage({ + type: "message", + user: "U123", + channel: "C456", + text: "fallback text", + ts: "1234567890.123456", + blocks, + }); + expect(message.text).toBe("fallback text"); + } + }); + }); }); // ============================================================================ From 0d627edffabed461b8fb404591ed2b24aad1b0a1 Mon Sep 17 00:00:00 2001 From: "roboeve[bot]" <311457182+roboeve[bot]@users.noreply.github.com> Date: Mon, 26 Oct 2026 14:40:19 +0000 Subject: [PATCH 6/7] test(slack): time only the newline trim in the linear-time test --- packages/adapter-slack/src/rich-text.test.ts | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/packages/adapter-slack/src/rich-text.test.ts b/packages/adapter-slack/src/rich-text.test.ts index c148b4175..015d899ed 100644 --- a/packages/adapter-slack/src/rich-text.test.ts +++ b/packages/adapter-slack/src/rich-text.test.ts @@ -277,20 +277,22 @@ describe("richTextBody", () => { }); it("trims long runs of trailing newlines in linear time", () => { + // A preformatted element renders in one pass, so the only work that + // grows with the input is the newline trimming over the whole body. const newlines = "\n".repeat(200_000); const value = event([ list([section(text("a"))]), { - type: "rich_text_quote", - elements: [text(`${newlines}x${newlines}`)], + type: "rich_text_preformatted", + elements: [text(`${newlines}x`)], }, ]); const started = performance.now(); const body = richTextBody(value) ?? ""; // The quadratic regex this replaces took tens of seconds here. expect(performance.now() - started).toBeLessThan(1000); - expect(body.startsWith("- a\n\n> \n")).toBe(true); - expect(body.endsWith("> x")).toBe(true); + expect(body.startsWith("- a\n\n```\n\n")).toBe(true); + expect(body.endsWith("\nx\n```")).toBe(true); }); }); }); From a2c3e2fb3a9e89393f8787f99ada566740867384 Mon Sep 17 00:00:00 2001 From: Ben Sabic Date: Tue, 27 Oct 2026 22:58:51 +1100 Subject: [PATCH 7/7] fix(slack): preserve composer list content and block boundaries Signed-off-by: Ben Sabic --- .changeset/slack-composer-lists.md | 4 +- apps/docs/content/adapters/official/slack.mdx | 6 + packages/adapter-slack/sample-messages.md | 33 ++ packages/adapter-slack/src/index.test.ts | 190 ++++++++++ packages/adapter-slack/src/rich-text.test.ts | 326 +++++++++++++++++- packages/adapter-slack/src/rich-text.ts | 92 ++++- 6 files changed, 624 insertions(+), 27 deletions(-) diff --git a/.changeset/slack-composer-lists.md b/.changeset/slack-composer-lists.md index 1644501f0..2093cd1ba 100644 --- a/.changeset/slack-composer-lists.md +++ b/.changeset/slack-composer-lists.md @@ -2,6 +2,8 @@ "@chat-adapter/slack": patch --- -Parse bulleted and numbered lists from Slack's message composer into mdast `list` nodes, including nested items. Slack flattens these lists in `event.text` to lines starting with `•` or `◦`, so when a message's `rich_text` blocks contain a list, the adapter now builds the message body from the blocks. `message.text` for such a list reads `one\ntwo` instead of `• one\n• two`. Messages without a list, or with blocks other than `rich_text` and tables, still use `event.text`. +Parse bulleted and numbered lists from Slack's message composer into mdast `list` nodes, including nested items. Slack flattens these lists in `event.text` to lines starting with `•` or `◦`, so when a message's `rich_text` blocks contain a list, the adapter now builds the message body from the blocks. `message.text` for such a list reads `one\ntwo` instead of `• one\n• two`. Literal list text such as `---` and `[x]` stays literal, and paragraphs in subsequent rich text blocks stay outside the list. + +Messages without a list, with blocks other than `rich_text` and tables, or with unsupported rich text elements still use `event.text`. This preserves references such as `message_mention` that the block renderer cannot reconstruct. Link labels that contain `>` in rich text blocks, including table cells, now stay inside the link instead of ending it early. diff --git a/apps/docs/content/adapters/official/slack.mdx b/apps/docs/content/adapters/official/slack.mdx index 293aede76..42ff539cb 100644 --- a/apps/docs/content/adapters/official/slack.mdx +++ b/apps/docs/content/adapters/official/slack.mdx @@ -757,6 +757,12 @@ await thread.post( Charts that violate Slack's constraints (50-character title, 12 segments/series, 20 categories, 20-character labels, one data point per category, at most 2 charts per message) fall back to a text rendering of the data instead of being rejected by the Slack API. +## Incoming message formatting + +Bulleted and numbered lists created in Slack's composer become nested mdast `list` nodes in `message.formatted`. In `message.text`, list items are separated by newlines without bullet markers. Literal list text such as `---` and `[x]` stays literal, and paragraphs in subsequent rich text blocks stay outside the list. + +If a message with a list contains unsupported rich text elements, such as a `message_mention`, the adapter uses Slack's `event.text` fallback for the body. This preserves the fallback's content, but list structure is unavailable for those messages. + ## Inbound attachments Incoming file attachments expose a lazy `fetchData()`. Downloads go through a guarded fetcher that limits responses to 25 MB, times out after 30 seconds, and, with the default transport, refuses private and internal addresses (including after redirects). The bot token is sent only to trusted Slack origins and never follows a redirect to another host. Set `fileTransport` to route downloads through a proxy, or override `createFileTransport()` in a subclass. A custom transport takes over the destination-address policy, so see [Egress proxies](#egress-proxies) for what it must enforce. diff --git a/packages/adapter-slack/sample-messages.md b/packages/adapter-slack/sample-messages.md index 89f8b5942..584092511 100644 --- a/packages/adapter-slack/sample-messages.md +++ b/packages/adapter-slack/sample-messages.md @@ -119,3 +119,36 @@ The next `app_mention` was posted through `chat.postMessage` with a `section` bl body: '{"token":"xAbCdEfGhIjKlMnOpQrStUvW","team_id":"T00FAKE00AA","api_app_id":"A00FAKEAPP01","event":{"type":"app_mention","user":"U00FAKEUSER1","ts":"1789700000.000200","text":"New request","team":"T00FAKE00AA","blocks":[{"type":"section","block_id":"cD2eF","text":{"type":"mrkdwn","text":"<@U00FAKEBOT01> please check the deploy","verbatim":false}}],"channel":"C00FAKECHAN1","event_ts":"1789700000.000200"},"type":"event_callback","event_id":"Ev0ASECTION01","event_time":1789700000,"authorizations":[{"enterprise_id":null,"team_id":"T00FAKE00AA","user_id":"U00FAKEBOT01","is_bot":true,"is_enterprise_install":false}],"is_ext_shared_channel":false}' } ``` +## Composer lists + +This message event comes from the reproduction in [issue #956](https://github.com/vercel/chat/issues/956), rather than a captured webhook. The `rich_text_list` elements preserve nesting that the bullet characters in `text` cannot express to a Markdown parser. The adapter produces a paragraph, a list with a nested item, and a final paragraph. + +```json +{ + "type": "message", + "channel": "D0123456789", + "channel_type": "im", + "user": "U0123456789", + "username": "jane", + "ts": "1726600000.000100", + "text": "Steps:\n• one\n• two\n ◦ nested\n and ", + "blocks": [{ + "type": "rich_text", + "elements": [ + { "type": "rich_text_section", "elements": [{ "type": "text", "text": "Steps:" }] }, + { "type": "rich_text_list", "style": "bullet", "indent": 0, "elements": [ + { "type": "rich_text_section", "elements": [{ "type": "text", "text": "one" }] }, + { "type": "rich_text_section", "elements": [{ "type": "text", "text": "two" }] } + ] }, + { "type": "rich_text_list", "style": "bullet", "indent": 1, "elements": [ + { "type": "rich_text_section", "elements": [{ "type": "text", "text": "nested" }] } + ] }, + { "type": "rich_text_section", "elements": [ + { "type": "broadcast", "range": "here" }, + { "type": "text", "text": " and " }, + { "type": "usergroup", "usergroup_id": "S0123456789" } + ] } + ] + }] +} +``` diff --git a/packages/adapter-slack/src/index.test.ts b/packages/adapter-slack/src/index.test.ts index 8e8de4f5d..009b41b37 100644 --- a/packages/adapter-slack/src/index.test.ts +++ b/packages/adapter-slack/src/index.test.ts @@ -2798,6 +2798,196 @@ describe("parseMessage", () => { } ).parseSlackMessage(event, `slack:${event.channel}:${event.ts}`); + describe.each([ + "sync", + "async", + ] as const)("%s regression coverage", (mode) => { + const parse = async (event: SlackEvent) => + mode === "sync" + ? adapter.parseMessage(event) + : await asyncParse(adapter, event); + + it.each([ + "---", + "[x] finished", + "[ ] pending", + "# Heading", + "> literal", + "- flag", + "+ added", + "* starred", + "1. numbered", + "line\n---", + "line\n# Heading", + ])("preserves literal list item %j without inventing markup", async (value) => { + const message = await parse({ + ...issueEvent, + text: "fallback must not be used", + blocks: [ + { + type: "rich_text", + elements: [ + list([ + section(text(value)), + section(text("ship", { bold: true })), + ]), + list([section(text("nested"))], "bullet", 1), + ], + }, + ], + }); + + expect(message.text).toBe(`${value}\nship\nnested`); + expect(message.formatted).toMatchObject({ + type: "root", + children: [ + { + type: "list", + ordered: false, + children: [ + { + type: "listItem", + checked: null, + children: [ + { + type: "paragraph", + children: [{ type: "text", value }], + }, + ], + }, + { + type: "listItem", + checked: null, + children: [ + { + type: "paragraph", + children: [ + { + type: "strong", + children: [{ type: "text", value: "ship" }], + }, + ], + }, + { + type: "list", + ordered: false, + children: [ + { + type: "listItem", + children: [ + { + type: "paragraph", + children: [{ type: "text", value: "nested" }], + }, + ], + }, + ], + }, + ], + }, + ], + }, + ], + }); + }); + + it.each([ + "before", + "inside", + "after", + "quote", + "preformatted", + "future element", + ] as const)("preserves the fallback for unsupported content at %s", async (placement) => { + const reference = section(text("See "), { + type: "message_mention", + channel_id: "C123ABC456", + message_ts: "1720710212.123456", + }); + const items = list([section(text("follow up"))]); + const elements = { + before: [reference, items], + inside: [list([reference, section(text("follow up"))])], + after: [items, reference], + quote: [ + items, + { type: "rich_text_quote", elements: reference.elements }, + ], + preformatted: [ + items, + { type: "rich_text_preformatted", elements: reference.elements }, + ], + "future element": [ + items, + { type: "rich_text_future", elements: [reference] }, + ], + }[placement]; + const url = + "https://example.slack.com/archives/C123ABC456/p1720710212123456"; + const message = await parse({ + ...issueEvent, + text: `See <${url}|referenced message>\n• follow up`, + blocks: [{ type: "rich_text", elements }], + }); + + expect(message.text).toBe("See referenced message\n• follow up"); + expect(message.formatted).toMatchObject({ + type: "root", + children: [ + { + type: "paragraph", + children: [ + { type: "text", value: "See " }, + { + type: "link", + url, + children: [{ type: "text", value: "referenced message" }], + }, + { type: "text", value: "\n• follow up" }, + ], + }, + ], + }); + }); + + it("keeps a subsequent rich text block outside the preceding list", async () => { + const message = await parse({ + ...issueEvent, + text: "fallback must not be used", + blocks: [ + { type: "rich_text", elements: [list([section(text("one"))])] }, + { type: "rich_text", elements: [section(text("after"))] }, + ], + }); + + expect(message.text).toBe("one\n\nafter"); + expect(message.formatted).toMatchObject({ + type: "root", + children: [ + { + type: "list", + ordered: false, + children: [ + { + type: "listItem", + children: [ + { + type: "paragraph", + children: [{ type: "text", value: "one" }], + }, + ], + }, + ], + }, + { + type: "paragraph", + children: [{ type: "text", value: "after" }], + }, + ], + }); + }); + }); + it("parses the issue's list into nested list nodes in both parsing paths", async () => { for (const message of [ adapter.parseMessage(issueEvent), diff --git a/packages/adapter-slack/src/rich-text.test.ts b/packages/adapter-slack/src/rich-text.test.ts index 015d899ed..fc087392e 100644 --- a/packages/adapter-slack/src/rich-text.test.ts +++ b/packages/adapter-slack/src/rich-text.test.ts @@ -1,4 +1,4 @@ -import { stringifyMarkdown } from "chat"; +import { stringifyMarkdown, toPlainText } from "chat"; import { describe, expect, it } from "vitest"; import type { SlackEvent, SlackMessageBlock } from "./event"; import { SlackFormatConverter } from "./markdown"; @@ -76,6 +76,34 @@ describe("richTextBody", () => { ).toBeUndefined(); }); + it.each([ + "rich_text_section", + "rich_text_quote", + "rich_text_preformatted", + ])("falls back for unsupported leaves in %s", (type) => { + const unsupported: SlackMessageBlock = { + type: "message_mention", + channel_id: "C123ABC456", + message_ts: "1720710212.123456", + }; + expect( + richTextBody( + event([ + list([section(text("item"))]), + { type, elements: [unsupported] }, + ]) + ) + ).toBeUndefined(); + }); + + it("falls back for unsupported leaves nested in a list", () => { + expect( + richTextBody( + event([list([section({ type: "future_inline", text: "unhandled" })])]) + ) + ).toBeUndefined(); + }); + it("ignores table blocks, which keep their own handling", () => { const body = richTextBody({ type: "message", @@ -126,14 +154,236 @@ describe("richTextBody", () => { }); it("separates lists from surrounding text with a blank line", () => { - const { mrkdwn } = render( + const { markdown } = render( event([ section(text("Steps:\n")), list([section(text("one"))]), section(text("after")), ]) ); - expect(mrkdwn).toBe("Steps:\n\n- one\n\nafter"); + expect(markdown).toBe("Steps:\n\n* one\n\nafter\n"); + }); + }); + + describe("literal list content", () => { + it.each([ + "---", + "***", + "___", + "===", + "[x] done", + "[ ] pending", + "# heading", + "> quote", + "", + "- flag", + "+ flag", + "* flag", + "1. first", + "1) first", + "```", + "~~~", + "*literal* _literal_ ~literal~ `literal`", + "\\*literal*", + ])("keeps %j literal on first and continuation lines", (literal) => { + for (const value of [literal, `before\n${literal}`]) { + const root = converter.toAst(item(text(value)).mrkdwn); + expect(root.children).toMatchObject([ + { + type: "list", + ordered: false, + children: [ + { + type: "listItem", + checked: null, + children: [ + { type: "paragraph", children: [{ type: "text", value }] }, + ], + }, + ], + }, + ]); + expect(toPlainText(root)).toBe(value); + } + }); + + it.each([ + " # heading", + "\t# heading", + ])("keeps leading whitespace literal in %j", (literal) => { + for (const value of [literal, `before\n${literal}`]) { + const root = converter.toAst(item(text(value)).mrkdwn); + expect(root.children).toMatchObject([ + { + type: "list", + children: [ + { + type: "listItem", + children: [ + { type: "paragraph", children: [{ type: "text", value }] }, + ], + }, + ], + }, + ]); + expect(toPlainText(root)).toBe(value); + } + }); + + it("preserves indentation in text surrounding a list", () => { + const root = converter.toAst( + render( + event([section(text(" # heading")), list([section(text("item"))])]) + ).mrkdwn + ); + expect(root.children).toMatchObject([ + { + type: "paragraph", + children: [{ type: "text", value: " # heading" }], + }, + { + type: "list", + children: [ + { + type: "listItem", + children: [ + { + type: "paragraph", + children: [{ type: "text", value: "item" }], + }, + ], + }, + ], + }, + ]); + expect(toPlainText(root)).toBe(" # heading\n\nitem"); + }); + + it("does not turn an indented continuation paragraph into code", () => { + const root = converter.toAst( + item(text("before\n\n # heading")).mrkdwn + ); + expect(root.children).toMatchObject([ + { + type: "list", + children: [ + { + type: "listItem", + children: [ + { + type: "paragraph", + children: [{ type: "text", value: "before" }], + }, + { + type: "paragraph", + children: [{ type: "text", value: " # heading" }], + }, + ], + }, + ], + }, + ]); + expect(toPlainText(root)).toBe("before\n # heading"); + }); + + it("protects syntax assembled from separate text elements", () => { + const root = converter.toAst( + item(text("-"), text("-"), text("-")).mrkdwn + ); + expect(root.children).toMatchObject([ + { + type: "list", + children: [ + { + type: "listItem", + children: [ + { + type: "paragraph", + children: [{ type: "text", value: "---" }], + }, + ], + }, + ], + }, + ]); + }); + + it("preserves real formatting around literal syntax", () => { + const literal = "# *literal* [x] <"; + const root = converter.toAst(item(text(literal, { bold: true })).mrkdwn); + expect(root.children).toMatchObject([ + { + type: "list", + children: [ + { + type: "listItem", + children: [ + { + type: "paragraph", + children: [ + { + type: "strong", + children: [{ type: "text", value: literal }], + }, + ], + }, + ], + }, + ], + }, + ]); + expect(toPlainText(root)).toBe(literal); + }); + + it.each([ + "*abc*", + "", + ])("keeps %j literal when conflicting marks drop inline code", (literal) => { + const root = converter.toAst( + item( + text(literal, { bold: true, code: true }), + text("x", { italic: true }) + ).mrkdwn + ); + expect(root.children).toMatchObject([ + { + type: "list", + children: [ + { + type: "listItem", + children: [ + { + type: "paragraph", + children: [{ type: "text", value: `${literal}x` }], + }, + ], + }, + ], + }, + ]); + expect(toPlainText(root)).toBe(`${literal}x`); + }); + + it("does not entity-encode punctuation inside inline code", () => { + const literal = "--- [x] # > < *literal*"; + const root = converter.toAst(item(text(literal, { code: true })).mrkdwn); + expect(root.children).toMatchObject([ + { + type: "list", + children: [ + { + type: "listItem", + children: [ + { + type: "paragraph", + children: [{ type: "inlineCode", value: literal }], + }, + ], + }, + ], + }, + ]); + expect(toPlainText(root)).toBe(literal); }); }); @@ -200,8 +450,26 @@ describe("richTextBody", () => { expect(markdown).toBe("* ~~old~~ **`run`**\n"); }); - it("entity-escapes text the way Slack escapes event.text", () => { - expect(item(text("a & c")).mrkdwn).toBe("- a <b> & c"); + it("preserves literal control characters and entity spellings", () => { + const literal = "a & c < #"; + const root = converter.toAst(item(text(literal)).mrkdwn); + expect(root.children).toMatchObject([ + { + type: "list", + children: [ + { + type: "listItem", + children: [ + { + type: "paragraph", + children: [{ type: "text", value: literal }], + }, + ], + }, + ], + }, + ]); + expect(toPlainText(root)).toBe(literal); }); }); @@ -258,6 +526,49 @@ describe("richTextBody", () => { }); }); + describe("rich text block boundaries", () => { + it("keeps following text out of preceding lists and quotes", () => { + const value = event([], { + blocks: [ + { type: "rich_text", elements: [list([section(text("item"))])] }, + { + type: "rich_text", + elements: [{ type: "rich_text_quote", elements: [text("quoted")] }], + }, + { type: "rich_text", elements: [section(text("after"))] }, + ], + }); + const root = converter.toAst(render(value).mrkdwn); + expect(root.children).toMatchObject([ + { + type: "list", + children: [ + { + type: "listItem", + children: [ + { + type: "paragraph", + children: [{ type: "text", value: "item" }], + }, + ], + }, + ], + }, + { + type: "blockquote", + children: [ + { + type: "paragraph", + children: [{ type: "text", value: "quoted" }], + }, + ], + }, + { type: "paragraph", children: [{ type: "text", value: "after" }] }, + ]); + expect(toPlainText(root)).toBe("item\n\nquoted\n\nafter"); + }); + }); + describe("quotes and code blocks", () => { it("renders quotes and preformatted text as their own blocks", () => { const value = event([ @@ -290,9 +601,8 @@ describe("richTextBody", () => { const started = performance.now(); const body = richTextBody(value) ?? ""; // The quadratic regex this replaces took tens of seconds here. - expect(performance.now() - started).toBeLessThan(1000); - expect(body.startsWith("- a\n\n```\n\n")).toBe(true); - expect(body.endsWith("\nx\n```")).toBe(true); + expect(performance.now() - started).toBeLessThan(5000); + expect(body).toBe(`- a\n\n\`\`\`\n${newlines}x\n\`\`\``); }); }); }); diff --git a/packages/adapter-slack/src/rich-text.ts b/packages/adapter-slack/src/rich-text.ts index 6bf5d7e18..c657fdd66 100644 --- a/packages/adapter-slack/src/rich-text.ts +++ b/packages/adapter-slack/src/rich-text.ts @@ -4,9 +4,9 @@ * Slack flattens composer lists in `event.text` to lines starting with `•` or * `◦`, which are not markdown list syntax. The list structure exists only in * the blocks, as `rich_text_list` elements with `style`, `indent`, and - * `offset`. `richTextBody` renders those blocks to the same mrkdwn dialect - * `event.text` uses, with markdown list markers, so the rest of the parse - * pipeline (mention resolution, the format converter) stays unchanged. + * `offset`. `richTextBody` builds an intermediate body with mrkdwn styles, + * Markdown list markers, and entity-encoded literal punctuation for the + * existing mention resolution and format converter pipeline. */ import { blocktext, @@ -23,6 +23,23 @@ const LABELED_TOKEN_PATTERN = const UNLABELED_TOKEN_PATTERN = /<(?:[@#][A-Z0-9_]+|!subteam\^[A-Z0-9_]+)>/g; const WHITESPACE_PATTERN = /\s/; const PUNCTUATION_PATTERN = /[\p{P}\p{S}]/u; +const LEADING_INDENTATION_PATTERN = /^[ \t]/gm; +// Encode literal ASCII punctuation so CommonMark cannot interpret it as +// markup after the Slack format converter runs. Style markers are added later. +const MARKDOWN_PUNCTUATION_PATTERN = + /[\u0021-\u002f\u003a-\u0040\u005b-\u0060\u007b-\u007e]/g; +const INLINE_ELEMENT_TYPES = new Set([ + "text", + "link", + "emoji", + "user", + "broadcast", + "channel", + "usergroup", + "date", + "color", + "team", +]); type Mark = "bold" | "italic" | "strike" | "code"; @@ -43,6 +60,8 @@ const MARKERS: Record = { interface Run { first: string; last: string; + /** Raw text to escape after the final marks are known, rather than a token. */ + literal?: boolean; marks: Mark[]; text: string; } @@ -53,8 +72,9 @@ interface Run { * `event.text`. * * Only messages whose non-table blocks are all `rich_text` qualify: other - * block types (such as an app's `section` blocks) carry content this renderer - * would drop. Tables keep their existing handling. + * block types (such as an app's `section` blocks), or unsupported elements + * inside rich text, can carry content this renderer would drop. Tables keep + * their existing handling. */ export function richTextBody(event: SlackEvent): string | undefined { const blocks = (event.blocks ?? []).filter( @@ -62,14 +82,18 @@ export function richTextBody(event: SlackEvent): string | undefined { ); if ( blocks.length === 0 || - !blocks.every((block) => block.type === "rich_text") || + !blocks.every( + (block) => + block.type === "rich_text" && + (block.elements ?? []).every(canRenderElement) + ) || !blocks.some((block) => block.elements?.some((element) => element.type === "rich_text_list") ) ) { return; } - const body = blocks.map(richTextBlockMrkdwn).join("\n"); + const body = blocks.map(richTextBlockMrkdwn).join("\n\n"); // Block elements carry only IDs. `event.text` labels the same tokens // (``, `<#C…|name>`), so reuse those labels. @@ -83,6 +107,23 @@ export function richTextBody(event: SlackEvent): string | undefined { ); } +function canRenderElement(element: SlackMessageBlock): boolean { + switch (element.type) { + case "rich_text_list": + return (element.elements ?? []).every( + (item) => item.type === "rich_text_section" && canRenderElement(item) + ); + case "rich_text_section": + case "rich_text_quote": + case "rich_text_preformatted": + return (element.elements ?? []).every((child) => + INLINE_ELEMENT_TYPES.has(child.type) + ); + default: + return false; + } +} + /** Remove trailing newlines without a regex, which is quadratic on long runs. */ function trimTrailingNewlines(text: string): string { let end = text.length; @@ -192,7 +233,12 @@ function richTextElementMrkdwn(element: SlackMessageBlock): string { * marker pairs pressed together, which neither Slack nor CommonMark parses. */ function inlineMrkdwn(elements: SlackMessageBlock[]): string { - return renderRuns(expressibleRuns(elements.flatMap(elementRuns))); + // Protect literal indentation before adding list or quote prefixes, so + // CommonMark cannot turn the text into a code block with encoded content. + return renderRuns(expressibleRuns(elements.flatMap(elementRuns))).replace( + LEADING_INDENTATION_PATTERN, + (char) => `&#${char.charCodeAt(0)};` + ); } function elementRuns(element: SlackMessageBlock): Run[] { @@ -205,9 +251,7 @@ function elementRuns(element: SlackMessageBlock): Run[] { // Markers never span a line break, so each line is its own run and the // break itself carries no marks. const runs: Run[] = []; - for (const [index, line] of escapeSlackText(element.text ?? "") - .split("\n") - .entries()) { + for (const [index, line] of (element.text ?? "").split("\n").entries()) { if (index > 0) { runs.push({ first: "\n", last: "\n", marks: [], text: "\n" }); } @@ -215,6 +259,7 @@ function elementRuns(element: SlackMessageBlock): Run[] { runs.push({ first: line[0], last: line.at(-1) ?? "", + literal: true, marks, text: line, }); @@ -324,17 +369,28 @@ function renderRuns(runs: Run[]): string { }; for (const run of runs) { - const core = run.text.trim(); + // A rejected style transition may have removed code. Only escape after + // that decision, so formerly-code text cannot become Markdown syntax. + let text = run.text; + if (run.literal) { + text = run.marks.includes("code") + ? escapeSlackText(run.text) + : run.text.replace( + MARKDOWN_PUNCTUATION_PATTERN, + (char) => `&#${char.charCodeAt(0)};` + ); + } + const core = text.trim(); if (!core) { - if (run.text.includes("\n")) { + if (text.includes("\n")) { closeFrom(0); } - pending += run.text; + pending += text; continue; } - const leading = run.text.length - run.text.trimStart().length; - const trailing = run.text.length - run.text.trimEnd().length; - pending += run.text.slice(0, leading); + const leading = text.length - text.trimStart().length; + const trailing = text.length - text.trimEnd().length; + pending += text.slice(0, leading); const shared = commonPrefix(open, run.marks); closeFrom(shared); @@ -345,7 +401,7 @@ function renderRuns(runs: Run[]): string { } open = [...run.marks]; output += core; - pending = run.text.slice(run.text.length - trailing); + pending = text.slice(text.length - trailing); } closeFrom(0); return output + pending;