Skip to content

fix(slack): parse composer lists from rich_text blocks - #1002

Merged
bensabic merged 8 commits into
mainfrom
fix/slack-composer-lists
Oct 27, 2026
Merged

bensabic merged 8 commits into
mainfrom
fix/slack-composer-lists

Conversation

@roboeve

@roboeve roboeve Bot commented Oct 7, 2026 •

Copy link
Copy Markdown
Contributor

Summary

Fixes #956. Bulleted and numbered lists typed in Slack's message composer now parse to mdast list nodes, including nested items.

The special-mentions half of #956 was fixed in #960. This PR covers the list half, which still reproduces on main: Slack flattens composer lists in event.text to lines starting with • or ◦, which are not markdown list syntax, so the issue's payload parses to a single paragraph. With this change it gives:

nodes   : ["paragraph","list","paragraph"]
markdown: "Steps:\n\n* one\n* two\n  * nested\n\n@here and @devs\n"

Approach

The list structure exists only in event.blocks, as rich_text_list elements with style, indent, and offset. When a message's blocks contain a list and every non-table block is rich_text, the new richTextBody (src/rich-text.ts) renders the blocks to the same mrkdwn dialect event.text uses, with markdown list markers. content() and resolvedContent() use that body instead of event.text on the text path, so mention resolution, the format converter, and table handling are unchanged. Every other message, including the layout-block bodies from #1062, keeps its current path.

  • Nested lists arrive as sibling rich_text_list elements with a higher indent. Each item is indented to its parent's content column, so - and 10. parents both nest correctly, and ordered lists continue from offset across a nested list.
  • Inline styles share markers across consecutive elements: bold make , bold italic this, bold work renders as *make _this_ work*. A style change that mrkdwn cannot express (bold starting mid-word, or a closing marker pressed against an opening one) keeps the text and drops only that formatting, instead of leaving stray * in the output.
  • Styles on links, mentions, and emoji are kept, and @here inside inline code stays literal, matching how event.text parses.
  • Block elements carry only IDs, so labels for <!subteam^…|@handle> and <#C…|name> are copied from event.text.
  • Trailing newlines are trimmed with a loop. The /\n+$/ regex flagged by CodeQL took about 39 seconds on 200,000 newlines in the new test.
  • Link labels in rich text are now entity-escaped, so a > in a label (including in table cells, which share blocktext) no longer ends the link early.

SlackMessageBlock["style"] gains bold, italic, and strike. It stays an object type, so existing reads of block.style?.code still compile; a type test guards this.

message.text follows the core toPlainText output for lists, so it now reads one\ntwo instead of • one\n• two.

Test plan

  • src/rich-text.test.ts: renderer logic, checked as mrkdwn and as markdown after the adapter's format converter (nesting columns, offsets, multi-line items, style nesting and mid-word changes, punctuation, line breaks, code, links, mention labels, quotes and code blocks, gating, and linear-time trimming). The trimming and link-label tests fail against the previous implementation.
  • src/rich-text.test-d.ts: the style type stays an object, and richTextBody's signature.
  • src/index.test.ts: the issue's payload through parseMessage and the async parser, user mention resolution and bot-mention detection inside list items, a table next to a list, the event.text fallback, and a > in a table cell link label. The list tests fail on main.
  • vitest run in packages/adapter-slack (963 tests, no type errors), tsc --noEmit, ultracite check, pnpm knip, and pnpm konsistent pass. The full packages/integration-tests suite passes (63 files, 1,425 tests).
  • Smoke test against the built packages: signed event_callback requests through chat.webhooks.slack with a memory state reach onDirectMessage and onNewMention with the expected list markdown, and a message without a list keeps event.text.

The branch merged main to resolve conflicts with #1062. The first implementation is backed out in two commits before the merge and reapplied after it.

Checklist

  • All commits are signed and verified
  • All commits are signed off for the DCO (git commit -s)
  • pnpm validate passes
  • Changeset added (or N/A — see CONTRIBUTING.md)
  • Documentation updated (or N/A)

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.
@vercel

vercel Bot commented Oct 7, 2026 •

Copy link
Copy Markdown
Contributor

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated
chat Ready Ready Preview, v0 Oct 27, 2026 12:00pm UTC
chat-sdk-nextjs-chat Ready Ready Preview, v0 Oct 27, 2026 12:00pm UTC

Comment thread packages/adapter-slack/src/index.ts Fixed
Comment thread packages/adapter-slack/src/index.ts Fixed
Comment thread packages/adapter-slack/src/index.ts Fixed
bensabic added a commit that referenced this pull request Oct 19, 2026
## Summary

- A bot now receives the content of Block Kit messages that apps and
workflows post, instead of only their notification fallback
- When an app mentions the bot from a `section` block, the handler gets
the request it was invoked for
- When the bot reads thread history, messages from alerting and deploy
apps include the details shown in Slack
- Messages typed in a Slack client are unchanged

## Problem

A Slack message can carry both `text` and `blocks`. When it carries
blocks, Slack renders the blocks and uses `text` only as "a fallback
string to display in notifications"
([`chat.postMessage`](https://docs.slack.dev/reference/methods/chat.postMessage#text-blocks-attachments)).
Apps and workflows rely on that: the fallback is often a one-line
summary, and the content people read is in the blocks.

The adapter builds `message.text` and `message.formatted` from `text`.
From the message's own blocks it reads only tables. Text in `section`,
`header`, and `context` blocks never reaches the bot, so the bot works
from the notification summary while people in the channel see the full
message.

This affects a bot in two ways.

**An app mentions the bot.** An internal tool asks the bot to act:

```json
{
  "type": "app_mention",
  "text": "New request",
  "blocks": [
    { "type": "section", "text": { "type": "mrkdwn", "text": "<@U_BOT> please check the deploy" } }
  ]
}
```

1. Slack fires `app_mention` for the token in the `section` block, even
though `text` has none.
2. The adapter reads the mention from the block and reports `isMention:
true`.
3. It builds the body from `text`, so `message.text` is `New request`.
4. `onNewMention` runs without the request it was invoked for, and the
bot can only ask what to do.

**The bot reads an app's message in history.** A deploy app posts `text:
"Deployment failed"` with the error, project, and links in `section`
blocks. A user replies in the thread and asks the bot what went wrong.
`fetchMessages`, `fetchChannelMessages`, and `fetchMessage` parse
messages the same way, so the bot's view of the alert is `Deployment
failed`, without the details the user is looking at.

## Changes

When a message has no `rich_text` blocks, text from its layout blocks
becomes the body, in block order:

- `section` text and `fields`, `header` text, and `context` text
elements
- `mrkdwn` objects keep their markup and mentions, which resolve to
display names like the rest of the body
- `plain_text` objects render literally, so `*` and `_` stay characters

The same message now gives `message.text` = `@Test Bot please check the
deploy`.

These rules keep existing behavior:

- **Typed messages keep `text`.** A Slack client sends `rich_text`
blocks that `text` mirrors, so a message with any `rich_text` block
keeps the `text` body. [#1002](#1002)
covers composer lists in that path.
- **Blocks without text keep `text`.** When the layout blocks carry no
text, as in a message with only buttons or images, `text` stays the
body.
- **Table placement is unchanged.** Tables from the message's blocks
keep their position around the body.
- **Mention detection is unchanged.** Only the message content changes.

-
[`packages/adapter-slack/src/index.ts`](https://github.com/vercel/chat/blob/9a7cfb78/packages/adapter-slack/src/index.ts):
`layoutBlockContent`, rendered through the existing attachment-content
path
-
[`packages/adapter-slack/src/index.test.ts`](https://github.com/vercel/chat/blob/9a7cfb78/packages/adapter-slack/src/index.test.ts):
a layout message parsed by both parse paths, a webhook mention in a
`section` block, and a buttons-only message that keeps its fallback
-
[`packages/adapter-slack/sample-messages.md`](https://github.com/vercel/chat/blob/9a7cfb78/packages/adapter-slack/sample-messages.md):
a captured `section` mention payload, anonymized

---------

Signed-off-by: Hiroki Osame <hiroki.osame@gmail.com>
Signed-off-by: Ben Sabic <bensabic@users.noreply.github.com>
Co-authored-by: Ben Sabic <bensabic@users.noreply.github.com>
roboeve Bot added 5 commits October 26, 2026 14:29
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.
Pairs with the previous commit so main merges cleanly; the reworked fix brings its own tests.
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.
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.
@roboeve

roboeve Bot commented Oct 26, 2026

Copy link
Copy Markdown
Contributor Author

CI on fdb41d9 failed on one test from this PR: trims long runs of trailing newlines in linear time took 2,046 ms against a 1,000 ms budget on Node 24 (vitest with coverage). The time went to building 400k inline runs for the quote, which is linear work, not the trim the test was written to check.

0d627ed switches the test input to a rich_text_preformatted element, which renders in one pass, so the trim over the whole body is the only work that grows with the input. Locally it now runs in 3 ms with coverage. With the old /\\n+$/ trim put back temporarily, the same test takes about 29 s and fails, so it still catches the regression. Product code is unchanged. All 22 tests in rich-text.test.ts pass and ultracite check is clean. New CI is pending.

Signed-off-by: Ben Sabic <bensabic@users.noreply.github.com>
@bensabic
bensabic marked this pull request as ready for review October 27, 2026 12:00
@bensabic
bensabic requested a review from a team October 27, 2026 12:00
@bensabic
bensabic merged commit 1ba1cc4 into main Oct 27, 2026
24 checks passed
@bensabic
bensabic deleted the fix/slack-composer-lists branch October 27, 2026 12:04

This branch was successfully deployed

3 active (2 outdated) deployments
Preview — a2c3e2fb Deployed Oct 27, 2026 by vercel[bot]
Preview – chat — ab1ac4ca Deployed Oct 7, 2026 by vercel[bot]
Preview – chat-sdk-nextjs-chat — ab1ac4ca Deployed Oct 7, 2026 by vercel[bot]
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Slack: bulleted lists are lost

2 participants