diff --git a/.changeset/slack-composer-lists.md b/.changeset/slack-composer-lists.md new file mode 100644 index 00000000..2093cd1b --- /dev/null +++ b/.changeset/slack-composer-lists.md @@ -0,0 +1,9 @@ +--- +"@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`. 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 293aede7..42ff539c 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 89f8b594..58409251 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/event.ts b/packages/adapter-slack/src/event.ts index 94c2d4ac..a0596b7b 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.test.ts b/packages/adapter-slack/src/index.test.ts index 93da6d32..009b41b3 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,440 @@ 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}`); + + 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), + 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"); + } + }); + }); }); // ============================================================================ diff --git a/packages/adapter-slack/src/index.ts b/packages/adapter-slack/src/index.ts index f59b3d95..d89a7ef6 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 00000000..2ed9ce24 --- /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 00000000..fc087392 --- /dev/null +++ b/packages/adapter-slack/src/rich-text.test.ts @@ -0,0 +1,608 @@ +import { stringifyMarkdown, toPlainText } 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.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", + 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 { markdown } = render( + event([ + section(text("Steps:\n")), + list([section(text("one"))]), + section(text("after")), + ]) + ); + 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); + }); + }); + + 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("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); + }); + }); + + 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("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([ + { 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", () => { + // 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_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(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 new file mode 100644 index 00000000..c657fdd6 --- /dev/null +++ b/packages/adapter-slack/src/rich-text.ts @@ -0,0 +1,408 @@ +/** + * 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` 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, + 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; +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"; + +/** 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; + /** Raw text to escape after the final marks are known, rather than a token. */ + literal?: boolean; + 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), 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( + (block) => !TABLE_BLOCK_TYPES.has(block.type) + ); + if ( + blocks.length === 0 || + !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\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 + ); +} + +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; + 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 { + // 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[] { + 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 (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) ?? "", + literal: true, + 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) { + // 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 (text.includes("\n")) { + closeFrom(0); + } + pending += text; + continue; + } + 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); + output += pending; + pending = ""; + for (const mark of run.marks.slice(shared)) { + output += MARKERS[mark]; + } + open = [...run.marks]; + output += core; + pending = text.slice(text.length - trailing); + } + closeFrom(0); + return output + pending; +}