Skip to content
9 changes: 9 additions & 0 deletions .changeset/slack-composer-lists.md
Original file line number Diff line number Diff line change
@@ -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.
6 changes: 6 additions & 0 deletions apps/docs/content/adapters/official/slack.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
33 changes: 33 additions & 0 deletions packages/adapter-slack/sample-messages.md
Original file line number Diff line number Diff line change
Expand Up @@ -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<!here> and <!subteam^S0123456789|@devs>",
"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" }
] }
]
}]
}
```
18 changes: 14 additions & 4 deletions packages/adapter-slack/src/event.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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 "";
}
Expand All @@ -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);
Expand Down
Loading
Loading