From 7dc0e8971026363573d422243ee45e1f129fef73 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 30 Apr 2026 13:46:45 +0000 Subject: [PATCH 1/5] fix(slack): render_postable uses AST path for PostableMarkdown (issue #81) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../adapters/slack/format_converter.py | 4 +- tests/test_slack_format.py | 61 +++++++++++++++++++ 2 files changed, 63 insertions(+), 2 deletions(-) diff --git a/src/chat_sdk/adapters/slack/format_converter.py b/src/chat_sdk/adapters/slack/format_converter.py index 0217381..d8261ff 100644 --- a/src/chat_sdk/adapters/slack/format_converter.py +++ b/src/chat_sdk/adapters/slack/format_converter.py @@ -84,12 +84,12 @@ 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"]) # 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 "" diff --git a/tests/test_slack_format.py b/tests/test_slack_format.py index 438da76..946a93f 100644 --- a/tests/test_slack_format.py +++ b/tests/test_slack_format.py @@ -46,6 +46,67 @@ def test_mixed_formatting(self): assert "" 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) -> 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 " + + def test_dict_markdown_converts_link(self): + result = self.converter.render_postable({"markdown": "Check [this](https://example.com)"}) + assert result == "Check " + + 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 "" 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 "" 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" + + # --------------------------------------------------------------------------- # toMarkdown (mrkdwn -> markdown) # --------------------------------------------------------------------------- From 4736baa42971b924c0f3627ada7db2856b1feca7 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 30 Apr 2026 13:51:55 +0000 Subject: [PATCH 2/5] 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 --- .../adapters/slack/format_converter.py | 21 +++++++++++++++++-- 1 file changed, 19 insertions(+), 2 deletions(-) diff --git a/src/chat_sdk/adapters/slack/format_converter.py b/src/chat_sdk/adapters/slack/format_converter.py index d8261ff..dd54826 100644 --- a/src/chat_sdk/adapters/slack/format_converter.py +++ b/src/chat_sdk/adapters/slack/format_converter.py @@ -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) @@ -87,12 +88,28 @@ def render_postable(self, message: Any) -> str: 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.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.""" From 51c05fb6ff434fddaa55319f106277216f4c7882 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 6 May 2026 22:55:23 +0000 Subject: [PATCH 3/5] refactor(slack): delete dead _markdown_to_mrkdwn and add card/ast branch tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../adapters/slack/format_converter.py | 18 ------------- tests/test_slack_format.py | 25 +++++++++++++++++++ 2 files changed, 25 insertions(+), 18 deletions(-) diff --git a/src/chat_sdk/adapters/slack/format_converter.py b/src/chat_sdk/adapters/slack/format_converter.py index dd54826..323633e 100644 --- a/src/chat_sdk/adapters/slack/format_converter.py +++ b/src/chat_sdk/adapters/slack/format_converter.py @@ -202,24 +202,6 @@ def _convert_mentions_to_slack(self, text: str) -> str: """Convert @mentions to Slack format: @name -> <@name>.""" return re.sub(r"(?", 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) -> - 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): diff --git a/tests/test_slack_format.py b/tests/test_slack_format.py index 946a93f..65c8c7f 100644 --- a/tests/test_slack_format.py +++ b/tests/test_slack_format.py @@ -106,6 +106,31 @@ def test_postable_raw_bypasses_conversion(self): 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": } 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": } 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) From 7410492290371d45ab576aec6e79c326dfc73be7 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 6 May 2026 23:00:32 +0000 Subject: [PATCH 4/5] test(slack): expand format converter coverage to all node types and branches MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- tests/test_slack_format.py | 140 +++++++++++++++++++++++++++++++++++++ 1 file changed, 140 insertions(+) diff --git a/tests/test_slack_format.py b/tests/test_slack_format.py index 65c8c7f..6608062 100644 --- a/tests/test_slack_format.py +++ b/tests/test_slack_format.py @@ -368,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 ") == "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"}] From 84cfb3879cd3b86bbc7e0ddf62b3cc06b0a3f05e Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 7 May 2026 04:22:30 +0000 Subject: [PATCH 5/5] chore: bump version to 0.4.26.3 and update changelog https://claude.ai/code/session_01XZeM3apTXAHZmjndAqeKPw --- CHANGELOG.md | 20 ++++++++++++++++++++ pyproject.toml | 2 +- 2 files changed, 21 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8fd71da..ab61e86 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/pyproject.toml b/pyproject.toml index 9b19b53..97fb917 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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",