Skip to content

Commit 04c0658

Browse files
fix(slack): render_postable uses AST path for PostableMarkdown (closes #81) (#82)
* fix(slack): render_postable uses AST path for PostableMarkdown (issue #81) SlackFormatConverter.render_postable was calling _markdown_to_mrkdwn (a simple regex) for PostableMarkdown and {"markdown": ...} dict inputs. The upstream TS SDK routes these through fromAst(parseMarkdown(text)), which correctly handles all markdown constructs — including links — via AST node handlers. The regex had two failure modes the AST path avoids: (a) its link pattern [^)]+ stops at the first ')' so URLs with parentheses were truncated, and (b) it had no equivalent of the link-node branch in _node_to_mrkdwn, making edge-case formatting harder to extend safely. Change both markdown branches in render_postable to call from_markdown instead. str and raw branches are unaffected — they correctly stay on _convert_mentions_to_slack since those inputs are already mrkdwn. Adds TestRenderPostable to test_slack_format.py covering the previously untested render_postable(PostableMarkdown(...)) code path, including link conversion, bold, mixed formatting, query-string URLs, and the str/raw passthrough cases. Closes #81 https://claude.ai/code/session_01XZeM3apTXAHZmjndAqeKPw * fix(slack): add card support and str fallback to render_postable Two pre-existing gaps in SlackFormatConverter.render_postable flagged in review: 1. Card types (PostableCard / CardElement dict) were unhandled; the override returned "" instead of routing through card_to_fallback_text like the base class does. 2. The fallback for unknown message shapes was "" (silent failure) rather than str(message), which matches BaseFormatConverter and is safer for unexpected inputs. Mirrors the base class structure for both cases. No new tests needed: these branches are identical to the base class and covered by its suite. https://claude.ai/code/session_01XZeM3apTXAHZmjndAqeKPw * refactor(slack): delete dead _markdown_to_mrkdwn and add card/ast branch tests Remove _markdown_to_mrkdwn — a regex-based private method with no call sites after render_postable was switched to the AST path. Deleting it restores structural parity with the TS SDK (which has no equivalent method). Also adds test coverage for the card dict branch, CardElement-style dict branch, .card attribute branch, and the {"ast": ...} dict branch in render_postable. https://claude.ai/code/session_01XZeM3apTXAHZmjndAqeKPw * test(slack): expand format converter coverage to all node types and branches Add 19 new tests covering previously untested paths: - render_postable: {"raw": ...} dict, CardElement dict, .ast attribute, arbitrary object fallback, multiple simultaneous @mentions - _node_to_mrkdwn: heading (h1/h2 → bold), blockquote, thematic break, image with alt, image without alt - extract_plain_text: strikethrough, bare URL, channel mention with name, bare channel, named user mention - to_blocks_with_table: non-dict AST returns None, standalone table emits no extra section blocks, column alignment produces column_settings https://claude.ai/code/session_01XZeM3apTXAHZmjndAqeKPw * chore: bump version to 0.4.26.3 and update changelog https://claude.ai/code/session_01XZeM3apTXAHZmjndAqeKPw --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent c36c8aa commit 04c0658

4 files changed

Lines changed: 268 additions & 23 deletions

File tree

CHANGELOG.md

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,25 @@
11
# Changelog
22

3+
## 0.4.26.3 (2026-05-07)
4+
5+
Python-only fix. No upstream version change.
6+
7+
### Fixes
8+
9+
- **`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.
10+
11+
### Structural parity
12+
13+
- **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`.
14+
15+
### Additions
16+
17+
- **`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.
18+
19+
### Test quality
20+
21+
- 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).
22+
323
## 0.4.26.2 (2026-04-24)
424

525
Parity catch-up with upstream `4.26.0`. No upstream version change.

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[project]
22
name = "chat-sdk"
3-
version = "0.4.26.2"
3+
version = "0.4.26.3"
44
description = "Multi-platform async chat SDK for Python — port of Vercel Chat"
55
keywords = [
66
"chat",

src/chat_sdk/adapters/slack/format_converter.py

Lines changed: 21 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -74,7 +74,8 @@ def to_ast(self, platform_text: str) -> Root:
7474
def render_postable(self, message: Any) -> str:
7575
"""Render a postable message to Slack mrkdwn string.
7676
77-
Supports str, ``{"raw": ...}``, ``{"markdown": ...}``, and ``{"ast": ...}``.
77+
Supports str, ``{"raw": ...}``, ``{"markdown": ...}``, ``{"ast": ...}``,
78+
and card types (``{"card": ...}`` / ``CardElement``).
7879
"""
7980
if isinstance(message, str):
8081
return self._convert_mentions_to_slack(message)
@@ -84,15 +85,31 @@ def render_postable(self, message: Any) -> str:
8485
if "raw" in message:
8586
return self._convert_mentions_to_slack(message["raw"])
8687
if "markdown" in message:
87-
return self._markdown_to_mrkdwn(message["markdown"])
88+
return self.from_markdown(message["markdown"])
8889
if "ast" in message:
8990
return self.from_ast(message["ast"])
91+
if "card" in message:
92+
from chat_sdk.cards import card_to_fallback_text
93+
94+
return card_to_fallback_text(message["card"])
95+
if message.get("type") == "card":
96+
from chat_sdk.cards import is_card_element
97+
98+
if is_card_element(message):
99+
from chat_sdk.cards import card_to_fallback_text
100+
101+
return card_to_fallback_text(message) # type: ignore[arg-type]
102+
return str(message)
90103
# Dataclass-style objects
91104
if hasattr(message, "markdown"):
92-
return self._markdown_to_mrkdwn(message.markdown)
105+
return self.from_markdown(message.markdown)
93106
if hasattr(message, "ast"):
94107
return self.from_ast(message.ast)
95-
return ""
108+
if hasattr(message, "card"):
109+
from chat_sdk.cards import card_to_fallback_text
110+
111+
return card_to_fallback_text(message.card)
112+
return str(message)
96113

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

188-
def _markdown_to_mrkdwn(self, text: str) -> str:
189-
"""Convert standard Markdown to Slack mrkdwn."""
190-
result = text
191-
192-
# Bold: **text** -> *text*
193-
result = re.sub(r"\*\*(.+?)\*\*", r"*\1*", result)
194-
195-
# Strikethrough: ~~text~~ -> ~text~
196-
result = re.sub(r"~~(.+?)~~", r"~\1~", result)
197-
198-
# Links: [text](url) -> <url|text>
199-
result = re.sub(r"\[([^\]]+)\]\(([^)]+)\)", r"<\2|\1>", result)
200-
201-
# Mentions
202-
result = self._convert_mentions_to_slack(result)
203-
204-
return result
205-
206205
def _node_to_mrkdwn(self, node: Content) -> str:
207206
"""Convert a single AST node to Slack mrkdwn."""
208207
if not isinstance(node, dict):

tests/test_slack_format.py

Lines changed: 226 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,92 @@ def test_mixed_formatting(self):
4646
assert "<https://x.com|link>" in result
4747

4848

49+
# ---------------------------------------------------------------------------
50+
# renderPostable — PostableMarkdown uses AST path (issue #81)
51+
# ---------------------------------------------------------------------------
52+
53+
54+
class TestRenderPostable:
55+
"""render_postable with PostableMarkdown must use the AST path (from_markdown),
56+
not the regex _markdown_to_mrkdwn, to match the TS SDK's fromAst(parseMarkdown())
57+
behavior. Regression guard for issue #81.
58+
"""
59+
60+
def setup_method(self):
61+
self.converter = SlackFormatConverter()
62+
63+
def test_postable_markdown_converts_link(self):
64+
"""[text](url) -> <url|text> via AST, not regex."""
65+
from chat_sdk.types import PostableMarkdown
66+
67+
result = self.converter.render_postable(PostableMarkdown(markdown="Check [this](https://example.com)"))
68+
assert result == "Check <https://example.com|this>"
69+
70+
def test_dict_markdown_converts_link(self):
71+
result = self.converter.render_postable({"markdown": "Check [this](https://example.com)"})
72+
assert result == "Check <https://example.com|this>"
73+
74+
def test_postable_markdown_converts_bold(self):
75+
from chat_sdk.types import PostableMarkdown
76+
77+
result = self.converter.render_postable(PostableMarkdown(markdown="Hello **world**!"))
78+
assert result == "Hello *world*!"
79+
80+
def test_postable_markdown_converts_mixed(self):
81+
from chat_sdk.types import PostableMarkdown
82+
83+
result = self.converter.render_postable(PostableMarkdown(markdown="**Bold** and [link](https://x.com)"))
84+
assert "*Bold*" in result
85+
assert "<https://x.com|link>" in result
86+
87+
def test_postable_markdown_link_with_query_string(self):
88+
"""URL with query params (no parens) converts correctly."""
89+
from chat_sdk.types import PostableMarkdown
90+
91+
result = self.converter.render_postable(
92+
PostableMarkdown(markdown="See [results](https://example.com/search?q=foo&page=2)")
93+
)
94+
assert "<https://example.com/search?q=foo&page=2|results>" in result
95+
96+
def test_str_passthrough_only_converts_mentions(self):
97+
"""str input is treated as already-mrkdwn; only @mentions are wrapped."""
98+
result = self.converter.render_postable("Hello *world* and @george")
99+
assert "*world*" in result
100+
assert "<@george>" in result
101+
102+
def test_postable_raw_bypasses_conversion(self):
103+
"""PostableRaw reaches Slack byte-for-byte (only mention wrapping)."""
104+
from chat_sdk.types import PostableRaw
105+
106+
result = self.converter.render_postable(PostableRaw(raw="Already *mrkdwn* text"))
107+
assert result == "Already *mrkdwn* text"
108+
109+
def test_dict_ast_converts_via_from_ast(self):
110+
"""{"ast": <root>} is rendered via from_ast."""
111+
from chat_sdk.shared.base_format_converter import parse_markdown
112+
113+
ast = parse_markdown("Hello **world**!")
114+
result = self.converter.render_postable({"ast": ast})
115+
assert result == "Hello *world*!"
116+
117+
def test_dict_card_uses_fallback_text(self):
118+
"""{"card": <payload>} extracts plain text via card_to_fallback_text."""
119+
card_payload = {"type": "card", "title": "My Card", "body": [{"type": "text", "text": "Card body"}]}
120+
result = self.converter.render_postable({"card": card_payload})
121+
assert isinstance(result, str)
122+
assert len(result) > 0
123+
124+
def test_object_with_card_attr_uses_fallback_text(self):
125+
"""Object with .card attribute extracts plain text via card_to_fallback_text."""
126+
127+
class FakeMessage:
128+
card = {"type": "card", "title": "Attr Card", "body": [{"type": "text", "text": "body text"}]}
129+
130+
result = self.converter.render_postable(FakeMessage())
131+
assert isinstance(result, str)
132+
assert len(result) > 0
133+
134+
49135
# ---------------------------------------------------------------------------
50136
# toMarkdown (mrkdwn -> markdown)
51137
# ---------------------------------------------------------------------------
@@ -282,3 +368,143 @@ def test_mixed_ordered_and_unordered(self):
282368
assert "sub a" in result
283369
assert "sub b" in result
284370
assert "2. second" in result
371+
372+
373+
# ---------------------------------------------------------------------------
374+
# render_postable — remaining branch coverage
375+
# ---------------------------------------------------------------------------
376+
377+
378+
class TestRenderPostableRemainingBranches:
379+
def setup_method(self):
380+
self.converter = SlackFormatConverter()
381+
382+
def test_dict_raw_treated_as_mrkdwn_with_mention_wrapping(self):
383+
"""{"raw": ...} is treated as already-mrkdwn; only @mentions are wrapped."""
384+
result = self.converter.render_postable({"raw": "Already *mrkdwn* @george"})
385+
assert result == "Already *mrkdwn* <@george>"
386+
387+
def test_card_element_dict_renders_via_fallback_text(self):
388+
"""{"type": "card", ...} CardElement dict uses card_to_fallback_text."""
389+
from chat_sdk.cards import Card
390+
391+
card = Card(title="My Card")
392+
result = self.converter.render_postable(card)
393+
assert "My Card" in result
394+
395+
def test_object_with_ast_attr_renders_via_from_ast(self):
396+
"""Object with .ast attribute is rendered via from_ast."""
397+
from chat_sdk.shared.base_format_converter import parse_markdown
398+
399+
class FakeMsg:
400+
ast = parse_markdown("Hello **world**!")
401+
402+
result = self.converter.render_postable(FakeMsg())
403+
assert result == "Hello *world*!"
404+
405+
def test_arbitrary_object_falls_back_to_str(self):
406+
"""Objects with no recognized attributes fall back to str()."""
407+
408+
class Opaque:
409+
def __str__(self):
410+
return "opaque output"
411+
412+
result = self.converter.render_postable(Opaque())
413+
assert result == "opaque output"
414+
415+
def test_multiple_at_mentions_in_str_all_wrapped(self):
416+
"""All bare @mentions in a str input are converted, not just the first."""
417+
result = self.converter.render_postable("Ping @alice and @bob please")
418+
assert "<@alice>" in result
419+
assert "<@bob>" in result
420+
421+
422+
# ---------------------------------------------------------------------------
423+
# _node_to_mrkdwn — individual node type rendering
424+
# ---------------------------------------------------------------------------
425+
426+
427+
class TestNodeRendering:
428+
def setup_method(self):
429+
self.converter = SlackFormatConverter()
430+
431+
def test_heading_renders_as_bold(self):
432+
assert self.converter.from_markdown("# My Heading") == "*My Heading*"
433+
434+
def test_h2_heading_renders_as_bold(self):
435+
assert self.converter.from_markdown("## Section Title") == "*Section Title*"
436+
437+
def test_blockquote_renders_with_gt_prefix(self):
438+
result = self.converter.from_markdown("> quoted text")
439+
assert result == "> quoted text"
440+
441+
def test_thematic_break_renders_as_dashes(self):
442+
result = self.converter.from_markdown("before\n\n---\n\nafter")
443+
assert "---" in result
444+
assert "before" in result
445+
assert "after" in result
446+
447+
def test_image_with_alt_renders_alt_and_url(self):
448+
result = self.converter.from_markdown("![alt text](https://example.com/img.png)")
449+
assert result == "alt text (https://example.com/img.png)"
450+
451+
def test_image_without_alt_renders_url_only(self):
452+
result = self.converter.from_markdown("![](https://example.com/img.png)")
453+
assert result == "https://example.com/img.png"
454+
455+
456+
# ---------------------------------------------------------------------------
457+
# extract_plain_text — additional cases
458+
# ---------------------------------------------------------------------------
459+
460+
461+
class TestExtractPlainTextAdditional:
462+
def setup_method(self):
463+
self.converter = SlackFormatConverter()
464+
465+
def test_removes_strikethrough_markers(self):
466+
assert self.converter.extract_plain_text("Hello ~world~!") == "Hello world!"
467+
468+
def test_extracts_bare_url(self):
469+
assert self.converter.extract_plain_text("Visit <https://example.com>") == "Visit https://example.com"
470+
471+
def test_extracts_channel_mention_with_name(self):
472+
assert self.converter.extract_plain_text("Join <#C123|general>") == "Join #general"
473+
474+
def test_extracts_bare_channel_mention(self):
475+
assert self.converter.extract_plain_text("Join <#C123>") == "Join #C123"
476+
477+
def test_user_mention_with_name_extracted(self):
478+
result = self.converter.extract_plain_text("Hey <@U123|john>!")
479+
assert result == "Hey @john!"
480+
481+
482+
# ---------------------------------------------------------------------------
483+
# to_blocks_with_table — additional cases
484+
# ---------------------------------------------------------------------------
485+
486+
487+
class TestToBlocksWithTableAdditional:
488+
def setup_method(self):
489+
self.converter = SlackFormatConverter()
490+
491+
def test_returns_none_for_non_dict_ast(self):
492+
assert self.converter.to_blocks_with_table("not a dict") is None # type: ignore[arg-type]
493+
assert self.converter.to_blocks_with_table(None) is None # type: ignore[arg-type]
494+
495+
def test_standalone_table_emits_no_extra_section_blocks(self):
496+
"""A table with no surrounding text produces exactly one block."""
497+
ast = self.converter.to_ast("| A | B |\n|---|---|\n| 1 | 2 |")
498+
blocks = self.converter.to_blocks_with_table(ast)
499+
assert blocks is not None
500+
assert len(blocks) == 1
501+
assert blocks[0]["type"] == "table"
502+
503+
def test_table_with_column_alignment_sets_column_settings(self):
504+
"""Aligned table columns produce column_settings on the table block."""
505+
md = "| Left | Center | Right |\n|:-----|:------:|------:|\n| a | b | c |"
506+
ast = self.converter.to_ast(md)
507+
blocks = self.converter.to_blocks_with_table(ast)
508+
assert blocks is not None
509+
settings = blocks[0].get("column_settings")
510+
assert settings == [{"align": "left"}, {"align": "center"}, {"align": "right"}]

0 commit comments

Comments
 (0)