Skip to content

[4.41/SL5] Slack inbound: pasted tables and alert attachments in message content #210

Description

@patrick-chinchill

Summary

Slack messages often carry their real content outside event["text"]. Pasted spreadsheets arrive as table / data_table blocks (top-level or inside attachments), and alerting integrations (Sentry, PagerDuty, GitHub) put their payload in legacy attachment pretext / title / text / fields. The Python adapter builds Message.text / Message.formatted from event["text"] only, so handlers, thread.messages, to_ai_messages and the AI tools see a one-line summary. This issue ports the Slack halves of upstream #817 and #846 so both parse paths fold that content into the message AST and plain text.

Upstream changes

  • 764e4759 fix(slack): preserve pasted tables in message content (#817) — chat@4.38.1 — parses table/data_table blocks (top-level and non-unfurl attachment blocks) into mdast table nodes. Cells are flattened to mrkdwn (blocktext: link/emoji/user/broadcast/channel/usergroup/date/color/team/raw_* values). Headerless pasted tables get an empty header row. Tables before the first non-table block stay above the text. text becomes toPlainText(formatted). The core markdown.ts half belongs to [4.41/C2b] Shared text utilities: bare-mention scanner, code-fence helpers, to_plain_text structural whitespace #193.
  • 864d9222 fix(slack): keep alert attachment content on normalized messages (#846) — chat@4.39.0 — non-unfurl attachments contribute pretext, title (linked to title_link, whose URL is also added to message.links), text and fields, literal unless named in mrkdwn_in. fallback is used only when nothing else renders. Blocks win over legacy fields; each attachment's tables stay next to its text. Mention IDs across body, cells and attachments are looked up in one parallel wave (collectMentionIds / lookupMentionNames / applyMentionNames). Its previous_message hunk in the message_changed path belongs to [4.41/SL6] Slack: dispatch message_changed / message_deleted to lifecycle handlers #211.
  • Context only: 683eadc1 fix(slack): read mentions from message content (#947) — chat@4.41.0 — classifies mentions in attachment blocks/parts (classifyAttachmentPart). [4.41/SL4] Slack inbound normalization: self-mention decode, content is_mention, mrkdwn fixes, bot author ids, socket retries #209 ports it in full (including its attachment tests) with a minimal private attachment extractor; this issue only makes that extractor reuse the new rendering helpers.

Current Python behavior

  • src/chat_sdk/adapters/slack/adapter.py:3306-3387 _parse_slack_message: raw_text = event.get("text", "") (:3315). At :3343 only that string goes through _resolve_inline_mentions, and :3361-3362 build text/formatted from it via extract_plain_text / to_ast.
  • adapter.py:3389-3431 _parse_slack_message_sync (used by parse_message at :4673) does the same with no lookups.
  • adapter.py:3082-3131 _extract_links reads attachments only for unfurl metadata (from_url/original_url, :3106-3118). title_link is never surfaced.
  • adapter.py:2923-2989 _resolve_inline_mentions does collect, lookup and apply in one method; there is no reusable collect/apply split.
  • src/chat_sdk/adapters/slack/types.py:224-246 SlackEvent TypedDict has no attachments field, and blocks is typed as list[SlackRichTextBlock] only.
  • grep -n 'pretext\|mrkdwn_in\|data_table\|title_link\|raw_number' src/chat_sdk/adapters/slack/adapter.py returns nothing.
  • src/chat_sdk/shared/markdown_parser.py:1083 ast_to_plain_text has no table/tableRow handling; [4.41/C2b] Shared text utilities: bare-mention scanner, code-fence helpers, to_plain_text structural whitespace #193 fixes that.

Scope

  • Module-level helpers in adapters/slack/adapter.py (or a new adapters/slack/content.py): _TABLE_BLOCK_TYPES, _block_text, _has_bold_text, _table_data, _event_tables (leading/trailing split), _is_foreign_attachment, _author_attachments, _attachment_content (parts plus tables), _literal_phrasing.
  • Split _resolve_inline_mentions into _collect_mention_ids(text, user_ids, channel_ids), async _lookup_mention_names(...) and _apply_mention_names(text, names). Keep the split-on-< parsing; no new regex over user text.
  • _content(event, text, attachments) (sync) and async _resolved_content(...): collect IDs from body, cells and attachment parts, one lookup wave, then apply. Plus _assemble_content, _attachment_nodes (literal lines share one paragraph joined by break; each mrkdwn part parsed in isolation), _table_node, _cell_children.
  • _parse_slack_message and _parse_slack_message_sync: formatted = <content>, text = ast_to_plain_text(formatted) (the C2b-updated helper). parse_message inherits the sync path.
  • _extract_links: add title_link of non-foreign attachments.
  • Point [4.41/SL4] Slack inbound normalization: self-mention decode, content is_mention, mrkdwn fixes, bot author ids, socket retries #209's _detect_self_mention attachment extractor at _author_attachments / _attachment_content so mention classification and rendering read the same parts. No behavior change; SL4's attachment mention tests must still pass untouched.
  • Extend SlackEvent: attachments (blocks, fallback, fields, is_app_unfurl, is_msg_unfurl, mrkdwn_in, pretext, title, title_link, …) and a SlackMessageBlock with rows.

Out of scope

Porting notes

  • Payloads are untrusted: guard with isinstance(x, dict) / isinstance(x, str) everywhere (upstream's isRecord/str). A cell value may be 0, False or ""; test type / is not None, never truthiness. Match JS String(): check bool before int (bool subclasses int) and emit "true"/"false"; str(3) → "3", str(1.5) → "1.5".
  • Date cells without fallback: datetime.fromtimestamp(ts, tz=timezone.utc).date().isoformat() (upstream toISOString().slice(0, 10)), never a naive local date.
  • headerless applies only to type == "table" with no bold cell in row 0; data_table row 0 is always the header.
  • Attachment title with title_link: f"<{title_link}|{escape_slack_text(title)}>" (adapters/slack/format.py:46), treated as literal.
  • fallback is used only when parts is empty and the attachment produced no tables.
  • Routing change: attachment content now reaches message.text, so on_message regex patterns and anything reading text see it. Document that handlers can filter on message.author.is_bot.
  • No background tasks; everything runs inside the existing message factory.

Tests

Upstream packages/adapter-slack/src/index.test.ts is not fidelity-mapped (MAPPING covers packages/chat/src only). Port into tests/test_slack_adapter.py or a new tests/test_slack_inbound_content.py:

  • #817: "preserves pasted table attachments as message content", "preserves table-only messages and ignores malformed table blocks", "preserves inline rich text within table cells", "preserves rich text metadata within table cells", "formats date cells from the timestamp when no fallback is present", "preserves empty and raw value cells so columns stay aligned", "parses data_table blocks with their header row intact", "ignores tables in unfurl and app attachments", "keeps tables pasted above the message text above it", "does not leave raw bot mention tokens in table cells".
  • #846: "preserves alert attachment content as message content", "falls back to attachment fallback only when nothing else carries content", "ignores content in unfurl and app attachments", "keeps attachment formatting characters literal unless mrkdwn_in enables them", "keeps attachment content out of an unclosed code fence in the body", "uses the fallback when attachment blocks carry nothing renderable", "prefers attachment blocks over legacy fields, matching Slack rendering", "links the attachment title to title_link and surfaces the URL", "keeps each attachment's tables adjacent to its text", "resolves mentions in attachment content with a single lookup per user" (assert the users_info AsyncMock await_count == 1).
  • Do not re-port the #947 attachment tests; they land with [4.41/SL4] Slack inbound normalization: self-mention decode, content is_mention, mrkdwn fixes, bot author ids, socket retries #209.
  • Python-specific: raw_boolean False renders as false; a non-dict attachment or row is skipped without raising; sync and async paths give equal formatted for an event with no mentions; an on_message pattern matching only attachment text now fires. Use the Ev0ATABLE001 payload from packages/adapter-slack/sample-messages.md as the table fixture.

Acceptance criteria

  • Full validation command from CLAUDE.md passes.
  • docs/UPSTREAM_SYNC.md updated for any divergence (e.g. a separate content.py module).
  • CHANGELOG entry under "Unreleased (4.41 wave)".
  • Consumer-visible changes called out: message.text/formatted now include pasted tables (tab-separated) and alert attachment content; message.links gains attachment title_links; on_message patterns can match attachment text.

Dependencies

Blocked by #209 (content is_mention, attachment extractor, to_ast normalization; SL4 itself needs #193's to_plain_text table rules).

Verify first

Metadata

  • Effort: L (about 800 LOC including tests)
  • Consumer impact: low. Additive, but LLM prompts built from message.text get more content, and alert-bot messages in subscribed channels can now match pattern handlers. Streaming is unaffected.
  • Suggested branch: sync/4.41-sl5

Part of #184.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions