From b4716e9195afad6b0fa2d117f971bdd6e8dad452 Mon Sep 17 00:00:00 2001 From: patrick-chinchill Date: Fri, 19 Jun 2026 19:30:58 -0700 Subject: [PATCH] =?UTF-8?q?feat(telegram):=20native=20rich=20streaming=20+?= =?UTF-8?q?=20send/edit=20rewrite=20(chat@4.31=204662309=20=E2=80=94=20TG4?= =?UTF-8?q?/4)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rewrite the Telegram outbound stream/send/edit path around the native rich-message Bot API with a rich -> MarkdownV2 -> plain fallback ladder, mirroring upstream vercel/chat#479. - Add resolve_rich_message, send_regular_message, can_fallback_from_rich_message, remember_rich_message_failure, with_telegram_rich_fallback, and the _rich_messages_available latch. - stream(): drafts via sendRichMessageDraft + final sendRichMessage; drop the empty opening draft; move the final flush AFTER renderer finish() so a held-back trailing table block is flushed post-finish. - post_message / edit_message: route {markdown}/{ast} through the rich endpoints and reuse the pre-rendered AST/text via TelegramParsedContent. - resolve_rich_message gate is a disjunction including 'raw' key PRESENCE (not truthiness); can_fallback (transient-tolerant) and remember_failure (only latches off on missing/unsupported/404) are two distinct predicates — a transient can't-parse never disables rich. Tests: rewrite the streaming suite for the rich ladder (non-streaming rich send, rich draft streaming with the dropped-opening-draft call count, flush-after-finish via a held-back table, rich markdown preserved, 404/ResourceNotFound permanent flag flip, transient can't-parse per- message fallback) plus direct unit tests for the two predicates and the resolve gate. Fold in the TG3 inbound entities nullish-ladder test (empty entities: [] does not fall through to caption_entities). --- src/chat_sdk/adapters/telegram/adapter.py | 514 ++++++++++++--- tests/test_telegram_api.py | 41 +- tests/test_telegram_streaming.py | 722 +++++++++++++++++----- tests/test_telegram_webhook.py | 27 + 4 files changed, 1082 insertions(+), 222 deletions(-) diff --git a/src/chat_sdk/adapters/telegram/adapter.py b/src/chat_sdk/adapters/telegram/adapter.py index ad77040..3d57edb 100644 --- a/src/chat_sdk/adapters/telegram/adapter.py +++ b/src/chat_sdk/adapters/telegram/adapter.py @@ -33,6 +33,7 @@ rich_message_media, rich_message_to_markdown, rich_message_to_text, + truncate_rich_markdown, ) from chat_sdk.adapters.telegram.types import ( TelegramAdapterConfig, @@ -69,7 +70,7 @@ ResourceNotFoundError, ValidationError, ) -from chat_sdk.shared.markdown_parser import ast_to_plain_text, parse_markdown +from chat_sdk.shared.markdown_parser import ast_to_plain_text, parse_markdown, stringify_markdown from chat_sdk.shared.streaming_markdown import StreamingMarkdownRenderer from chat_sdk.types import ( ActionEvent, @@ -85,7 +86,6 @@ LockScope, Message, MessageMetadata, - PostableMarkdown, RawMessage, ReactionEvent, SlashCommandEvent, @@ -175,6 +175,24 @@ class TelegramParsedContent: text: str +@dataclass +class RichMessageResolution: + """Resolved native rich-message payload for an outgoing message. + + Produced by :meth:`TelegramAdapter.resolve_rich_message` when a markdown + or AST postable is eligible for the ``sendRichMessage`` / + ``sendRichMessageDraft`` Bot API endpoints. ``markdown`` is the + rich-limit-truncated, emoji-resolved body sent verbatim to Telegram; + ``formatted`` / ``text`` are the locally pre-rendered AST and plain text + reused by :meth:`parse_telegram_message` (so the response isn't + re-parsed). Port of upstream ``resolveRichMessage``'s return shape. + """ + + formatted: FormattedContent + markdown: str + text: str + + TelegramRuntimeMode = str # "webhook" | "polling" _T = TypeVar("_T") @@ -673,6 +691,15 @@ def __init__(self, config: TelegramAdapterConfig | None = None) -> None: # at 2_147_483_647 to stay within Telegram's signed-int32 range. self._next_draft_id: int = max(1, int(time.time() * 1000) % 2_147_483_647) + # Native rich-message availability (vercel/chat#479). Starts True and + # latches to False the first time Telegram reports the rich-message + # endpoints are missing/unsupported (a 404 ``method not found`` or a + # ``rich message ... unsupported`` validation error), so subsequent + # sends skip the rich attempt entirely and go straight to MarkdownV2. + # Transient ``can't parse`` failures fall back per-message without + # flipping this flag. + self._rich_messages_available: bool = True + # Shared aiohttp session for connection pooling self._http_session: Any | None = None @@ -1397,6 +1424,7 @@ async def post_message( "Telegram adapter does not support mixing file uploads and attachments in one message", ) + rich = self.resolve_rich_message(message, card, len(files), len(attachments)) raw_message: TelegramMessage if len(files) == 1: @@ -1420,26 +1448,38 @@ async def post_message( if not text.strip(): raise ValidationError("telegram", "Message text cannot be empty") - async def _send_message(resolved_parse_mode: str | None, resolved_text: str) -> TelegramMessage: - return await self.telegram_fetch( - "sendMessage", - { - "chat_id": parsed_thread.chat_id, - "message_thread_id": parsed_thread.message_thread_id, - "text": resolved_text, - "reply_markup": reply_markup, - "parse_mode": resolved_parse_mode, - }, + async def _send_regular() -> TelegramMessage: + return await self.send_regular_message( + parsed_thread, + text, + plain_text, + parse_mode, + reply_markup, + thread_id, ) - raw_message = await self.with_telegram_markdown_fallback( - parse_mode, - _send_message, - initial_text=text, - fallback_text=plain_text, - method="sendMessage", - thread_id=thread_id, - ) + if rich is not None: + rich_payload = rich + + async def _send_rich() -> TelegramMessage: + return await self.telegram_fetch( + "sendRichMessage", + { + "chat_id": parsed_thread.chat_id, + "message_thread_id": parsed_thread.message_thread_id, + "rich_message": {"markdown": rich_payload.markdown}, + "reply_markup": reply_markup, + }, + ) + + raw_message = await self.with_telegram_rich_fallback( + _send_rich, + _send_regular, + method="sendRichMessage", + thread_id=thread_id, + ) + else: + raw_message = await _send_regular() resulting_thread_id = self.encode_thread_id( TelegramThreadId( @@ -1452,7 +1492,11 @@ async def _send_message(resolved_parse_mode: str | None, resolved_text: str) -> ) ) - parsed_message = self.parse_telegram_message(raw_message, resulting_thread_id) + # Reuse the locally pre-rendered AST/text when the rich path produced + # the message, so Telegram's echo isn't re-parsed (upstream passes + # ``{ formatted, text }`` into ``parseTelegramMessage``). + parsed_content = TelegramParsedContent(formatted=rich.formatted, text=rich.text) if rich is not None else None + parsed_message = self.parse_telegram_message(raw_message, resulting_thread_id, parsed_content) self.cache_message(parsed_message) return RawMessage( @@ -1500,6 +1544,7 @@ async def edit_message( ), parse_mode, ) + rich = self.resolve_rich_message(message, card, 0, 0) if not text.strip(): raise ValidationError("telegram", "Message text cannot be empty") @@ -1519,15 +1564,40 @@ async def _edit_message_text(resolved_parse_mode: str | None, resolved_text: str }, ) - result = await self.with_telegram_markdown_fallback( - parse_mode, - _edit_message_text, - initial_text=text, - fallback_text=plain_text, - message_id=message_id, - method="editMessageText", - thread_id=thread_id, - ) + async def _edit_regular() -> Any: + return await self.with_telegram_markdown_fallback( + parse_mode, + _edit_message_text, + initial_text=text, + fallback_text=plain_text, + message_id=message_id, + method="editMessageText", + thread_id=thread_id, + ) + + if rich is not None: + rich_payload = rich + + async def _edit_rich() -> Any: + return await self.telegram_fetch( + "editMessageText", + { + "chat_id": chat_id, + "message_id": telegram_message_id, + "rich_message": {"markdown": rich_payload.markdown}, + "reply_markup": reply_markup or empty_telegram_inline_keyboard(), + }, + ) + + result = await self.with_telegram_rich_fallback( + _edit_rich, + _edit_regular, + message_id=message_id, + method="editMessageText", + thread_id=thread_id, + ) + else: + result = await _edit_regular() # Telegram returns ``true`` when editing inline messages if result is True: @@ -1538,11 +1608,13 @@ async def _edit_message_text(resolved_parse_mode: str | None, resolved_text: str "editMessage", ) + # ``rich?.text ?? text`` / ``rich?.formatted ?? toAst(text)``: when + # the rich path supplied a pre-rendered AST/text, reuse it. updated = Message( id=existing.id, thread_id=existing.thread_id, - text=text, - formatted=self._format_converter.to_ast(text), + text=rich.text if rich is not None else text, + formatted=rich.formatted if rich is not None else self._format_converter.to_ast(text), raw=existing.raw, author=existing.author, metadata=MessageMetadata( @@ -1573,7 +1645,8 @@ async def _edit_message_text(resolved_parse_mode: str | None, resolved_text: str ) ) - parsed_message = self.parse_telegram_message(result, resulting_thread_id) + parsed_content = TelegramParsedContent(formatted=rich.formatted, text=rich.text) if rich is not None else None + parsed_message = self.parse_telegram_message(result, resulting_thread_id, parsed_content) self.cache_message(parsed_message) return RawMessage( @@ -1661,25 +1734,30 @@ async def stream( ) -> RawMessage | None: """Stream a message to a Telegram private chat via draft updates. - Port of upstream ``TelegramAdapter.stream`` (vercel/chat#340). - Private chats (DMs) get native draft streaming through the - ``sendMessageDraft`` Bot API method: the draft bubble updates in - place as chunks arrive, throttled to ``options.update_interval_ms`` - (default :data:`TELEGRAM_DEFAULT_STREAM_UPDATE_INTERVAL_MS`), and a - regular ``sendMessage`` persists the final text when the stream - ends. Returns ``None`` for non-DM threads — before consuming any - chunks — so the SDK's built-in post+edit fallback handles groups, - supergroups, and channels. - - Draft updates render the in-flight markdown through - :class:`StreamingMarkdownRenderer` and - :func:`truncate_for_telegram`, so transiently unpaired entity - markers are trimmed to a MarkdownV2-safe boundary instead of - tripping Telegram's ``can't parse entities`` 400. If Telegram still - rejects the markdown, the stream downgrades to plain-text drafts - (and a plain-text final send); any other draft failure disables - draft updates entirely but never fails the stream — the final - message is always attempted. + Port of upstream ``TelegramAdapter.stream`` (vercel/chat#479, on top + of the #340 draft-streaming foundation). Private chats (DMs) get + native draft streaming: the draft bubble updates in place as chunks + arrive, throttled to ``options.update_interval_ms`` (default + :data:`TELEGRAM_DEFAULT_STREAM_UPDATE_INTERVAL_MS`), and a final send + persists the message when the stream ends. Returns ``None`` for + non-DM threads — before consuming any chunks — so the SDK's built-in + post+edit fallback handles groups, supergroups, and channels. + + The outbound ladder is **rich → MarkdownV2 → plain**: + + * Drafts first go through ``sendRichMessageDraft`` (native rich + markdown), and the final message through ``sendRichMessage``. + * If the rich endpoint reports it can't parse / is missing / + unsupported, the stream demotes to the MarkdownV2 + ``sendMessageDraft`` + ``sendMessage`` path (the #340 behaviour, + now the second tier). + * If Telegram then rejects the MarkdownV2 entities, the stream + downgrades again to plain-text drafts and a plain final send. + + Any other (non-fallback) draft failure disables draft updates + entirely but never fails the stream — the final message is always + attempted. There is no longer an empty opening draft; the first + bubble update carries real content. """ if not self.is_dm(thread_id): return None @@ -1699,6 +1777,7 @@ async def stream( last_flush_at = 0.0 draft_streaming_enabled = True stream_uses_markdown = True + stream_uses_rich = self._rich_messages_available def _now_ms() -> float: return time.monotonic() * 1000.0 @@ -1731,13 +1810,54 @@ def _draft_payload(text: str, *, markdown: bool) -> dict[str, Any]: return payload async def send_draft(text: str, use_markdown: bool) -> None: - nonlocal draft_streaming_enabled, last_draft_text, last_flush_at, stream_uses_markdown + nonlocal draft_streaming_enabled, last_draft_text, last_flush_at + nonlocal stream_uses_markdown, stream_uses_rich if not draft_streaming_enabled or text == last_draft_text: return + draft_text = text + if stream_uses_rich: + try: + await self.telegram_fetch( + "sendRichMessageDraft", + { + "chat_id": parsed_thread.chat_id, + "message_thread_id": parsed_thread.message_thread_id, + "draft_id": draft_id, + "rich_message": {"markdown": text}, + }, + ) + last_draft_text = text + last_flush_at = _now_ms() + return + except Exception as error: + if not self.can_fallback_from_rich_message(error, "sendRichMessageDraft"): + # Not a rich-availability problem (e.g. a network + # blip): stop draft updates but keep the stream alive + # for the final send. Rich stays enabled. + draft_streaming_enabled = False + self._logger.warn( + "Telegram rich draft streaming update failed", + {"error": str(error), "thread_id": thread_id}, + ) + return + + # Rich is unavailable / can't-parse: remember (may latch + # rich off permanently), demote this stream to the + # MarkdownV2 draft path, and re-render the body. + self.remember_rich_message_failure(error, "sendRichMessageDraft") + self._logger.warn( + "Telegram rich draft failed; retrying with a regular draft", + {"error": str(error), "thread_id": thread_id}, + ) + stream_uses_rich = False + draft_text = ( + render_markdown_text(renderer.render()) if use_markdown else render_plain_text(accumulated) + ) + try: - await self.telegram_fetch("sendMessageDraft", _draft_payload(text, markdown=use_markdown)) - last_draft_text = text + await self.telegram_fetch("sendMessageDraft", _draft_payload(draft_text, markdown=use_markdown)) + last_draft_text = draft_text last_flush_at = _now_ms() except Exception as error: if use_markdown and self.is_telegram_markdown_parse_error(error): @@ -1767,15 +1887,14 @@ async def send_draft(text: str, use_markdown: bool) -> None: async def flush_draft() -> None: if not draft_streaming_enabled: return - draft_text = ( - render_markdown_text(renderer.render()) if stream_uses_markdown else render_plain_text(accumulated) - ) + if stream_uses_rich: + draft_text = truncate_rich_markdown(renderer.render()) + elif stream_uses_markdown: + draft_text = render_markdown_text(renderer.render()) + else: + draft_text = render_plain_text(accumulated) await send_draft(draft_text, stream_uses_markdown) - # Open the draft bubble immediately so the user sees activity - # before the first chunk lands. - await send_draft("", False) - async for chunk in text_stream: text: str | None = None if isinstance(chunk, str): @@ -1798,23 +1917,72 @@ async def flush_draft() -> None: if _now_ms() - last_flush_at >= update_interval_ms: await flush_draft() - await flush_draft() - if not accumulated.strip(): raise ValidationError("telegram", "Telegram streaming requires text content") - # Persist the final message through the regular post path (which - # carries its own markdown-parse retry). The returned RawMessage - # leaves ``text`` unset so ``Thread.stream`` records its local - # accumulator — matching upstream, which records - # ``{ markdown: accumulated }``. - final_postable: AdapterPostableMessage = ( - PostableMarkdown(markdown=accumulated) - if stream_uses_markdown - else self.resolve_telegram_fallback_text(accumulated, _markdown_to_plain_text(accumulated)) - ) + # Finalise the renderer FIRST, then flush — a trailing table-like + # block only resolves to its final markdown once the renderer is + # finished, so the last draft must reflect the post-finish render. + final_markdown = renderer.finish() + await flush_draft() - return await self.post_message(thread_id, final_postable) + if stream_uses_rich: + markdown = truncate_rich_markdown(final_markdown) + try: + raw = await self.telegram_fetch( + "sendRichMessage", + { + "chat_id": parsed_thread.chat_id, + "message_thread_id": parsed_thread.message_thread_id, + "rich_message": {"markdown": markdown}, + }, + ) + resulting_thread_id = self.encode_thread_id( + TelegramThreadId( + chat_id=str(raw["chat"]["id"]), + message_thread_id=( + raw.get("message_thread_id") + if raw.get("message_thread_id") is not None + else parsed_thread.message_thread_id + ), + ) + ) + formatted = self._format_converter.to_ast(accumulated) + message = self.parse_telegram_message( + raw, + resulting_thread_id, + TelegramParsedContent(formatted=formatted, text=ast_to_plain_text(formatted)), + ) + self.cache_message(message) + return RawMessage(id=message.id, thread_id=message.thread_id, raw=raw) + except Exception as error: + if not self.can_fallback_from_rich_message(error, "sendRichMessage"): + raise + self.remember_rich_message_failure(error, "sendRichMessage") + + regular_text = render_markdown_text(final_markdown) if stream_uses_markdown else render_plain_text(accumulated) + plain_text = render_plain_text(accumulated) + raw = await self.send_regular_message( + parsed_thread, + regular_text, + plain_text, + TELEGRAM_MARKDOWN_PARSE_MODE if stream_uses_markdown else None, + None, + thread_id, + ) + resulting_thread_id = self.encode_thread_id( + TelegramThreadId( + chat_id=str(raw["chat"]["id"]), + message_thread_id=( + raw.get("message_thread_id") + if raw.get("message_thread_id") is not None + else parsed_thread.message_thread_id + ), + ) + ) + message = self.parse_telegram_message(raw, resulting_thread_id) + self.cache_message(message) + return RawMessage(id=message.id, thread_id=message.thread_id, raw=raw) # -- Fetching messages --------------------------------------------------- @@ -2769,6 +2937,202 @@ def resolve_telegram_fallback_text(self, original_text: str, fallback_text: str) """ return fallback_text if fallback_text.strip() else original_text + # -- Native rich messages (vercel/chat#479) ------------------------------ + + def resolve_rich_message( + self, + message: AdapterPostableMessage, + card: Any, + file_count: int, + attachment_count: int, + ) -> RichMessageResolution | None: + """Resolve a postable into a native rich-message payload, or ``None``. + + Port of upstream ``resolveRichMessage``. Returns ``None`` — disabling + the rich path so the caller sends a regular MarkdownV2/plain message — + when ANY of the following hold (a disjunction, ported exactly): + + * rich messages are unavailable (the cached endpoint-missing latch); + * the message carries a card; + * a file or attachment is being uploaded; + * the message is a plain string; + * the message carries a ``raw`` field (**key presence**, not + truthiness — an empty ``raw`` still opts out of rich rendering). + + Only ``{"markdown": …}`` and ``{"ast": …}`` shapes resolve to a rich + payload; everything else falls through to ``None``. + """ + # ``"raw" in message`` — key PRESENCE, not truthiness: dict membership + # for mapping payloads, attribute presence for dataclass payloads. + has_raw = (isinstance(message, dict) and "raw" in message) or ( + not isinstance(message, str) and hasattr(message, "raw") + ) + if ( + not self._rich_messages_available + or card + or file_count > 0 + or attachment_count > 0 + or isinstance(message, str) + or has_raw + ): + return None + + markdown_value = self._postable_field(message, "markdown") + if markdown_value is not None: + formatted = self._format_converter.to_ast(markdown_value) + return RichMessageResolution( + formatted=formatted, + markdown=truncate_rich_markdown(convert_emoji_placeholders(markdown_value, "gchat")), + text=ast_to_plain_text(formatted), + ) + + ast_value = self._postable_field(message, "ast") + if ast_value is not None: + return RichMessageResolution( + formatted=ast_value, + markdown=truncate_rich_markdown(convert_emoji_placeholders(stringify_markdown(ast_value), "gchat")), + text=ast_to_plain_text(ast_value), + ) + + return None + + @staticmethod + def _postable_field(message: AdapterPostableMessage, field: str) -> Any: + """Return ``message[field]`` for dict / dataclass postables, else ``None``. + + Mirrors upstream's ``"field" in message ? message.field : …`` key- + presence narrowing across both the dict-shaped and dataclass-shaped + postable representations. + """ + if isinstance(message, dict): + return message.get(field) + if hasattr(message, field): + return getattr(message, field) + return None + + async def send_regular_message( + self, + thread: TelegramThreadId, + text: str, + plain_text: str, + parse_mode: str | None, + reply_markup: TelegramInlineKeyboardMarkup | None, + thread_id: str, + ) -> TelegramMessage: + """Send a non-rich message via ``sendMessage`` with markdown fallback. + + Port of upstream ``sendRegularMessage``: the MarkdownV2-or-plain send + path used both directly (plain strings, ``raw`` payloads, the + rich-unavailable branch) and as the fallback operation passed to + :meth:`with_telegram_rich_fallback`. + """ + + async def _send_message(resolved_parse_mode: str | None, resolved_text: str) -> TelegramMessage: + return await self.telegram_fetch( + "sendMessage", + { + "chat_id": thread.chat_id, + "message_thread_id": thread.message_thread_id, + "text": resolved_text, + "reply_markup": reply_markup, + "parse_mode": resolved_parse_mode, + }, + ) + + return await self.with_telegram_markdown_fallback( + parse_mode, + _send_message, + initial_text=text, + fallback_text=plain_text, + method="sendMessage", + thread_id=thread_id, + ) + + def can_fallback_from_rich_message(self, error: object, method: str) -> bool: + """Whether a rich-message failure may retry as a regular message. + + Port of upstream ``canFallbackFromRichMessage``. Returns ``True`` for + the *transient and the permanent* rich failures alike — i.e. the + message should be retried via the regular MarkdownV2/plain path: + + * a ``ResourceNotFoundError`` raised by a ``sendRichMessage*`` call + (the 404 ``method not found``); OR + * a :class:`ValidationError` whose message contains ``can't parse``, + or ``method`` + ``not found``, or ``rich message`` + ``unsupported``. + + Distinct from :meth:`remember_rich_message_failure`, which decides the + *narrower* set that additionally latches rich off permanently. A + transient ``can't parse`` qualifies here but NOT there. + """ + message = str(error).lower() if isinstance(error, ValidationError) else "" + missing_method = "method" in message and "not found" in message + unsupported_rich = "rich message" in message and "unsupported" in message + + return bool( + (method.startswith("sendRichMessage") and isinstance(error, ResourceNotFoundError)) + or (isinstance(error, ValidationError) and ("can't parse" in message or missing_method or unsupported_rich)) + ) + + def remember_rich_message_failure(self, error: object, method: str) -> None: + """Latch rich messages off when a failure proves the endpoint is gone. + + Port of upstream ``rememberRichMessageFailure``. Flips + ``_rich_messages_available`` to ``False`` — permanently, for the life + of the adapter instance — ONLY for the endpoint-missing / unsupported + class of failures: + + * a ``ResourceNotFoundError`` from a ``sendRichMessage*`` call; OR + * a :class:`ValidationError` containing ``method`` + ``not found`` or + ``rich message`` + ``unsupported``. + + A transient ``can't parse`` / generic validation failure does NOT + match here, so rich remains enabled for subsequent messages — the key + distinction from :meth:`can_fallback_from_rich_message`. + """ + message = str(error).lower() if isinstance(error, ValidationError) else "" + missing_method = "method" in message and "not found" in message + unsupported_rich = "rich message" in message and "unsupported" in message + + if (method.startswith("sendRichMessage") and isinstance(error, ResourceNotFoundError)) or ( + isinstance(error, ValidationError) and (missing_method or unsupported_rich) + ): + self._rich_messages_available = False + + async def with_telegram_rich_fallback( + self, + operation: Callable[[], Awaitable[_T]], + fallback: Callable[[], Awaitable[_T]], + *, + method: str, + message_id: str | None = None, + thread_id: str | None = None, + ) -> _T: + """Run *operation* (a rich send), retrying via *fallback* on rich errors. + + Port of upstream ``withTelegramRichFallback``. On failure: if + :meth:`can_fallback_from_rich_message` rejects the error it re-raises + unchanged (non-rich failures must surface); otherwise it records the + failure via :meth:`remember_rich_message_failure` (which may latch + rich off) and returns *fallback*'s result — the regular send. + """ + try: + return await operation() + except Exception as error: + if not self.can_fallback_from_rich_message(error, method): + raise + + self.remember_rich_message_failure(error, method) + log_context: dict[str, Any] = {"error": str(error), "method": method} + if message_id is not None: + log_context["message_id"] = message_id + if thread_id is not None: + log_context["thread_id"] = thread_id + self._logger.warn( + "Telegram rich message failed; retrying with a regular message", + log_context, + ) + return await fallback() + # -- Truncation ---------------------------------------------------------- def truncate_message(self, text: str, parse_mode: str | None = None) -> str: diff --git a/tests/test_telegram_api.py b/tests/test_telegram_api.py index e5d408a..87eefa4 100644 --- a/tests/test_telegram_api.py +++ b/tests/test_telegram_api.py @@ -85,7 +85,7 @@ def _init_adapter(adapter: TelegramAdapter) -> MagicMock: class TestPostMessageSendsText: - """post_message with plain markdown sends sendMessage.""" + """post_message with markdown routes through the native rich endpoint.""" @pytest.mark.asyncio async def test_post_message_sends_text(self): @@ -100,9 +100,11 @@ async def test_post_message_sends_text(self): method = call_args[0][0] payload = call_args[0][1] - assert method == "sendMessage" + # A ``{markdown}`` payload now goes through ``sendRichMessage`` with the + # markdown carried verbatim in ``rich_message`` (vercel/chat#479). + assert method == "sendRichMessage" assert payload["chat_id"] == CHAT_ID - assert payload["text"] == "Hi" + assert payload["rich_message"]["markdown"] == "Hi" assert result.id is not None @@ -151,17 +153,38 @@ async def test_post_message_with_card(self): class TestPostMessageParseMode: - """post_message with markdown content sends MarkdownV2 parse_mode.""" + """post_message markdown: rich primary, MarkdownV2 on the regular fallback.""" @pytest.mark.asyncio async def test_post_message_parse_mode_markdown(self): + # Rich is the primary path: the markdown is sent through + # ``sendRichMessage`` with no Bot-API ``parse_mode`` (the rich endpoint + # parses markdown natively). adapter = _make_adapter() _init_adapter(adapter) adapter.telegram_fetch = AsyncMock(return_value=_make_telegram_message(text="**bold**")) await adapter.post_message(THREAD_ID, {"markdown": "**bold**"}) + method = adapter.telegram_fetch.call_args[0][0] payload = adapter.telegram_fetch.call_args[0][1] + assert method == "sendRichMessage" + assert payload.get("parse_mode") is None + + @pytest.mark.asyncio + async def test_post_message_parse_mode_markdown_on_regular_path(self): + # When rich is unavailable the markdown falls to the regular + # ``sendMessage`` path, which DOES carry ``parse_mode=MarkdownV2``. + adapter = _make_adapter() + _init_adapter(adapter) + adapter._rich_messages_available = False + adapter.telegram_fetch = AsyncMock(return_value=_make_telegram_message(text="**bold**")) + + await adapter.post_message(THREAD_ID, {"markdown": "**bold**"}) + + method = adapter.telegram_fetch.call_args[0][0] + payload = adapter.telegram_fetch.call_args[0][1] + assert method == "sendMessage" assert payload.get("parse_mode") == "MarkdownV2" @@ -448,10 +471,13 @@ async def test_edit_message(self): method = adapter.telegram_fetch.call_args[0][0] payload = adapter.telegram_fetch.call_args[0][1] + # A ``{markdown}`` edit routes through the native rich edit + # (``editMessageText`` with ``rich_message``), not a MarkdownV2 text + # edit (vercel/chat#479). assert method == "editMessageText" assert payload["chat_id"] == CHAT_ID assert payload["message_id"] == MESSAGE_ID_INT - assert payload["text"] == "Updated" + assert payload["rich_message"]["markdown"] == "Updated" # ============================================================================= @@ -1857,10 +1883,11 @@ async def test_delegates_to_post_message(self): result = await adapter.post_channel_message(THREAD_ID, {"markdown": "chan"}) assert result.id == COMPOSITE_MESSAGE_ID assert result.thread_id == THREAD_ID - # Verify it delegated to telegram_fetch (i.e. post_message internally) + # Verify it delegated to telegram_fetch (i.e. post_message internally); + # a ``{markdown}`` payload routes through the rich endpoint. adapter.telegram_fetch.assert_called_once() call_args = adapter.telegram_fetch.call_args - assert call_args[0][0] == "sendMessage" + assert call_args[0][0] == "sendRichMessage" # ============================================================================= diff --git a/tests/test_telegram_streaming.py b/tests/test_telegram_streaming.py index 6222dfc..55d8851 100644 --- a/tests/test_telegram_streaming.py +++ b/tests/test_telegram_streaming.py @@ -1,19 +1,29 @@ -"""Tests for Telegram native DM draft streaming and markdown-parse fallback. +"""Tests for Telegram native rich streaming + send/edit rewrite. -Faithful translation of the ``index.test.ts`` additions from upstream -vercel/chat#340 (commit 5461ea9, "feat(telegram): add native DM draft -streaming with segmented stream results"): ``stream()`` via -``sendMessageDraft`` for private chats, ``None``-delegation for non-DM -threads, and the retry-without-``parse_mode`` path shared by -``post_message`` / ``edit_message`` when Telegram rejects MarkdownV2 -entity parsing. +Faithful translation of the ``index.test.ts`` rich-message additions from +upstream vercel/chat#479 (commit 4662309, "feat(telegram): native rich +streaming + send/edit"). The outbound path is a **rich → MarkdownV2 → +plain** fallback ladder: + +* ``stream()`` updates a draft bubble through ``sendRichMessageDraft`` and + persists the final message via ``sendRichMessage``; on a rich-endpoint + failure it demotes to the #340 ``sendMessageDraft`` / ``sendMessage`` + MarkdownV2 path (now the second tier), then to plain text. +* ``post_message`` / ``edit_message`` route ``{markdown}`` / ``{ast}`` + payloads through ``sendRichMessage`` / rich ``editMessageText``, with the + same fallback ladder. + +There is **no empty opening draft** anymore (it was removed in the rewrite), +so the streaming call counts here are one lower than the #340 suite. + +A 404 ``method not found`` / ``ResourceNotFound`` on a rich call latches +``_rich_messages_available`` to ``False`` permanently (future messages skip +rich). A transient ``can't parse`` / validation failure falls back for the +current message only and leaves the flag set. Upstream mocks ``fetch`` at the HTTP layer; these tests follow the -established Python convention (see ``test_telegram_api.py``) of mocking -``telegram_fetch`` with an ordered script — each entry is either a value -to return or an exception to raise, mirroring upstream's -``mockResolvedValueOnce`` / ``telegramError`` chains after Bot API error -mapping. +established Python convention of mocking ``telegram_fetch`` with an ordered +script — each entry is either a value to return or an exception to raise. """ from __future__ import annotations @@ -26,7 +36,11 @@ TelegramAdapter, ) from chat_sdk.adapters.telegram.types import TelegramAdapterConfig -from chat_sdk.shared.errors import AdapterRateLimitError, ValidationError +from chat_sdk.shared.errors import ( + AdapterRateLimitError, + ResourceNotFoundError, + ValidationError, +) from chat_sdk.shared.mock_adapter import MockLogger # ============================================================================= @@ -49,19 +63,29 @@ def _make_adapter(**overrides: Any) -> TelegramAdapter: def _sample_message( - text: str = "hello world", + text: str | None = "hello world", message_id: int = 11, chat_id: int = 123, chat_type: str = "private", + rich_message: dict[str, Any] | None = None, ) -> dict[str, Any]: """Return a minimal Telegram message dict (mirrors upstream sampleMessage).""" - return { + msg: dict[str, Any] = { "message_id": message_id, "chat": {"id": chat_id, "type": chat_type}, "from": {"id": 999, "is_bot": True, "first_name": "Bot", "username": "mybot"}, "date": 1700000000, - "text": text, } + if text is not None: + msg["text"] = text + if rich_message is not None: + msg["rich_message"] = rich_message + return msg + + +def _rich_message(text: str) -> dict[str, Any]: + """A rich_message payload whose single paragraph carries *text*.""" + return {"blocks": [{"type": "paragraph", "text": text}]} def _parse_entities_error() -> ValidationError: @@ -72,6 +96,26 @@ def _parse_entities_error() -> ValidationError: ) +def _method_not_found_error() -> ResourceNotFoundError: + """The error a 404 ``method not found`` maps to (rich endpoint missing). + + ``throw_telegram_api_error`` raises ``ResourceNotFoundError(adapter, + method)`` for an ``error_code`` of 404, mirroring upstream's + ``ResourceNotFoundError`` for the absent ``sendRichMessage*`` endpoints. + """ + return ResourceNotFoundError("telegram", "sendRichMessage") + + +def _rich_unsupported_error() -> ValidationError: + """A ``rich message ... unsupported`` 400 (latches rich off).""" + return ValidationError("telegram", "Bad Request: rich message is unsupported") + + +def _rich_cant_parse_error() -> ValidationError: + """A transient ``can't parse rich message`` 400 (does NOT latch rich off).""" + return ValidationError("telegram", "Bad Request: can't parse rich message") + + def _script_fetch(adapter: TelegramAdapter, script: list[Any]) -> list[tuple[str, Any]]: """Replace ``telegram_fetch`` with an ordered script of results. @@ -94,6 +138,30 @@ async def fetch(method: str, payload: Any = None, **_kwargs: Any) -> Any: return calls +def _method_fetch(adapter: TelegramAdapter, handlers: dict[str, Any]) -> list[tuple[str, Any]]: + """Replace ``telegram_fetch`` with a per-method handler map. + + Each handler is either a static value (returned), an ``Exception`` + (raised), or a zero-arg callable producing the next value. Mirrors + upstream's ``mockFetch.mockImplementation`` switch on the Bot API method. + """ + calls: list[tuple[str, Any]] = [] + + async def fetch(method: str, payload: Any = None, **_kwargs: Any) -> Any: + calls.append((method, payload)) + if method not in handlers: + raise AssertionError(f"Unexpected Telegram method in test: {method}") + handler = handlers[method] + if callable(handler): + handler = handler() + if isinstance(handler, Exception): + raise handler + return handler + + adapter.telegram_fetch = fetch # type: ignore[method-assign] + return calls + + async def _text_stream(chunks: list[str]): for chunk in chunks: yield chunk @@ -108,18 +176,93 @@ def _stream_options(update_interval_ms: int): # ============================================================================= -# Tests -- stream() draft streaming (vercel/chat#340) +# Tests -- non-streaming rich send (post_message) +# ============================================================================= + + +class TestTelegramRichSend: + """it() blocks for postMessage / editMessage rich routing.""" + + # it("uses rich messages for markdown messages") + @pytest.mark.asyncio + async def test_uses_rich_messages_for_markdown_messages(self): + adapter = _make_adapter() + calls = _script_fetch(adapter, [_sample_message()]) + + await adapter.post_message(DM_THREAD_ID, {"markdown": "**bold** and _italic_"}) + + assert calls[0][0] == "sendRichMessage" + assert calls[0][1]["chat_id"] == DM_CHAT_ID + assert calls[0][1]["rich_message"]["markdown"] == "**bold** and _italic_" + + # it("uses rich messages for AST messages") + @pytest.mark.asyncio + async def test_uses_rich_messages_for_ast_messages(self): + from chat_sdk.adapters.telegram.format_converter import TelegramFormatConverter + + adapter = _make_adapter() + calls = _script_fetch(adapter, [_sample_message()]) + + ast = TelegramFormatConverter().to_ast("**hello** world!") + await adapter.post_message(DM_THREAD_ID, {"ast": ast}) + + assert calls[0][0] == "sendRichMessage" + # stringifyMarkdown renders strong as ``**`` and appends a trailing + # newline — the rich markdown is sent verbatim. + assert calls[0][1]["rich_message"]["markdown"] == "**hello** world!\n" + + # it("omits parse_mode for plain string messages") -- plain strings skip + # rich entirely (the resolveRichMessage `typeof message === "string"` gate) + # and ship through sendMessage with no parse_mode. + @pytest.mark.asyncio + async def test_plain_string_messages_skip_rich_and_omit_parse_mode(self): + adapter = _make_adapter() + calls = _script_fetch(adapter, [_sample_message()]) + + await adapter.post_message(DM_THREAD_ID, "plain text message") + + assert calls[0][0] == "sendMessage" + assert calls[0][1].get("parse_mode") is None + + # it("omits parse_mode for raw messages") -- a ``raw`` payload opts out of + # rich via the `"raw" in message` PRESENCE gate, even non-empty. + @pytest.mark.asyncio + async def test_raw_messages_skip_rich_and_ship_verbatim(self): + adapter = _make_adapter() + calls = _script_fetch(adapter, [_sample_message()]) + + await adapter.post_message(DM_THREAD_ID, {"raw": "raw.unparsed!(text)"}) + + assert calls[0][0] == "sendMessage" + assert calls[0][1].get("parse_mode") is None + assert calls[0][1]["text"] == "raw.unparsed!(text)" + + +# ============================================================================= +# Tests -- rich draft streaming (vercel/chat#479) # ============================================================================= -class TestTelegramDraftStreaming: - """it() blocks for TelegramAdapter.stream draft updates.""" +class TestTelegramRichDraftStreaming: + """it() blocks for TelegramAdapter.stream rich draft updates.""" # it("streams draft updates for private chats and sends a final message") + # + # HAZARD 1 (dropped opening draft): the rewrite removed the empty + # ``send_draft("", False)`` that opened the bubble in #340. With two + # chunks at interval 0 the call sequence is now [draft, draft, final] = + # THREE calls (was four). This test pins the NEW count. @pytest.mark.asyncio - async def test_streams_draft_updates_for_private_chats_and_sends_a_final_message(self): + async def test_streams_rich_draft_updates_and_sends_final_rich_message(self): adapter = _make_adapter() - calls = _script_fetch(adapter, [True, True, True, _sample_message()]) + calls = _script_fetch( + adapter, + [ + True, # first sendRichMessageDraft + True, # second sendRichMessageDraft + _sample_message(text=None, rich_message=_rich_message("hello world")), + ], + ) result = await adapter.stream(DM_THREAD_ID, _text_stream(["hello", " world"]), _stream_options(0)) @@ -128,69 +271,89 @@ async def test_streams_draft_updates_for_private_chats_and_sends_a_final_message assert result.thread_id == DM_THREAD_ID methods = [method for method, _payload in calls] - assert methods == ["sendMessageDraft", "sendMessageDraft", "sendMessageDraft", "sendMessage"] + # No opening empty draft: two rich drafts then the final rich send. + assert methods == ["sendRichMessageDraft", "sendRichMessageDraft", "sendRichMessage"] - initial_draft = calls[0][1] - first_draft = calls[1][1] - second_draft = calls[2][1] - final_send = calls[3][1] + first_draft = calls[0][1] + second_draft = calls[1][1] + final_send = calls[2][1] - assert initial_draft["chat_id"] == DM_CHAT_ID - assert initial_draft["text"] == "" - assert "parse_mode" not in initial_draft assert first_draft["chat_id"] == DM_CHAT_ID - assert first_draft["draft_id"] == initial_draft["draft_id"] - assert first_draft["text"] == "hello" - assert first_draft["parse_mode"] == "MarkdownV2" + assert first_draft["rich_message"]["markdown"] == "hello" assert second_draft["draft_id"] == first_draft["draft_id"] - assert second_draft["text"] == "hello world" + assert second_draft["rich_message"]["markdown"] == "hello world" assert final_send["chat_id"] == DM_CHAT_ID - assert final_send["text"] == "hello world" - assert final_send["parse_mode"] == "MarkdownV2" + assert final_send["rich_message"]["markdown"] == "hello world" + + # it("flushes trailing table-like lines before completing a rich stream") + # + # HAZARD 2 (flush AFTER finish): the StreamingMarkdownRenderer holds back a + # complete-but-still-streaming table block — ``renderer.render()`` returns + # ``""`` for it mid-stream and only ``renderer.finish()`` releases the full + # ``| a | b | ... | 1 | 2 |`` markdown. The final flush MUST run after + # ``finish()`` so the last draft carries the released table. + # + # With the flush BEFORE finish (the mutation), the post-finish flush is + # gone and the only draft would carry the held-back ``""`` render — so the + # draft-body assertion below FAILS on that ordering. The whole table is + # supplied in one chunk with interval MAX so no mid-stream flush fires; the + # post-finish flush is the sole draft. + @pytest.mark.asyncio + async def test_flushes_trailing_table_like_block_after_renderer_finish(self): + table = "| a | b |\n| - | - |\n| 1 | 2 |" + adapter = _make_adapter() + calls = _script_fetch( + adapter, + [ + True, # sendRichMessageDraft (the post-finish flush) + _sample_message(text=None, rich_message=_rich_message("table")), + ], + ) + + await adapter.stream(DM_THREAD_ID, _text_stream([table]), _stream_options(2**53 - 1)) + + assert [method for method, _payload in calls] == ["sendRichMessageDraft", "sendRichMessage"] + draft_body = calls[0][1] + final_body = calls[1][1] + # The held-back table only appears in the draft because the flush ran + # AFTER finish; a pre-finish flush would have sent an empty render. + assert draft_body["rich_message"]["markdown"] == table + assert final_body["rich_message"]["markdown"] == table - # it("keeps markdown parse mode for an exact-limit draft and final message") + # it("keeps rich markdown for a draft and final message") -- the rich + # markdown is sent verbatim (no MarkdownV2 re-render). A long body whose + # ``**ok**`` survives intact proves the rich path bypasses the escaping + # MarkdownV2 renderer entirely. @pytest.mark.asyncio - async def test_keeps_markdown_parse_mode_for_an_exactlimit_draft_and_final_message(self): + async def test_keeps_rich_markdown_for_draft_and_final_without_markdownv2_rerender(self): long_markdown = "a" * 3494 + "**ok**" - rendered_markdown = "a" * 3494 + "*ok*" adapter = _make_adapter() - calls = _script_fetch(adapter, [True, True, _sample_message(text="a" * 3494 + "ok", message_id=41)]) + calls = _method_fetch( + adapter, + { + "sendRichMessageDraft": True, + "sendRichMessage": lambda: _sample_message(text=None, message_id=41, rich_message=_rich_message("ok")), + }, + ) result = await adapter.stream( DM_THREAD_ID, _text_stream([long_markdown]), - # JS Number.MAX_SAFE_INTEGER: no mid-stream flush; only the - # end-of-stream flush ships the rendered draft. _stream_options(2**53 - 1), ) assert result is not None assert [ - (method, len(payload["text"]), payload["text"][-10:], payload.get("parse_mode")) + (method, len(payload["rich_message"]["markdown"]), payload["rich_message"]["markdown"][-10:]) for method, payload in calls ] == [ - ("sendMessageDraft", 0, "", None), - ( - "sendMessageDraft", - len(rendered_markdown), - rendered_markdown[-10:], - "MarkdownV2", - ), - ( - "sendMessage", - len(rendered_markdown), - rendered_markdown[-10:], - "MarkdownV2", - ), + ("sendRichMessageDraft", len(long_markdown), long_markdown[-10:]), + ("sendRichMessage", len(long_markdown), long_markdown[-10:]), ] - - draft_payload = calls[1][1] - final_payload = calls[2][1] - # Exact bodies: no ellipsis was appended and the MarkdownV2-safe - # boundary trim left the balanced ``*ok*`` pair intact. - assert draft_payload["text"] == rendered_markdown - assert final_payload["text"] == rendered_markdown + # The literal ``**ok**`` markers survive — never escaped to ``\*\*``. + assert calls[0][1]["rich_message"]["markdown"] == long_markdown + assert calls[1][1]["rich_message"]["markdown"] == long_markdown # it("returns null for non-DM streaming so Chat SDK can use fallback streaming") @pytest.mark.asyncio @@ -208,33 +371,52 @@ async def test_returns_null_for_nondm_streaming_so_chat_sdk_can_use_fallback_str # Delegation happens before any chunk is consumed or API call made. assert calls == [] - # it("falls back to a final message when draft streaming updates fail") + # it("renders MarkdownV2 when rich draft streaming is unavailable") + # + # HAZARD 4a (404/ResourceNotFound -> permanent flag flip): the first rich + # draft 404s with ``method not found``; the stream demotes to the + # MarkdownV2 ``sendMessageDraft`` path AND latches + # ``_rich_messages_available`` to ``False`` permanently. The final send + # goes through ``sendMessage`` (MarkdownV2), not ``sendRichMessage``. @pytest.mark.asyncio - async def test_falls_back_to_a_final_message_when_draft_streaming_updates_fail(self): + async def test_renders_markdownv2_when_rich_draft_streaming_is_unavailable(self): logger = MockLogger() adapter = _make_adapter(logger=logger) calls = _script_fetch( adapter, [ - ValidationError("telegram", "Bad Request: chat not found"), - _sample_message(), + ValidationError("telegram", "Not Found: method not found"), # rich draft missing + True, # MarkdownV2 sendMessageDraft accepted + _sample_message(text="hello world"), # final sendMessage ], ) - result = await adapter.stream(DM_THREAD_ID, _text_stream(["hello", " world"]), _stream_options(0)) + result = await adapter.stream(DM_THREAD_ID, _text_stream(["**hello**"]), _stream_options(0)) assert result is not None assert result.id == "123:11" assert any( - call[0] == "Telegram draft streaming update failed" and call[1]["thread_id"] == DM_THREAD_ID + call[0] == "Telegram rich draft failed; retrying with a regular draft" + and call[1]["thread_id"] == DM_THREAD_ID for call in logger.warn.calls ) - # The failed (non-parse-error) initial draft disables draft - # updates; the stream still persists the final message. - assert [method for method, _payload in calls] == ["sendMessageDraft", "sendMessage"] + methods = [method for method, _payload in calls] + assert methods == ["sendRichMessageDraft", "sendMessageDraft", "sendMessage"] + + fallback_draft = calls[1][1] + assert fallback_draft["chat_id"] == DM_CHAT_ID + assert fallback_draft["parse_mode"] == "MarkdownV2" + assert fallback_draft["text"] == "*hello*" + # The endpoint-missing failure latched rich off for the instance. + assert adapter._rich_messages_available is False # it("continues to the final message when markdown draft retry also fails") + # + # Full ladder exercised: rich draft 404s (demote to MarkdownV2 + latch + # off), MarkdownV2 draft hits a can't-parse 400 (demote to plain), the + # plain retry 429s (draft updates disabled), yet the final plain + # ``sendMessage`` still persists the message. @pytest.mark.asyncio async def test_continues_to_the_final_message_when_markdown_draft_retry_also_fails(self): logger = MockLogger() @@ -242,10 +424,10 @@ async def test_continues_to_the_final_message_when_markdown_draft_retry_also_fai calls = _script_fetch( adapter, [ - True, # initial empty draft - _parse_entities_error(), # markdown draft rejected + ValidationError("telegram", "Not Found: method not found"), # rich draft missing + _parse_entities_error(), # MarkdownV2 draft rejected AdapterRateLimitError("telegram", 1), # plain retry 429s - _sample_message(text="**broken"), + _sample_message(text="**broken"), # final plain send ], ) @@ -262,93 +444,87 @@ async def test_continues_to_the_final_message_when_markdown_draft_retry_also_fai assert final_send.get("parse_mode") is None assert final_send["text"] == "**broken" - # it("falls back to plain-text draft and final send when Telegram can't - # parse streamed markdown") + # Python-only: a transient rich-draft ``can't parse`` failure demotes the + # CURRENT stream to MarkdownV2 but must NOT latch rich off — the flag stays + # True so the next stream tries rich again. Pins HAZARD 4 from the draft + # side (can_fallback allows fallback; remember_failure does not flip). @pytest.mark.asyncio - async def test_falls_back_to_plaintext_draft_and_final_send_when_telegram_cant_parse_streamed_markdown(self): + async def test_transient_rich_draft_failure_does_not_latch_rich_off(self): adapter = _make_adapter() calls = _script_fetch( adapter, [ - True, # initial empty draft - _parse_entities_error(), # markdown draft rejected - True, # plain retry accepted - _sample_message(text="**broken"), + _rich_cant_parse_error(), # transient rich draft failure + True, # MarkdownV2 sendMessageDraft accepted + _sample_message(text="hello"), # final sendMessage ], ) - result = await adapter.stream(DM_THREAD_ID, _text_stream(["**broken"]), _stream_options(0)) + result = await adapter.stream(DM_THREAD_ID, _text_stream(["**hello**"]), _stream_options(0)) assert result is not None - assert result.id == "123:11" + # Demoted this stream to the MarkdownV2 path... + assert [m for m, _ in calls] == ["sendRichMessageDraft", "sendMessageDraft", "sendMessage"] + # ...but rich remains available for the next message. + assert adapter._rich_messages_available is True - retry_draft = calls[2][1] - final_send = calls[3][1] - assert calls[2][0] == "sendMessageDraft" - assert "parse_mode" not in retry_draft - assert retry_draft["text"] == "**broken" - assert calls[3][0] == "sendMessage" - assert final_send.get("parse_mode") is None - assert final_send["text"] == "**broken" - - # it("reuses original text when streamed plain-text fallback would be empty") + # it("falls back to plain-text draft and final send when Telegram can't + # parse streamed markdown") -- rich disabled up front (flag already + # False), MarkdownV2 draft rejected, plain retry accepted, plain final. @pytest.mark.asyncio - async def test_reuses_original_text_when_streamed_plaintext_fallback_would_be_empty(self): + async def test_falls_back_to_plaintext_draft_and_final_send_when_telegram_cant_parse_streamed_markdown(self): adapter = _make_adapter() + adapter._rich_messages_available = False # rich endpoint known-missing calls = _script_fetch( adapter, [ - True, # initial empty draft - _parse_entities_error(), # markdown draft rejected + _parse_entities_error(), # MarkdownV2 draft rejected True, # plain retry accepted - _sample_message(text="**"), + _sample_message(text="**broken"), # final plain send ], ) - result = await adapter.stream(DM_THREAD_ID, _text_stream(["**"]), _stream_options(0)) + result = await adapter.stream(DM_THREAD_ID, _text_stream(["**broken"]), _stream_options(0)) assert result is not None assert result.id == "123:11" - # Upstream's remark renders ``**`` to an empty plain string, so the - # fallback resolver reuses the original text. Our parser keeps the - # unpaired markers literal (``**`` plain-renders as ``**``); both - # paths converge on shipping the original text verbatim. The - # empty-fallback branch itself is pinned by - # test_resolve_telegram_fallback_text_reuses_original_when_fallback_is_blank. - retry_draft = calls[2][1] - final_send = calls[3][1] + retry_draft = calls[1][1] + final_send = calls[2][1] + assert calls[0][0] == "sendMessageDraft" + assert calls[1][0] == "sendMessageDraft" assert "parse_mode" not in retry_draft - assert retry_draft["text"] == "**" + assert retry_draft["text"] == "**broken" + assert calls[2][0] == "sendMessage" assert final_send.get("parse_mode") is None - assert final_send["text"] == "**" - - # Python-only: direct adapter.stream call with options omitted entirely. - # Thread.post never hits this (thread.py seeds updateIntervalMs with the - # 500ms thread default before calling the adapter, matching upstream - # thread.ts), but a direct adapter.stream(...) call passes options=None, - # so update_interval_ms=None must resolve to - # TELEGRAM_DEFAULT_STREAM_UPDATE_INTERVAL_MS instead of crashing. + assert final_send["text"] == "**broken" + + # Python-only: direct adapter.stream call with options omitted entirely + # still defaults update_interval_ms instead of crashing. With rich enabled + # the drafts go through sendRichMessageDraft and the final via + # sendRichMessage. @pytest.mark.asyncio async def test_stream_defaults_update_interval_when_options_omitted(self): adapter = _make_adapter() - calls = _script_fetch(adapter, [True, True, _sample_message()]) + calls = _script_fetch( + adapter, + [True, True, _sample_message(text=None, rich_message=_rich_message("hello world"))], + ) result = await adapter.stream(DM_THREAD_ID, _text_stream(["hello", " world"])) assert result is not None assert result.id == "123:11" - # Initial empty draft first, persisted message last, and nothing - # but draft updates in between (count depends on wall-clock timing - # against the 250ms default, so only the shape is asserted). - assert calls[0][0] == "sendMessageDraft" - assert calls[0][1]["text"] == "" - assert calls[-1][0] == "sendMessage" - assert all(method == "sendMessageDraft" for method, _payload in calls[:-1]) - - # Python-only: the empty-stream contract from upstream's stream() body - # (`if (!accumulated.trim()) throw`) — whitespace-only streams must - # raise instead of persisting a blank message. + # Final rich send last; only rich drafts before it (count depends on + # wall-clock timing against the 250ms default, so only shape asserted). + assert calls[-1][0] == "sendRichMessage" + assert all(method == "sendRichMessageDraft" for method, _payload in calls[:-1]) + + # Python-only: the empty-stream contract — whitespace-only streams raise + # BEFORE any final send. With no opening draft, the only call is the + # mid-stream rich draft for the whitespace chunk (the rich render of + # ``" "`` is non-empty), after which ``accumulated.strip()`` is empty and + # the stream raises rather than persisting a blank message. @pytest.mark.asyncio async def test_stream_rejects_whitespace_only_streams(self): adapter = _make_adapter() @@ -357,25 +533,291 @@ async def test_stream_rejects_whitespace_only_streams(self): with pytest.raises(ValidationError, match="requires text content"): await adapter.stream(DM_THREAD_ID, _text_stream([" "]), _stream_options(0)) - # Only the initial empty draft went out; whitespace renders to "" - # which matches the last draft text, so flushes were skipped. - assert [method for method, _payload in calls] == ["sendMessageDraft"] + # Only the mid-stream rich draft went out; no final send was attempted. + assert [method for method, _payload in calls] == ["sendRichMessageDraft"] # ============================================================================= -# Tests -- retry without parse_mode (post_message / edit_message) +# Tests -- rich send/edit fallback ladder (post_message / edit_message) +# ============================================================================= + + +class TestTelegramRichSendFallback: + """it() blocks for the rich -> MarkdownV2 -> plain ladder on post/edit.""" + + # it("falls back from rich messages to plain text when Telegram can't + # parse markdown") + # + # HAZARD 3 + 4b: a transient ``can't parse rich message`` lets the message + # fall back (rich -> MarkdownV2 -> plain) but does NOT latch rich off. + @pytest.mark.asyncio + async def test_falls_back_from_rich_to_plain_text_on_cant_parse(self): + logger = MockLogger() + adapter = _make_adapter(logger=logger) + calls = _script_fetch( + adapter, + [ + _rich_cant_parse_error(), # rich send rejected (transient) + _parse_entities_error(), # MarkdownV2 send rejected + _sample_message(text="**broken"), # plain send accepted + ], + ) + + result = await adapter.post_message(DM_THREAD_ID, {"markdown": "**broken"}) + + assert result.id == "123:11" + + assert calls[0][0] == "sendRichMessage" + assert calls[0][1]["rich_message"]["markdown"] == "**broken" + assert calls[1][0] == "sendMessage" + assert calls[1][1]["parse_mode"] == "MarkdownV2" + assert calls[2][0] == "sendMessage" + assert calls[2][1].get("parse_mode") is None + assert calls[2][1]["text"] == "**broken" + assert any( + call[0] == "Telegram rich message failed; retrying with a regular message" + and call[1]["method"] == "sendRichMessage" + and call[1]["thread_id"] == DM_THREAD_ID + for call in logger.warn.calls + ) + # Transient parse failure: rich stays enabled for the next message. + assert adapter._rich_messages_available is True + + # it("caches unavailable rich message endpoints") + # + # HAZARD 4a (permanent flag flip): a 404 ``method not found`` on the first + # rich send latches ``_rich_messages_available`` off, so the SECOND post + # skips ``sendRichMessage`` and goes straight to ``sendMessage``. + @pytest.mark.asyncio + async def test_caches_unavailable_rich_message_endpoints(self): + adapter = _make_adapter() + calls = _script_fetch( + adapter, + [ + _method_not_found_error(), # first rich send 404s + _sample_message(), # first message falls back to sendMessage + _sample_message(message_id=12), # second message: sendMessage directly + ], + ) + + await adapter.post_message(DM_THREAD_ID, {"markdown": "first"}) + assert adapter._rich_messages_available is False + + await adapter.post_message(DM_THREAD_ID, {"markdown": "second"}) + + assert calls[0][0] == "sendRichMessage" + assert calls[1][0] == "sendMessage" + # The second message never attempts rich — the flag stayed flipped. + assert calls[2][0] == "sendMessage" + + # it("does not swallow non-parse validation errors during markdown send") + @pytest.mark.asyncio + async def test_does_not_swallow_nonparse_validation_errors_during_rich_send(self): + adapter = _make_adapter() + calls = _script_fetch( + adapter, + [ValidationError("telegram", "Bad Request: chat not found")], + ) + + with pytest.raises(ValidationError, match="chat not found"): + await adapter.post_message(DM_THREAD_ID, {"markdown": "**broken**"}) + + # A non-rich-related validation error is NOT a fallback trigger: the + # rich send raises and nothing else is attempted. + assert [method for method, _payload in calls] == ["sendRichMessage"] + assert adapter._rich_messages_available is True + + # it("does not treat unrelated unsupported errors as rich message failures") + # -- ``message thread is unsupported`` lacks the ``rich message`` token, so + # canFallbackFromRichMessage rejects it and the error propagates. + @pytest.mark.asyncio + async def test_does_not_treat_unrelated_unsupported_errors_as_rich_failures(self): + adapter = _make_adapter() + calls = _script_fetch( + adapter, + [ValidationError("telegram", "Bad Request: message thread is unsupported")], + ) + + with pytest.raises(ValidationError, match="message thread is unsupported"): + await adapter.post_message(DM_THREAD_ID, {"markdown": "**hello**"}) + + assert [method for method, _payload in calls] == ["sendRichMessage"] + assert adapter._rich_messages_available is True + + # it("falls back from rich edits to plain text when Telegram can't parse + # markdown") -- a ``rich message is unsupported`` edit failure latches + # rich off AND falls back through the MarkdownV2 -> plain edit ladder. + @pytest.mark.asyncio + async def test_falls_back_from_rich_edits_to_plain_text(self): + adapter = _make_adapter() + calls = _script_fetch( + adapter, + [ + _sample_message(), # post (rich send succeeds) + _rich_unsupported_error(), # rich edit unsupported -> latch off + _parse_entities_error(), # MarkdownV2 edit rejected + _sample_message(text="**broken"), # plain edit accepted + ], + ) + + posted = await adapter.post_message(DM_THREAD_ID, "hello") + result = await adapter.edit_message(DM_THREAD_ID, posted.id, {"markdown": "**broken"}) + + assert result.id == "123:11" + + # post(hello) is a plain string -> sendMessage (no rich). + assert calls[0][0] == "sendMessage" + assert calls[1][0] == "editMessageText" + assert calls[1][1]["rich_message"]["markdown"] == "**broken" + assert calls[2][0] == "editMessageText" + assert calls[2][1]["parse_mode"] == "MarkdownV2" + assert calls[3][0] == "editMessageText" + assert calls[3][1].get("parse_mode") is None + assert calls[3][1]["text"] == "**broken" + # ``rich message ... unsupported`` latched rich off. + assert adapter._rich_messages_available is False + + +# ============================================================================= +# Tests -- can_fallback_from_rich_message vs remember_rich_message_failure +# ============================================================================= + + +class TestRichFallbackPredicates: + """Direct unit tests for the two DISTINCT boolean shapes (HAZARD 4). + + ``can_fallback_from_rich_message`` decides whether a rich failure may + retry as a regular send. ``remember_rich_message_failure`` decides the + NARROWER set that also latches rich off permanently. A transient + ``can't parse`` qualifies for the former but NOT the latter. + """ + + def test_cant_parse_allows_fallback_but_does_not_latch(self): + adapter = _make_adapter() + err = _rich_cant_parse_error() + + assert adapter.can_fallback_from_rich_message(err, "sendRichMessage") is True + + adapter.remember_rich_message_failure(err, "sendRichMessage") + # Transient parse failure must leave rich enabled. + assert adapter._rich_messages_available is True + + def test_method_not_found_validation_allows_fallback_and_latches(self): + adapter = _make_adapter() + err = ValidationError("telegram", "Not Found: method not found") + + assert adapter.can_fallback_from_rich_message(err, "sendRichMessageDraft") is True + + adapter.remember_rich_message_failure(err, "sendRichMessageDraft") + assert adapter._rich_messages_available is False + + def test_unsupported_rich_validation_allows_fallback_and_latches(self): + adapter = _make_adapter() + err = _rich_unsupported_error() + + assert adapter.can_fallback_from_rich_message(err, "sendRichMessage") is True + + adapter.remember_rich_message_failure(err, "sendRichMessage") + assert adapter._rich_messages_available is False + + def test_resource_not_found_only_falls_back_for_send_rich_methods(self): + # The ResourceNotFound branch is gated on ``method.startswith + # ("sendRichMessage")``. A 404 attributed to a non-rich method is NOT a + # rich fallback trigger. + adapter = _make_adapter() + err = ResourceNotFoundError("telegram", "editMessageText") + + assert adapter.can_fallback_from_rich_message(err, "sendRichMessage") is True + assert adapter.can_fallback_from_rich_message(err, "editMessageText") is False + + def test_resource_not_found_latches_only_for_send_rich_methods(self): + adapter = _make_adapter() + + adapter.remember_rich_message_failure(ResourceNotFoundError("telegram", "editMessageText"), "editMessageText") + assert adapter._rich_messages_available is True + + adapter.remember_rich_message_failure(ResourceNotFoundError("telegram", "sendRichMessage"), "sendRichMessage") + assert adapter._rich_messages_available is False + + def test_unrelated_validation_error_neither_falls_back_nor_latches(self): + adapter = _make_adapter() + err = ValidationError("telegram", "Bad Request: message thread is unsupported") + + # ``unsupported`` without the ``rich message`` token does not qualify. + assert adapter.can_fallback_from_rich_message(err, "sendRichMessage") is False + + adapter.remember_rich_message_failure(err, "sendRichMessage") + assert adapter._rich_messages_available is True + + +# ============================================================================= +# Tests -- resolve_rich_message gate (HAZARD 3: 'raw' PRESENCE disjunction) +# ============================================================================= + + +class TestResolveRichMessageGate: + """Direct unit tests for resolve_rich_message's opt-out disjunction.""" + + def test_markdown_payload_resolves_to_rich(self): + adapter = _make_adapter() + rich = adapter.resolve_rich_message({"markdown": "**hi**"}, None, 0, 0) + assert rich is not None + assert rich.markdown == "**hi**" + + def test_raw_presence_opts_out_even_when_empty(self): + # ``"raw" in message`` is a PRESENCE check, not truthiness, and it + # short-circuits BEFORE the ``markdown`` branch. A payload carrying an + # EMPTY ``raw`` AND a ``markdown`` body still opts out of rich — the + # raw key alone disables it. A ``message.get("raw")`` truthiness + # mutation would treat the empty ``raw`` as absent, fall through to the + # ``markdown`` branch, and (wrongly) resolve a rich payload — so the + # ``is None`` assertion below FAILS on that mutation. (Without the + # co-present ``markdown`` key the gate is untestable: a bare + # ``{"raw": ""}`` resolves to ``None`` either way for lack of a body.) + adapter = _make_adapter() + assert adapter.resolve_rich_message({"raw": "", "markdown": "**hi**"}, None, 0, 0) is None + assert adapter.resolve_rich_message({"raw": "non-empty", "markdown": "**hi**"}, None, 0, 0) is None + + def test_plain_string_opts_out(self): + adapter = _make_adapter() + assert adapter.resolve_rich_message("plain", None, 0, 0) is None + + def test_card_opts_out(self): + adapter = _make_adapter() + assert adapter.resolve_rich_message({"markdown": "**hi**"}, object(), 0, 0) is None + + def test_file_or_attachment_opts_out(self): + adapter = _make_adapter() + assert adapter.resolve_rich_message({"markdown": "**hi**"}, None, 1, 0) is None + assert adapter.resolve_rich_message({"markdown": "**hi**"}, None, 0, 1) is None + + def test_unavailable_flag_opts_out(self): + adapter = _make_adapter() + adapter._rich_messages_available = False + assert adapter.resolve_rich_message({"markdown": "**hi**"}, None, 0, 0) is None + + +# ============================================================================= +# Tests -- retry without parse_mode (regular sendMessage path) # ============================================================================= class TestTelegramMarkdownParseFallback: - """it() blocks for withTelegramMarkdownFallback on post/edit.""" + """it() blocks for withTelegramMarkdownFallback on the regular send path. + + These exercise the SECOND tier (MarkdownV2 -> plain) directly by routing + through messages that skip rich (``raw`` payloads / rich-disabled + adapters), keeping the markdown-retry coverage from #340 intact. + """ # it("retries markdown messages without parse_mode when Telegram can't - # parse entities") + # parse entities") -- driven through a rich-disabled adapter so the + # ``{markdown}`` post lands directly on the MarkdownV2 sendMessage. @pytest.mark.asyncio async def test_retries_markdown_messages_without_parsemode_when_telegram_cant_parse_entities(self): logger = MockLogger() adapter = _make_adapter(logger=logger) + adapter._rich_messages_available = False calls = _script_fetch( adapter, [_parse_entities_error(), _sample_message(text="**broken")], @@ -387,9 +829,8 @@ async def test_retries_markdown_messages_without_parsemode_when_telegram_cant_pa first_send = calls[0][1] second_send = calls[1][1] - # First attempt ships the MarkdownV2 rendering (escaped markers). + assert calls[0][0] == "sendMessage" assert first_send["parse_mode"] == "MarkdownV2" - # Retry drops parse_mode and ships the plain-text rendering. assert second_send.get("parse_mode") is None assert second_send["text"] == "**broken" assert any( @@ -404,6 +845,7 @@ async def test_retries_markdown_messages_without_parsemode_when_telegram_cant_pa @pytest.mark.asyncio async def test_retries_markdown_messages_with_original_text_when_plaintext_fallback_would_be_empty(self): adapter = _make_adapter() + adapter._rich_messages_available = False calls = _script_fetch( adapter, [_parse_entities_error(), _sample_message(text="**")], @@ -421,6 +863,7 @@ async def test_retries_markdown_messages_with_original_text_when_plaintext_fallb @pytest.mark.asyncio async def test_does_not_swallow_nonparse_validation_errors_during_markdown_send(self): adapter = _make_adapter() + adapter._rich_messages_available = False calls = _script_fetch( adapter, [ValidationError("telegram", "Bad Request: chat not found")], @@ -438,6 +881,7 @@ async def test_does_not_swallow_nonparse_validation_errors_during_markdown_send( async def test_retries_markdown_edits_without_parsemode_when_telegram_cant_parse_entities(self): logger = MockLogger() adapter = _make_adapter(logger=logger) + adapter._rich_messages_available = False calls = _script_fetch( adapter, [ @@ -472,6 +916,7 @@ async def test_retries_markdown_edits_without_parsemode_when_telegram_cant_parse @pytest.mark.asyncio async def test_retries_markdown_edits_with_original_text_when_plaintext_fallback_would_be_empty(self): adapter = _make_adapter() + adapter._rich_messages_available = False calls = _script_fetch( adapter, [ @@ -490,10 +935,7 @@ async def test_retries_markdown_edits_with_original_text_when_plaintext_fallback assert second_edit.get("parse_mode") is None assert second_edit["text"] == "**" - # Python-only: pins the branch upstream exercises through remark's - # empty rendering of ``**`` — our parser keeps those markers literal, - # so the faithful tests above never produce a blank fallback. Without - # this, the ``fallback_text.strip()`` guard could regress unnoticed. + # Python-only: pins the empty-fallback branch of resolve_telegram_fallback_text. def test_resolve_telegram_fallback_text_reuses_original_when_fallback_is_blank(self): adapter = _make_adapter() diff --git a/tests/test_telegram_webhook.py b/tests/test_telegram_webhook.py index 0e18107..3ca2005 100644 --- a/tests/test_telegram_webhook.py +++ b/tests/test_telegram_webhook.py @@ -783,6 +783,33 @@ def test_message_without_rich_message_is_unaffected(self): assert parsed.text == "just text" assert parsed.formatted["children"][0]["type"] == "paragraph" + def test_empty_entities_list_does_not_fall_through_to_caption_entities(self): + # TG3 follow-up: `raw.entities ?? raw.caption_entities ?? []` is a + # NULLISH ladder. A present-but-EMPTY `entities: []` is a real value + # and short-circuits the chain, so the populated `caption_entities` + # are NOT applied. With the correct nullish ladder the bold entity + # never fires and `.text` stays the bare plain text. + # + # A truthy-`or` mutation (`entities or caption_entities`) would treat + # the empty list as falsy, reach for `caption_entities`, and wrap the + # span in `**...**` — so this assertion FAILS on that mutation. + adapter = _make_adapter() + msg = _sample_message( + text="bold body", + entities=[], + caption_entities=[{"type": "bold", "offset": 0, "length": 9}], + ) + parsed = adapter.parse_message(msg) + assert parsed.text == "bold body" + # Sanity anchor: the SAME entity, supplied via `entities`, DOES apply + # — proving the bold entity is load-bearing and the test above is not + # passing for an unrelated reason. + msg_applied = _sample_message( + text="bold body", + entities=[{"type": "bold", "offset": 0, "length": 9}], + ) + assert adapter.parse_message(msg_applied).text == "**bold body**" + class TestTelegramParseInboundRichMedia: """extractAttachments handling of media nested in ``rich_message`` blocks."""