Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,25 @@
# Changelog

## 0.4.26.3 (2026-05-07)

Python-only fix. No upstream version change.

### Fixes

- **`SlackFormatConverter.render_postable` now uses the AST path for all markdown inputs** (issue #81). Previously, `PostableMarkdown` and `{"markdown": ...}` dict inputs were routed through a private regex helper (`_markdown_to_mrkdwn`) that truncated URLs containing parentheses and diverged silently from the TS SDK's `fromAst(parseMarkdown(text))` behavior. Both branches now call `from_markdown`, which goes through the AST. `str` and `raw` branches are unchanged.

### Structural parity

- **Deleted `_markdown_to_mrkdwn`** — a regex-based private method with no call sites after the fix above. The TS SDK has no equivalent; its presence was an undocumented divergence. Removes a confusing dead-code path and restores structural parity with `adapter-slack/src/markdown.ts`.

### Additions

- **`render_postable` now handles card and object-with-ast inputs** — added `{"card": ...}` dict, `{"type": "card", ...}` `CardElement` dict, `{"ast": ...}` dict, and `.card` / `.ast` attribute branches, plus `str(message)` fallback for unrecognized types. Matches the full union of `AdapterPostableMessage` variants.

### Test quality

- Added 19 tests to `tests/test_slack_format.py` covering all `render_postable` branches, every `_node_to_mrkdwn` node type (heading, blockquote, thematic break, image with/without alt), the remaining `extract_plain_text` paths (strikethrough, bare URL, channel mentions), and `to_blocks_with_table` edge cases (non-dict AST, standalone table, column alignment).

## 0.4.26.2 (2026-04-24)

Parity catch-up with upstream `4.26.0`. No upstream version change.
Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[project]
name = "chat-sdk"
version = "0.4.26.2"
version = "0.4.26.3"
description = "Multi-platform async chat SDK for Python — port of Vercel Chat"
keywords = [
"chat",
Expand Down
43 changes: 21 additions & 22 deletions src/chat_sdk/adapters/slack/format_converter.py
Original file line number Diff line number Diff line change
Expand Up @@ -74,7 +74,8 @@ def to_ast(self, platform_text: str) -> Root:
def render_postable(self, message: Any) -> str:
"""Render a postable message to Slack mrkdwn string.

Supports str, ``{"raw": ...}``, ``{"markdown": ...}``, and ``{"ast": ...}``.
Supports str, ``{"raw": ...}``, ``{"markdown": ...}``, ``{"ast": ...}``,
and card types (``{"card": ...}`` / ``CardElement``).
"""
if isinstance(message, str):
return self._convert_mentions_to_slack(message)
Expand All @@ -84,15 +85,31 @@ def render_postable(self, message: Any) -> str:
if "raw" in message:
return self._convert_mentions_to_slack(message["raw"])
if "markdown" in message:
return self._markdown_to_mrkdwn(message["markdown"])
return self.from_markdown(message["markdown"])
if "ast" in message:
return self.from_ast(message["ast"])
if "card" in message:
from chat_sdk.cards import card_to_fallback_text

return card_to_fallback_text(message["card"])
if message.get("type") == "card":
from chat_sdk.cards import is_card_element

if is_card_element(message):
from chat_sdk.cards import card_to_fallback_text

return card_to_fallback_text(message) # type: ignore[arg-type]
return str(message)
# Dataclass-style objects
if hasattr(message, "markdown"):
return self._markdown_to_mrkdwn(message.markdown)
return self.from_markdown(message.markdown)
if hasattr(message, "ast"):
return self.from_ast(message.ast)
return ""
if hasattr(message, "card"):
from chat_sdk.cards import card_to_fallback_text

return card_to_fallback_text(message.card)
return str(message)

def extract_plain_text(self, platform_text: str) -> str:
"""Extract plain text from Slack mrkdwn by stripping formatting."""
Expand Down Expand Up @@ -185,24 +202,6 @@ def _convert_mentions_to_slack(self, text: str) -> str:
"""Convert @mentions to Slack format: @name -> <@name>."""
return re.sub(r"(?<!<)@(\w+)", r"<@\1>", text)

def _markdown_to_mrkdwn(self, text: str) -> str:
"""Convert standard Markdown to Slack mrkdwn."""
result = text

# Bold: **text** -> *text*
result = re.sub(r"\*\*(.+?)\*\*", r"*\1*", result)

# Strikethrough: ~~text~~ -> ~text~
result = re.sub(r"~~(.+?)~~", r"~\1~", result)

# Links: [text](url) -> <url|text>
result = re.sub(r"\[([^\]]+)\]\(([^)]+)\)", r"<\2|\1>", result)

# Mentions
result = self._convert_mentions_to_slack(result)

return result

def _node_to_mrkdwn(self, node: Content) -> str:
"""Convert a single AST node to Slack mrkdwn."""
if not isinstance(node, dict):
Expand Down
226 changes: 226 additions & 0 deletions tests/test_slack_format.py
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,92 @@ def test_mixed_formatting(self):
assert "<https://x.com|link>" in result


# ---------------------------------------------------------------------------
# renderPostable — PostableMarkdown uses AST path (issue #81)
# ---------------------------------------------------------------------------


class TestRenderPostable:
"""render_postable with PostableMarkdown must use the AST path (from_markdown),
not the regex _markdown_to_mrkdwn, to match the TS SDK's fromAst(parseMarkdown())
behavior. Regression guard for issue #81.
"""

def setup_method(self):
self.converter = SlackFormatConverter()

def test_postable_markdown_converts_link(self):
"""[text](url) -> <url|text> via AST, not regex."""
from chat_sdk.types import PostableMarkdown

result = self.converter.render_postable(PostableMarkdown(markdown="Check [this](https://example.com)"))
assert result == "Check <https://example.com|this>"

def test_dict_markdown_converts_link(self):
result = self.converter.render_postable({"markdown": "Check [this](https://example.com)"})
assert result == "Check <https://example.com|this>"

def test_postable_markdown_converts_bold(self):
from chat_sdk.types import PostableMarkdown

result = self.converter.render_postable(PostableMarkdown(markdown="Hello **world**!"))
assert result == "Hello *world*!"

def test_postable_markdown_converts_mixed(self):
from chat_sdk.types import PostableMarkdown

result = self.converter.render_postable(PostableMarkdown(markdown="**Bold** and [link](https://x.com)"))
assert "*Bold*" in result
assert "<https://x.com|link>" in result

def test_postable_markdown_link_with_query_string(self):
"""URL with query params (no parens) converts correctly."""
from chat_sdk.types import PostableMarkdown

result = self.converter.render_postable(
PostableMarkdown(markdown="See [results](https://example.com/search?q=foo&page=2)")
)
assert "<https://example.com/search?q=foo&page=2|results>" in result

def test_str_passthrough_only_converts_mentions(self):
"""str input is treated as already-mrkdwn; only @mentions are wrapped."""
result = self.converter.render_postable("Hello *world* and @george")
assert "*world*" in result
assert "<@george>" in result

def test_postable_raw_bypasses_conversion(self):
"""PostableRaw reaches Slack byte-for-byte (only mention wrapping)."""
from chat_sdk.types import PostableRaw

result = self.converter.render_postable(PostableRaw(raw="Already *mrkdwn* text"))
assert result == "Already *mrkdwn* text"

def test_dict_ast_converts_via_from_ast(self):
"""{"ast": <root>} is rendered via from_ast."""
from chat_sdk.shared.base_format_converter import parse_markdown

ast = parse_markdown("Hello **world**!")
result = self.converter.render_postable({"ast": ast})
assert result == "Hello *world*!"

def test_dict_card_uses_fallback_text(self):
"""{"card": <payload>} extracts plain text via card_to_fallback_text."""
card_payload = {"type": "card", "title": "My Card", "body": [{"type": "text", "text": "Card body"}]}
result = self.converter.render_postable({"card": card_payload})
assert isinstance(result, str)
assert len(result) > 0

def test_object_with_card_attr_uses_fallback_text(self):
"""Object with .card attribute extracts plain text via card_to_fallback_text."""

class FakeMessage:
card = {"type": "card", "title": "Attr Card", "body": [{"type": "text", "text": "body text"}]}

result = self.converter.render_postable(FakeMessage())
assert isinstance(result, str)
assert len(result) > 0


# ---------------------------------------------------------------------------
# toMarkdown (mrkdwn -> markdown)
# ---------------------------------------------------------------------------
Expand Down Expand Up @@ -282,3 +368,143 @@ def test_mixed_ordered_and_unordered(self):
assert "sub a" in result
assert "sub b" in result
assert "2. second" in result


# ---------------------------------------------------------------------------
# render_postable — remaining branch coverage
# ---------------------------------------------------------------------------


class TestRenderPostableRemainingBranches:
def setup_method(self):
self.converter = SlackFormatConverter()

def test_dict_raw_treated_as_mrkdwn_with_mention_wrapping(self):
"""{"raw": ...} is treated as already-mrkdwn; only @mentions are wrapped."""
result = self.converter.render_postable({"raw": "Already *mrkdwn* @george"})
assert result == "Already *mrkdwn* <@george>"

def test_card_element_dict_renders_via_fallback_text(self):
"""{"type": "card", ...} CardElement dict uses card_to_fallback_text."""
from chat_sdk.cards import Card

card = Card(title="My Card")
result = self.converter.render_postable(card)
assert "My Card" in result

def test_object_with_ast_attr_renders_via_from_ast(self):
"""Object with .ast attribute is rendered via from_ast."""
from chat_sdk.shared.base_format_converter import parse_markdown

class FakeMsg:
ast = parse_markdown("Hello **world**!")

result = self.converter.render_postable(FakeMsg())
assert result == "Hello *world*!"

def test_arbitrary_object_falls_back_to_str(self):
"""Objects with no recognized attributes fall back to str()."""

class Opaque:
def __str__(self):
return "opaque output"

result = self.converter.render_postable(Opaque())
assert result == "opaque output"

def test_multiple_at_mentions_in_str_all_wrapped(self):
"""All bare @mentions in a str input are converted, not just the first."""
result = self.converter.render_postable("Ping @alice and @bob please")
assert "<@alice>" in result
assert "<@bob>" in result


# ---------------------------------------------------------------------------
# _node_to_mrkdwn — individual node type rendering
# ---------------------------------------------------------------------------


class TestNodeRendering:
def setup_method(self):
self.converter = SlackFormatConverter()

def test_heading_renders_as_bold(self):
assert self.converter.from_markdown("# My Heading") == "*My Heading*"

def test_h2_heading_renders_as_bold(self):
assert self.converter.from_markdown("## Section Title") == "*Section Title*"

def test_blockquote_renders_with_gt_prefix(self):
result = self.converter.from_markdown("> quoted text")
assert result == "> quoted text"

def test_thematic_break_renders_as_dashes(self):
result = self.converter.from_markdown("before\n\n---\n\nafter")
assert "---" in result
assert "before" in result
assert "after" in result

def test_image_with_alt_renders_alt_and_url(self):
result = self.converter.from_markdown("![alt text](https://example.com/img.png)")
assert result == "alt text (https://example.com/img.png)"

def test_image_without_alt_renders_url_only(self):
result = self.converter.from_markdown("![](https://example.com/img.png)")
assert result == "https://example.com/img.png"


# ---------------------------------------------------------------------------
# extract_plain_text — additional cases
# ---------------------------------------------------------------------------


class TestExtractPlainTextAdditional:
def setup_method(self):
self.converter = SlackFormatConverter()

def test_removes_strikethrough_markers(self):
assert self.converter.extract_plain_text("Hello ~world~!") == "Hello world!"

def test_extracts_bare_url(self):
assert self.converter.extract_plain_text("Visit <https://example.com>") == "Visit https://example.com"

def test_extracts_channel_mention_with_name(self):
assert self.converter.extract_plain_text("Join <#C123|general>") == "Join #general"

def test_extracts_bare_channel_mention(self):
assert self.converter.extract_plain_text("Join <#C123>") == "Join #C123"

def test_user_mention_with_name_extracted(self):
result = self.converter.extract_plain_text("Hey <@U123|john>!")
assert result == "Hey @john!"


# ---------------------------------------------------------------------------
# to_blocks_with_table — additional cases
# ---------------------------------------------------------------------------


class TestToBlocksWithTableAdditional:
def setup_method(self):
self.converter = SlackFormatConverter()

def test_returns_none_for_non_dict_ast(self):
assert self.converter.to_blocks_with_table("not a dict") is None # type: ignore[arg-type]
assert self.converter.to_blocks_with_table(None) is None # type: ignore[arg-type]

def test_standalone_table_emits_no_extra_section_blocks(self):
"""A table with no surrounding text produces exactly one block."""
ast = self.converter.to_ast("| A | B |\n|---|---|\n| 1 | 2 |")
blocks = self.converter.to_blocks_with_table(ast)
assert blocks is not None
assert len(blocks) == 1
assert blocks[0]["type"] == "table"

def test_table_with_column_alignment_sets_column_settings(self):
"""Aligned table columns produce column_settings on the table block."""
md = "| Left | Center | Right |\n|:-----|:------:|------:|\n| a | b | c |"
ast = self.converter.to_ast(md)
blocks = self.converter.to_blocks_with_table(ast)
assert blocks is not None
settings = blocks[0].get("column_settings")
assert settings == [{"align": "left"}, {"align": "center"}, {"align": "right"}]
Loading