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", diff --git a/src/chat_sdk/adapters/slack/format_converter.py b/src/chat_sdk/adapters/slack/format_converter.py index 0217381..323633e 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) @@ -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.""" @@ -185,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 438da76..6608062 100644 --- a/tests/test_slack_format.py +++ b/tests/test_slack_format.py @@ -46,6 +46,92 @@ 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" + + 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) # --------------------------------------------------------------------------- @@ -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 ") == "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"}]