diff --git a/src/chat_sdk/adapters/telegram/rich.py b/src/chat_sdk/adapters/telegram/rich.py
new file mode 100644
index 0000000..251c037
--- /dev/null
+++ b/src/chat_sdk/adapters/telegram/rich.py
@@ -0,0 +1,456 @@
+"""Telegram rich-message → Markdown / plain-text rendering (Bot API 10.1).
+
+Python port of ``packages/adapter-telegram/src/rich.ts`` (chat@4.31.0,
+commit 4662309). Regex/parse-heavy module ported character-for-character.
+
+The rich-message wire types live in :mod:`chat_sdk.adapters.telegram.types`
+(TG1). This module converts the recursive ``TelegramRichBlock`` /
+``TelegramRichText`` shapes into Markdown (for sending) and plain text (for
+search/snippets), and extracts media attachments.
+"""
+
+from __future__ import annotations
+
+import re
+from dataclasses import dataclass
+from typing import Literal
+
+from chat_sdk.shared.streaming_markdown import StreamingMarkdownRenderer
+
+from .types import (
+ TelegramFile,
+ TelegramRichBlock,
+ TelegramRichBlockTable,
+ TelegramRichCaption,
+ TelegramRichCell,
+ TelegramRichItem,
+ TelegramRichMessage,
+ TelegramRichText,
+)
+
+TELEGRAM_RICH_MESSAGE_LIMIT = 32_768
+
+# Upstream: /[!-/:-@[-`{-~]/g -- the four ASCII punctuation ranges
+# "!"-"/" ":"-"@" "["-"`" "{"-"~"
+# A char class, so re.sub matches a single punctuation char at a time.
+MARKDOWN_PUNCTUATION = re.compile(r"[!-/:-@[-`{-~]")
+# Upstream: /[\r\n]/g
+LINE_BREAKS = re.compile(r"[\r\n]")
+# Upstream: /`+/g -- runs of one or more backticks.
+BACKTICKS = re.compile(r"`+")
+
+# The exact set of characters JS ``String.prototype.trim``/``trimEnd`` removes:
+# the ECMAScript WhiteSpace set plus the LineTerminator set. Python's bare
+# ``str.strip()``/``str.rstrip()`` strip a broader Unicode-whitespace set (e.g.
+# the C0 separators ``\x1c``-``\x1f`` and NEL ``\x85``, which JS keeps; and they
+# do NOT strip the BOM ````, which JS removes). Passing this explicit
+# string to ``strip``/``rstrip`` matches JS character-for-character.
+JS_WHITESPACE = (
+ "\t\n\v\f\r " # \t \n \v \f \r and SPACE
+ " " # NO-BREAK SPACE
+ " " # OGHAM SPACE MARK
+ " " # EN QUAD..HAIR SPACE
+ "
" # LINE SEPARATOR
+ "
" # PARAGRAPH SEPARATOR
+ " " # NARROW NO-BREAK SPACE
+ " " # MEDIUM MATHEMATICAL SPACE
+ " " # IDEOGRAPHIC SPACE
+ "" # ZERO WIDTH NO-BREAK SPACE (BOM)
+)
+
+
+def _present(value: TelegramRichText | None) -> bool:
+ """Match JS truthiness for an optional ``TelegramRichText`` field.
+
+ Upstream gates these fields with ``value.x ? ... : ""``. The only values
+ a ``TelegramRichText`` (``str | list | object``) can take that differ from
+ Python truthiness is the empty list ``[]``: JS arrays are ALWAYS truthy, so
+ ``credit: []`` renders upstream but a bare ``if value.get("credit")`` would
+ skip it in Python. We therefore render whenever the value is present and is
+ not the empty string ``""`` (the only falsy ``TelegramRichText`` in JS):
+
+ - ``None`` / absent -> skip (JS ``undefined`` is falsy)
+ - ``""`` -> skip (JS ``""`` is falsy)
+ - ``[]`` -> render (JS arrays are truthy)
+ - ``[span]`` / ``"text"`` / object -> render (truthy)
+ """
+ return value is not None and value != ""
+
+
+def truncate_rich_markdown(markdown: str) -> str:
+ """Truncate *markdown* to the Telegram rich-message character limit.
+
+ Truncation is code-point aware: ``list(str)`` yields one element per
+ Unicode code point (matching JS ``Array.from(str)``), so a slice on a
+ list index never splits a surrogate pair / non-BMP character. The
+ held-back tail is re-rendered through :class:`StreamingMarkdownRenderer`
+ so the truncated prefix stays valid markdown, with ``"..."`` appended.
+ """
+ characters = list(markdown)
+ if len(characters) <= TELEGRAM_RICH_MESSAGE_LIMIT:
+ return markdown
+
+ end = TELEGRAM_RICH_MESSAGE_LIMIT - 3
+ while end > 0:
+ renderer = StreamingMarkdownRenderer()
+ renderer.push("".join(characters[:end]))
+ rendered = f"{renderer.finish()}..."
+ if len(rendered) <= TELEGRAM_RICH_MESSAGE_LIMIT:
+ return rendered
+ end -= len(rendered) - TELEGRAM_RICH_MESSAGE_LIMIT
+
+ return "..."
+
+
+def _escape_text(value: str) -> str:
+ """Backslash-escape every Markdown punctuation char in *value*.
+
+ Upstream: ``value.replace(MARKDOWN_PUNCTUATION, "\\$&")`` -- each matched
+ punctuation char is prefixed with a single backslash. ``\\g<0>`` is the
+ Python equivalent of JS ``$&`` (the whole match).
+ """
+ return MARKDOWN_PUNCTUATION.sub(r"\\\g<0>", value)
+
+
+def _inline_code(value: str) -> str:
+ if not value:
+ return ""
+ runs = BACKTICKS.findall(value)
+ size = max(1, *(len(run) + 1 for run in runs)) if runs else 1
+ marker = "`" * size
+ has_boundary_space = value.startswith(" ") and value.endswith(" ") and len(value.strip(JS_WHITESPACE)) > 0
+ padding = " " if value.startswith("`") or value.endswith("`") or has_boundary_space else ""
+ return f"{marker}{padding}{value}{padding}{marker}"
+
+
+def _code_block(value: str, language: str | None = None) -> str:
+ runs = BACKTICKS.findall(value)
+ size = max(3, *(len(run) + 1 for run in runs)) if runs else 3
+ marker = "`" * size
+ info = LINE_BREAKS.sub(" ", language).replace("`", "") if language else ""
+ return f"{marker}{info}\n{value}\n{marker}"
+
+
+def _link_destination(value: str) -> str:
+ return (
+ "<"
+ + value.replace("\\", "%5C").replace("<", "%3C").replace(">", "%3E").replace("\r", "%0D").replace("\n", "%0A")
+ + ">"
+ )
+
+
+def _text(markdown: TelegramRichText) -> str:
+ if isinstance(markdown, str):
+ return _escape_text(markdown)
+ if isinstance(markdown, list):
+ return "".join(_text(part) for part in markdown)
+
+ match markdown["type"]:
+ case "bold":
+ return f"**{_text(markdown['text'])}**"
+ case "italic":
+ return f"_{_text(markdown['text'])}_"
+ case "underline":
+ return f"{_text(markdown['text'])}"
+ case "strikethrough":
+ return f"~~{_text(markdown['text'])}~~"
+ case "spoiler":
+ return f"||{_text(markdown['text'])}||"
+ case "subscript":
+ return f"{_text(markdown['text'])}"
+ case "superscript":
+ return f"{_text(markdown['text'])}"
+ case "marked":
+ return f"=={_text(markdown['text'])}=="
+ case "code":
+ return _inline_code(_plain(markdown["text"]))
+ case "date_time" | "text_mention":
+ return _text(markdown["text"])
+ case "bank_card_number" | "bot_command" | "cashtag" | "hashtag" | "mention":
+ return _text(markdown["text"])
+ case "custom_emoji":
+ return markdown["alternative_text"]
+ case "mathematical_expression":
+ return f"${markdown['expression']}$"
+ case "url":
+ return f"[{_text(markdown['text'])}]({_link_destination(markdown['url'])})"
+ case "email_address":
+ dest = _link_destination(f"mailto:{markdown['email_address']}")
+ return f"[{_text(markdown['text'])}]({dest})"
+ case "phone_number":
+ dest = _link_destination(f"tel:{markdown['phone_number']}")
+ return f"[{_text(markdown['text'])}]({dest})"
+ case "anchor":
+ return ""
+ case "anchor_link" | "reference" | "reference_link":
+ return _text(markdown["text"])
+ case _:
+ return ""
+
+
+def _plain(markdown: TelegramRichText) -> str:
+ if isinstance(markdown, str):
+ return markdown
+ if isinstance(markdown, list):
+ return "".join(_plain(part) for part in markdown)
+
+ match markdown["type"]:
+ case (
+ "bold"
+ | "italic"
+ | "underline"
+ | "strikethrough"
+ | "spoiler"
+ | "subscript"
+ | "superscript"
+ | "marked"
+ | "code"
+ | "date_time"
+ | "text_mention"
+ | "url"
+ | "email_address"
+ | "phone_number"
+ | "bank_card_number"
+ | "mention"
+ | "hashtag"
+ | "cashtag"
+ | "bot_command"
+ | "anchor_link"
+ | "reference"
+ | "reference_link"
+ ):
+ return _plain(markdown["text"])
+ case "custom_emoji":
+ return markdown["alternative_text"]
+ case "mathematical_expression":
+ return markdown["expression"]
+ case "anchor":
+ return ""
+ case _:
+ return ""
+
+
+def _caption(value: TelegramRichCaption | None = None) -> str:
+ if not value:
+ return ""
+ credit = f"\n{_text(value['credit'])}" if _present(value.get("credit")) else ""
+ return f"{_text(value['text'])}{credit}"
+
+
+def _plain_caption(value: TelegramRichCaption | None = None) -> str:
+ if not value:
+ return ""
+ credit = f"\n{_plain(value['credit'])}" if _present(value.get("credit")) else ""
+ return f"{_plain(value['text'])}{credit}"
+
+
+def _cell(value: TelegramRichCell) -> str:
+ return _text(value["text"]) if _present(value.get("text")) else ""
+
+
+def _item(value: TelegramRichItem) -> str:
+ checked = ""
+ if value.get("has_checkbox"):
+ checked = "[x] " if value.get("is_checked") else "[ ] "
+ content = "\n\n".join(_block(b) for b in value["blocks"]).replace("\n", "\n ")
+ return f"{value['label']} {checked}{content}".rstrip(JS_WHITESPACE)
+
+
+def _plain_item(value: TelegramRichItem) -> str:
+ checked = ""
+ if value.get("has_checkbox"):
+ checked = "[x] " if value.get("is_checked") else "[ ] "
+ content = "\n".join(b for b in (_plain_block(blk) for blk in value["blocks"]) if b).replace("\n", "\n ")
+ return f"{value['label']} {checked}{content}".rstrip(JS_WHITESPACE)
+
+
+def _table(value: TelegramRichBlockTable) -> str:
+ rows = [f"| {' | '.join(_cell(c) for c in row)} |" for row in value["cells"]]
+ if len(rows) == 0:
+ return _text(value["caption"]) if _present(value.get("caption")) else ""
+
+ columns = max(len(row) for row in value["cells"])
+ separator = f"| {' | '.join('---' for _ in range(columns))} |"
+ content = "\n".join([rows[0], separator, *rows[1:]])
+ return f"{_text(value['caption'])}\n\n{content}" if _present(value.get("caption")) else content
+
+
+def _quote(value: str) -> str:
+ return "\n".join(f"> {line}" for line in value.split("\n"))
+
+
+def _block(value: TelegramRichBlock) -> str:
+ match value["type"]:
+ case "paragraph" | "footer" | "thinking":
+ return _text(value["text"])
+ case "heading":
+ level = min(6, max(1, value["size"]))
+ return f"{'#' * level} {_text(value['text'])}"
+ case "pre":
+ return _code_block(_plain(value["text"]), value.get("language"))
+ case "divider":
+ return "---"
+ case "mathematical_expression":
+ return f"$${value['expression']}$$"
+ case "anchor":
+ return ""
+ case "list":
+ return "\n".join(_item(i) for i in value["items"])
+ case "blockquote":
+ content = "\n\n".join(_block(b) for b in value["blocks"])
+ credit = f"\n\n{_text(value['credit'])}" if _present(value.get("credit")) else ""
+ return _quote(f"{content}{credit}")
+ case "pullquote":
+ credit = f"\n\n{_text(value['credit'])}" if _present(value.get("credit")) else ""
+ return _quote(f"{_text(value['text'])}{credit}")
+ case "collage" | "slideshow":
+ content = "\n\n".join(b for b in (_block(blk) for blk in value["blocks"]) if b)
+ description = _caption(value.get("caption"))
+ return "\n\n".join(part for part in (content, description) if part)
+ case "table":
+ return _table(value)
+ case "details":
+ body = "\n\n".join(_block(b) for b in value["blocks"])
+ return f"{_text(value['summary'])}\n\n{body}"
+ case "map":
+ return _caption(value.get("caption"))
+ case "animation" | "audio" | "photo" | "video" | "voice_note":
+ return _caption(value.get("caption"))
+ case _:
+ return ""
+
+
+def _plain_block(value: TelegramRichBlock) -> str:
+ match value["type"]:
+ case "paragraph" | "footer" | "thinking" | "heading" | "pre":
+ return _plain(value["text"])
+ case "divider" | "anchor":
+ return ""
+ case "mathematical_expression":
+ return value["expression"]
+ case "list":
+ return "\n".join(_plain_item(i) for i in value["items"])
+ case "blockquote":
+ content = "\n\n".join(b for b in (_plain_block(blk) for blk in value["blocks"]) if b)
+ credit = f"\n\n{_plain(value['credit'])}" if _present(value.get("credit")) else ""
+ return f"{content}{credit}"
+ case "pullquote":
+ credit = f"\n\n{_plain(value['credit'])}" if _present(value.get("credit")) else ""
+ return f"{_plain(value['text'])}{credit}"
+ case "collage" | "slideshow":
+ content = "\n\n".join(b for b in (_plain_block(blk) for blk in value["blocks"]) if b)
+ description = _plain_caption(value.get("caption"))
+ return "\n\n".join(part for part in (content, description) if part)
+ case "table":
+ rows = [
+ "\t".join(_plain(entry["text"]) if _present(entry.get("text")) else "" for entry in row)
+ for row in value["cells"]
+ ]
+ content = "\n".join(rows)
+ return f"{_plain(value['caption'])}\n\n{content}" if _present(value.get("caption")) else content
+ case "details":
+ body = "\n\n".join(b for b in (_plain_block(blk) for blk in value["blocks"]) if b)
+ return f"{_plain(value['summary'])}\n\n{body}"
+ case "map":
+ return _plain_caption(value.get("caption"))
+ case "animation" | "audio" | "photo" | "video" | "voice_note":
+ return _plain_caption(value.get("caption"))
+ case _:
+ return ""
+
+
+@dataclass
+class RichMedia:
+ """A media attachment extracted from a rich message."""
+
+ file: TelegramFile
+ type: Literal["image", "file", "video", "audio"]
+ height: int | None = None
+ mime_type: str | None = None
+ name: str | None = None
+ width: int | None = None
+
+
+def _media(blocks: list[TelegramRichBlock], result: list[RichMedia]) -> None:
+ for value in blocks:
+ match value["type"]:
+ case "list":
+ for entry in value["items"]:
+ _media(entry["blocks"], result)
+ case "blockquote" | "collage" | "slideshow" | "details":
+ _media(value["blocks"], result)
+ case "animation":
+ animation = value["animation"]
+ mime_type = animation.get("mime_type")
+ result.append(
+ RichMedia(
+ file=animation,
+ height=animation.get("height"),
+ mime_type=mime_type,
+ name=animation.get("file_name"),
+ type="image" if mime_type is not None and mime_type.startswith("image/") else "video",
+ width=animation.get("width"),
+ )
+ )
+ case "audio":
+ audio = value["audio"]
+ result.append(
+ RichMedia(
+ file=audio,
+ mime_type=audio.get("mime_type"),
+ name=audio.get("file_name"),
+ type="audio",
+ )
+ )
+ case "photo":
+ photos = value["photo"]
+ photo = photos[-1] if photos else None
+ if photo:
+ result.append(
+ RichMedia(
+ file=photo,
+ height=photo.get("height"),
+ type="image",
+ width=photo.get("width"),
+ )
+ )
+ case "video":
+ video = value["video"]
+ result.append(
+ RichMedia(
+ file=video,
+ height=video.get("height"),
+ mime_type=video.get("mime_type"),
+ name=video.get("file_name"),
+ type="video",
+ width=video.get("width"),
+ )
+ )
+ case "voice_note":
+ voice_note = value["voice_note"]
+ result.append(
+ RichMedia(
+ file=voice_note,
+ mime_type=voice_note.get("mime_type"),
+ type="audio",
+ )
+ )
+ case _:
+ pass
+
+
+def rich_message_to_markdown(message: TelegramRichMessage) -> str:
+ """Render a Telegram rich message to Markdown."""
+ return "\n\n".join(b for b in (_block(blk) for blk in message["blocks"]) if b).strip(JS_WHITESPACE)
+
+
+def rich_message_to_text(message: TelegramRichMessage) -> str:
+ """Render a Telegram rich message to plain text."""
+ return "\n\n".join(b for b in (_plain_block(blk) for blk in message["blocks"]) if b).strip(JS_WHITESPACE)
+
+
+def rich_message_media(message: TelegramRichMessage) -> list[RichMedia]:
+ """Extract media attachments from a Telegram rich message."""
+ result: list[RichMedia] = []
+ _media(message["blocks"], result)
+ return result
diff --git a/tests/test_telegram_rich.py b/tests/test_telegram_rich.py
new file mode 100644
index 0000000..3be9d5c
--- /dev/null
+++ b/tests/test_telegram_rich.py
@@ -0,0 +1,438 @@
+"""Tests for the Telegram rich-message renderer.
+
+Ports ``packages/adapter-telegram/src/rich.test.ts`` (chat@4.31.0, commit
+4662309) plus adversarial coverage for the substitution / regex passes:
+a full-punctuation escape sweep, all-backtick fence run-sizing, and a
+surrogate-pair (non-BMP) truncation-boundary check.
+
+Fixtures are annotated to the concrete TG1 rich-block / rich-text TypedDict
+variants so the file stays pyrefly-sound.
+"""
+
+from __future__ import annotations
+
+from chat_sdk.adapters.telegram.rich import (
+ TELEGRAM_RICH_MESSAGE_LIMIT,
+ RichMedia,
+ rich_message_media,
+ rich_message_to_markdown,
+ rich_message_to_text,
+ truncate_rich_markdown,
+)
+from chat_sdk.adapters.telegram.rich import (
+ _escape_text as escape_text,
+)
+from chat_sdk.adapters.telegram.rich import (
+ _inline_code as inline_code,
+)
+from chat_sdk.adapters.telegram.types import (
+ TelegramPhotoSize,
+ TelegramRichBlockAnimation,
+ TelegramRichBlockHeading,
+ TelegramRichBlockList,
+ TelegramRichBlockPhoto,
+ TelegramRichBlockPre,
+ TelegramRichBlockPullquote,
+ TelegramRichBlockTable,
+ TelegramRichBlockText,
+ TelegramRichCell,
+ TelegramRichItem,
+ TelegramRichMessage,
+ TelegramRichText,
+ TelegramRichTextHashtag,
+ TelegramRichTextMention,
+ TelegramRichTextStyled,
+ TelegramRichTextUrl,
+)
+from chat_sdk.shared.markdown_parser import ast_to_plain_text, parse_markdown
+
+
+def test_normalizes_structured_rich_blocks_to_markdown() -> None:
+ """Headings, links, bold spans, and tables render to Markdown."""
+ heading: TelegramRichBlockHeading = {"type": "heading", "size": 2, "text": "Summary"}
+ guide_url: TelegramRichTextUrl = {
+ "type": "url",
+ "text": "the guide",
+ "url": "https://example.com",
+ }
+ continue_bold: TelegramRichTextStyled = {"type": "bold", "text": "continue"}
+ paragraph_text: TelegramRichText = ["Read ", guide_url, " and ", continue_bold]
+ paragraph: TelegramRichBlockText = {"type": "paragraph", "text": paragraph_text}
+ header_row: list[TelegramRichCell] = [
+ {"align": "left", "is_header": True, "text": "Name", "valign": "top"},
+ {"align": "right", "is_header": True, "text": "Status", "valign": "top"},
+ ]
+ body_row: list[TelegramRichCell] = [
+ {"align": "left", "text": "Build", "valign": "top"},
+ {"align": "right", "text": "Ready", "valign": "top"},
+ ]
+ table: TelegramRichBlockTable = {"type": "table", "cells": [header_row, body_row]}
+ message: TelegramRichMessage = {"blocks": [heading, paragraph, table]}
+
+ assert rich_message_to_markdown(message) == "\n\n".join(
+ [
+ "## Summary",
+ "Read [the guide]() and **continue**",
+ "| Name | Status |\n| --- | --- |\n| Build | Ready |",
+ ]
+ )
+ assert "Read the guide and continue" in rich_message_to_text(message)
+
+
+def test_preserves_displayed_text_for_detected_entities() -> None:
+ """Mention/hashtag entities keep their displayed text through escape + parse."""
+ mention: TelegramRichTextMention = {
+ "type": "mention",
+ "text": "@chat_sdk",
+ "username": "chat_sdk",
+ }
+ hashtag: TelegramRichTextHashtag = {
+ "type": "hashtag",
+ "hashtag": "release",
+ "text": "#release",
+ }
+ paragraph_text: TelegramRichText = [mention, " ", hashtag]
+ paragraph: TelegramRichBlockText = {"type": "paragraph", "text": paragraph_text}
+ message: TelegramRichMessage = {"blocks": [paragraph]}
+
+ assert rich_message_to_text(message) == "@chat_sdk #release"
+ assert ast_to_plain_text(parse_markdown(rich_message_to_markdown(message))) == "@chat_sdk #release"
+
+
+def test_escapes_literal_rich_text_without_changing_displayed_content() -> None:
+ """Inline code, links, literal markdown, and fenced code escape correctly.
+
+ Asserts on the renderer's emitted Markdown string (the load-bearing,
+ char-for-char port). Upstream's CommonMark round-trip via
+ ``mdast-util-from-markdown`` is not replicated here because the Python
+ markdown parser is intentionally not full CommonMark (it does not handle
+ angle-bracket link destinations or fence widening); see
+ ``docs/SELF_REVIEW.md`` / CLAUDE.md "Known Limitations".
+ """
+ code_span: TelegramRichTextStyled = {"type": "code", "text": " a ` b "}
+ empty_code: TelegramRichTextStyled = {"type": "code", "text": ""}
+ link_span: TelegramRichTextUrl = {
+ "type": "url",
+ "text": "label ] x",
+ "url": "https://example.com/a_(b)",
+ }
+ paragraph_text: TelegramRichText = [
+ code_span,
+ empty_code,
+ " ",
+ link_span,
+ "\n# literal\n- item\n~~plain~~",
+ ]
+ paragraph: TelegramRichBlockText = {"type": "paragraph", "text": paragraph_text}
+ pre: TelegramRichBlockPre = {
+ "type": "pre",
+ "language": "type`script",
+ "text": "const fence = ```;",
+ }
+ message: TelegramRichMessage = {"blocks": [paragraph, pre]}
+
+ markdown = rich_message_to_markdown(message)
+
+ # Inline code: backtick run of 1 -> 2-backtick fence; boundary spaces pad.
+ assert "`` a ` b ``" in markdown
+ # Empty inline code renders to nothing (no stray fence).
+ assert "````" not in markdown.split("\n\n")[0]
+ # Link: display text has its ``]`` escaped, url wrapped in angle brackets.
+ assert "[label \\] x]()" in markdown
+ # Literal markdown is backslash-escaped, not interpreted.
+ assert "\\# literal\n\\- item\n\\~\\~plain\\~\\~" in markdown
+ # Pre block: inner ``` run (3) widens the fence to 4, language sanitized
+ # (backticks stripped from ``type`script`` -> ``typescript``).
+ assert markdown.endswith("````typescript\nconst fence = ```;\n````")
+
+
+def test_normalizes_rich_formatting_to_plain_text() -> None:
+ """Styled spans and tables collapse to tab/newline-joined plain text."""
+ underline: TelegramRichTextStyled = {"type": "underline", "text": "underlined"}
+ subscript: TelegramRichTextStyled = {"type": "subscript", "text": "subscript"}
+ marked: TelegramRichTextStyled = {"type": "marked", "text": "marked"}
+ paragraph_text: TelegramRichText = [underline, " ", subscript, " ", marked]
+ paragraph: TelegramRichBlockText = {"type": "paragraph", "text": paragraph_text}
+ header_row: list[TelegramRichCell] = [
+ {"align": "left", "text": "Name", "valign": "top"},
+ {"align": "left", "text": "Status", "valign": "top"},
+ ]
+ body_row: list[TelegramRichCell] = [
+ {"align": "left", "text": "Build", "valign": "top"},
+ {"align": "left", "text": "Ready", "valign": "top"},
+ ]
+ table: TelegramRichBlockTable = {"type": "table", "cells": [header_row, body_row]}
+ message: TelegramRichMessage = {"blocks": [paragraph, table]}
+
+ assert rich_message_to_text(message) == "underlined subscript marked\n\nName\tStatus\nBuild\tReady"
+
+
+def test_truncates_markdown_at_the_rich_message_limit() -> None:
+ """Over-limit markdown is truncated to the limit and ends with ``...``."""
+ markdown = truncate_rich_markdown("a" * (TELEGRAM_RICH_MESSAGE_LIMIT + 100))
+
+ assert len(markdown) <= TELEGRAM_RICH_MESSAGE_LIMIT
+ assert markdown.endswith("...")
+
+
+def test_preserves_a_table_like_trailing_line_when_truncating() -> None:
+ """A trailing table-like line survives the renderer's truncation pass."""
+ prefix = f"{'a' * (TELEGRAM_RICH_MESSAGE_LIMIT - 12)}\n| tail |"
+ markdown = f"{prefix}{'b' * 100}"
+
+ assert "| tail |" in truncate_rich_markdown(markdown)
+
+
+# ---------------------------------------------------------------------------
+# Media extraction (richMessageMedia)
+# ---------------------------------------------------------------------------
+
+
+def test_extracts_media_and_picks_largest_photo() -> None:
+ """``rich_message_media`` recurses lists and picks the last photo size."""
+ small: TelegramPhotoSize = {
+ "file_id": "small",
+ "file_unique_id": "s",
+ "height": 10,
+ "width": 10,
+ }
+ large: TelegramPhotoSize = {
+ "file_id": "large",
+ "file_unique_id": "l",
+ "height": 200,
+ "width": 300,
+ }
+ photo_block: TelegramRichBlockPhoto = {"type": "photo", "photo": [small, large]}
+ item: TelegramRichItem = {"label": "1", "blocks": [photo_block]}
+ list_block: TelegramRichBlockList = {"type": "list", "items": [item]}
+ message: TelegramRichMessage = {"blocks": [list_block]}
+
+ media = rich_message_media(message)
+ assert media == [RichMedia(file=large, type="image", height=200, width=300)]
+
+
+def test_animation_image_mime_maps_to_image_type() -> None:
+ """An animation with an ``image/*`` mime is classified as ``image``."""
+ gif: TelegramRichBlockAnimation = {
+ "type": "animation",
+ "animation": {
+ "file_id": "gif",
+ "file_unique_id": "g",
+ "duration": 1,
+ "height": 5,
+ "width": 6,
+ "mime_type": "image/gif",
+ "file_name": "a.gif",
+ },
+ }
+ mp4: TelegramRichBlockAnimation = {
+ "type": "animation",
+ "animation": {
+ "file_id": "mp4",
+ "file_unique_id": "m",
+ "duration": 1,
+ "height": 5,
+ "width": 6,
+ "mime_type": "video/mp4",
+ },
+ }
+ message: TelegramRichMessage = {"blocks": [gif, mp4]}
+
+ media = rich_message_media(message)
+ assert [m.type for m in media] == ["image", "video"]
+
+
+# ---------------------------------------------------------------------------
+# Adversarial: escape char-class sweep
+# ---------------------------------------------------------------------------
+
+# The four ASCII punctuation ranges of /[!-/:-@[-`{-~]/ enumerated explicitly.
+PUNCTUATION_IN_CLASS = (
+ "".join(chr(c) for c in range(ord("!"), ord("/") + 1))
+ + "".join(chr(c) for c in range(ord(":"), ord("@") + 1))
+ + "".join(chr(c) for c in range(ord("["), ord("`") + 1))
+ + "".join(chr(c) for c in range(ord("{"), ord("~") + 1))
+)
+
+
+def test_escape_sweep_every_in_class_char_is_backslash_escaped() -> None:
+ """Every punctuation char in the class is escaped to ``\\``."""
+ for ch in PUNCTUATION_IN_CLASS:
+ assert escape_text(ch) == f"\\{ch}", f"char {ch!r} should be escaped"
+
+
+def test_escape_sweep_out_of_class_chars_are_untouched() -> None:
+ """Letters, digits, whitespace, and space are left untouched."""
+ # Build a set of chars deliberately OUTSIDE the punctuation class.
+ out_of_class = (
+ "abcXYZ0123456789"
+ + " \t\n\r"
+ + "".join(chr(c) for c in range(ord("0"), ord("9") + 1))
+ + "éñ漢字😀" # non-ASCII: Python Unicode regex must NOT escape these
+ )
+ for ch in out_of_class:
+ assert escape_text(ch) == ch, f"char {ch!r} should NOT be escaped"
+
+
+def test_escape_does_not_touch_the_space_between_punctuation_ranges() -> None:
+ """The gaps between ranges (e.g. digits 0-9, uppercase A-Z) stay literal.
+
+ The char class has holes: ``0``-``9`` sit between ``/`` and ``:``; ``A``-``Z``
+ sit between ``@`` and ``[``; ``a``-``z`` sit between ``\\`` and ``{``. A
+ wrong range bound would silently escape these.
+ """
+ for ch in "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz":
+ assert escape_text(ch) == ch
+
+
+# ---------------------------------------------------------------------------
+# Adversarial: all-backtick inline-code fence run sizing
+# ---------------------------------------------------------------------------
+
+
+def test_inline_code_fence_grows_with_longest_backtick_run() -> None:
+ """The fence marker is ``max(1, longest_run + 1)`` backticks long."""
+ # No backticks -> minimal 1-backtick fence.
+ assert inline_code("x") == "`x`"
+ # One backtick -> fence of 2.
+ assert inline_code("a`b") == "``a`b``"
+ # A run of three backticks -> fence of 4.
+ assert inline_code("a```b") == "````a```b````"
+ # Two separate runs (1 and 3): the longest (3) wins -> fence of 4.
+ assert inline_code("a`b```c") == "````a`b```c````"
+
+
+def test_inline_code_pads_when_value_touches_a_backtick() -> None:
+ """Boundary backticks/spaces trigger one space of padding each side."""
+ # Leading backtick -> pad both sides.
+ assert inline_code("`x") == "`` `x ``"
+ # Pure spaces around content -> pad.
+ assert inline_code(" x ") == "` x `"
+ # Empty value renders to empty string (no fence).
+ assert inline_code("") == ""
+
+
+# ---------------------------------------------------------------------------
+# Adversarial: surrogate-pair / non-BMP truncation boundary
+# ---------------------------------------------------------------------------
+
+
+def test_truncation_never_splits_a_non_bmp_code_point() -> None:
+ """Truncating a long run of emoji cuts on code-point boundaries only.
+
+ Each emoji is a single Python code point (matching JS ``Array.from``),
+ so the truncated output must contain only whole emoji plus the ``...``
+ suffix -- never a lone surrogate or a partial code point.
+ """
+ emoji = "😀"
+ markdown = emoji * (TELEGRAM_RICH_MESSAGE_LIMIT + 50)
+
+ result = truncate_rich_markdown(markdown)
+
+ assert len(result) <= TELEGRAM_RICH_MESSAGE_LIMIT
+ assert result.endswith("...")
+ body = result[:-3]
+ # Body is non-empty and composed solely of whole emoji code points.
+ assert body
+ assert set(body) == {emoji}
+ # The body is an exact multiple of the (single) emoji code point: no
+ # partial code point or lone surrogate could survive a code-point slice.
+ assert body == emoji * len(body)
+ # No lone surrogate code units leaked in (Python strings never hold them
+ # from a slice, but assert the invariant explicitly).
+ assert all(not (0xD800 <= ord(c) <= 0xDFFF) for c in result)
+
+
+def test_truncation_passes_through_under_limit_text_unchanged() -> None:
+ """At or under the limit, the input is returned verbatim."""
+ text = "😀" * 5
+ assert truncate_rich_markdown(text) == text
+ assert truncate_rich_markdown("plain") == "plain"
+
+
+# ---------------------------------------------------------------------------
+# Fidelity: empty-LIST rich-text field renders (JS arrays are truthy)
+# ---------------------------------------------------------------------------
+
+
+def test_empty_list_credit_renders_like_a_present_credit() -> None:
+ """An empty-list ``credit`` ([]) renders, matching JS array truthiness.
+
+ Upstream gates the credit with ``value.credit ? ... : ""``; a JS array is
+ ALWAYS truthy, so ``credit: []`` produces a blank credit (a leading-newline
+ empty line in the quote), whereas absent / empty-string skip it. A bare
+ Python-truthiness gate (``if value.get("credit")``) would wrongly skip
+ ``[]`` -- this test fails under that regression.
+ """
+ # credit=[] -> truthy in JS -> credit segment "\n\n" + text([]) ("") renders.
+ # quote("Quote\n\n") -> "> Quote\n> \n> " -> trailing ws trimmed by .strip().
+ with_empty_list: TelegramRichBlockPullquote = {"type": "pullquote", "text": "Quote", "credit": []}
+ assert rich_message_to_markdown({"blocks": [with_empty_list]}) == "> Quote\n> \n>"
+
+ # Absent credit -> JS undefined is falsy -> skipped entirely.
+ absent: TelegramRichBlockPullquote = {"type": "pullquote", "text": "Quote"}
+ assert rich_message_to_markdown({"blocks": [absent]}) == "> Quote"
+
+ # Empty-string credit -> JS "" is falsy -> skipped (the ONLY falsy RichText).
+ empty_string: TelegramRichBlockPullquote = {"type": "pullquote", "text": "Quote", "credit": ""}
+ assert rich_message_to_markdown({"blocks": [empty_string]}) == "> Quote"
+
+ # A real credit span renders the same blank-line-separated shape as [].
+ with_author: TelegramRichBlockPullquote = {"type": "pullquote", "text": "Quote", "credit": "Author"}
+ assert rich_message_to_markdown({"blocks": [with_author]}) == "> Quote\n> \n> Author"
+
+
+# ---------------------------------------------------------------------------
+# Mutation coverage: fence default width, truncation reserve, exact boundary
+# ---------------------------------------------------------------------------
+
+
+def test_pre_block_without_internal_backticks_emits_a_three_backtick_fence() -> None:
+ """A code/pre block with NO internal backticks uses the default 3-fence.
+
+ ``_code_block`` sizes the fence as ``max(3, longest_run + 1)`` and falls
+ back to ``3`` when there are no backtick runs. This pins the no-run default:
+ the mutations ``else 3`` -> ``else 2`` and ``max(3, ...)`` -> ``max(2, ...)``
+ both shrink the fence to 2 backticks and fail this assertion.
+ """
+ fence = "`" * 3
+ pre: TelegramRichBlockPre = {"type": "pre", "language": "python", "text": "hello world"}
+ markdown = rich_message_to_markdown({"blocks": [pre]})
+
+ assert markdown == f"{fence}python\nhello world\n{fence}"
+ # Exactly three opening backticks -- never two.
+ assert len(markdown) - len(markdown.lstrip("`")) == 3
+
+
+def test_truncation_reserves_room_for_the_ellipsis() -> None:
+ """Truncation reserves 3 chars for ``...`` so the result stays at the limit.
+
+ With an unclosed bold marker straddling the boundary, the correct reserve
+ (``end = LIMIT - 3``) returns a result of length EXACTLY ``LIMIT`` on the
+ first iteration. The mutation ``end = LIMIT`` over-shoots, the shrink loop
+ diverges, and it returns a shorter (length ``LIMIT - 2``) result -- so an
+ exact-length assertion fails under the mutation.
+ """
+ # 'a' x (LIMIT-1) + '**' opens an unclosed bold span right at the boundary;
+ # the trailing run keeps the input over the limit.
+ over_limit = ("a" * (TELEGRAM_RICH_MESSAGE_LIMIT - 1)) + "**" + ("b" * 200)
+ result = truncate_rich_markdown(over_limit)
+
+ # The ellipsis fits within the limit -- length is exactly the limit here,
+ # never over it (mutation would yield LIMIT - 2, killing the equality).
+ assert len(result) == TELEGRAM_RICH_MESSAGE_LIMIT
+ assert result.endswith("...")
+
+
+def test_exact_limit_length_passes_through_unchanged() -> None:
+ """A string of length EXACTLY ``LIMIT`` is returned verbatim (no ``...``).
+
+ The early-return guard is ``len(characters) <= LIMIT``. At the boundary
+ ``len == LIMIT`` the input must pass through; the mutation ``<=`` -> ``<``
+ would instead truncate it (appending ``...``), failing this assertion.
+ """
+ exact = "a" * TELEGRAM_RICH_MESSAGE_LIMIT
+ result = truncate_rich_markdown(exact)
+
+ assert result == exact
+ assert not result.endswith("...")