Skip to content

Commit 287d143

Browse files
fix(telegram): JS-truthy empty-list rich-text gates + mutation coverage
Render optional rich-text fields (credit/caption/cell text) when the value is an empty list []. Upstream gates these with `value.x ? ... : ""`; JS arrays are always truthy, so credit: [] renders a blank credit, while the prior Python-truthiness gate wrongly skipped it. Add a _present() helper that renders for everything except None/absent and the empty string "". Strip with the exact ECMAScript WhiteSpace+LineTerminator set (JS_WHITESPACE) instead of Python's broader Unicode-whitespace strip(), matching JS trimEnd()/trim() character-for-character. Add regression tests, each verified to fail on its mutation: - empty-list credit renders (truthiness trap) - no-backtick pre block emits a 3-backtick fence (max(3,...)/else 3) - truncation reserves room for the ellipsis (end = LIMIT - 3) - exact-limit-length input passes through unchanged (<= boundary)
1 parent 626141a commit 287d143

2 files changed

Lines changed: 143 additions & 16 deletions

File tree

src/chat_sdk/adapters/telegram/rich.py

Lines changed: 54 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,43 @@
3939
# Upstream: /`+/g -- runs of one or more backticks.
4040
BACKTICKS = re.compile(r"`+")
4141

42+
# The exact set of characters JS ``String.prototype.trim``/``trimEnd`` removes:
43+
# the ECMAScript WhiteSpace set plus the LineTerminator set. Python's bare
44+
# ``str.strip()``/``str.rstrip()`` strip a broader Unicode-whitespace set (e.g.
45+
# the C0 separators ``\x1c``-``\x1f`` and NEL ``\x85``, which JS keeps; and they
46+
# do NOT strip the BOM ````, which JS removes). Passing this explicit
47+
# string to ``strip``/``rstrip`` matches JS character-for-character.
48+
JS_WHITESPACE = (
49+
"\t\n\v\f\r " # \t \n \v \f \r and SPACE
50+
" " # NO-BREAK SPACE
51+
" " # OGHAM SPACE MARK
52+
"           " # EN QUAD..HAIR SPACE
53+
"
" # LINE SEPARATOR
54+
"
" # PARAGRAPH SEPARATOR
55+
" " # NARROW NO-BREAK SPACE
56+
" " # MEDIUM MATHEMATICAL SPACE
57+
" " # IDEOGRAPHIC SPACE
58+
"" # ZERO WIDTH NO-BREAK SPACE (BOM)
59+
)
60+
61+
62+
def _present(value: TelegramRichText | None) -> bool:
63+
"""Match JS truthiness for an optional ``TelegramRichText`` field.
64+
65+
Upstream gates these fields with ``value.x ? ... : ""``. The only values
66+
a ``TelegramRichText`` (``str | list | object``) can take that differ from
67+
Python truthiness is the empty list ``[]``: JS arrays are ALWAYS truthy, so
68+
``credit: []`` renders upstream but a bare ``if value.get("credit")`` would
69+
skip it in Python. We therefore render whenever the value is present and is
70+
not the empty string ``""`` (the only falsy ``TelegramRichText`` in JS):
71+
72+
- ``None`` / absent -> skip (JS ``undefined`` is falsy)
73+
- ``""`` -> skip (JS ``""`` is falsy)
74+
- ``[]`` -> render (JS arrays are truthy)
75+
- ``[span]`` / ``"text"`` / object -> render (truthy)
76+
"""
77+
return value is not None and value != ""
78+
4279

4380
def truncate_rich_markdown(markdown: str) -> str:
4481
"""Truncate *markdown* to the Telegram rich-message character limit.
@@ -81,7 +118,7 @@ def _inline_code(value: str) -> str:
81118
runs = BACKTICKS.findall(value)
82119
size = max(1, *(len(run) + 1 for run in runs)) if runs else 1
83120
marker = "`" * size
84-
has_boundary_space = value.startswith(" ") and value.endswith(" ") and len(value.strip()) > 0
121+
has_boundary_space = value.startswith(" ") and value.endswith(" ") and len(value.strip(JS_WHITESPACE)) > 0
85122
padding = " " if value.startswith("`") or value.endswith("`") or has_boundary_space else ""
86123
return f"{marker}{padding}{value}{padding}{marker}"
87124

@@ -196,46 +233,46 @@ def _plain(markdown: TelegramRichText) -> str:
196233
def _caption(value: TelegramRichCaption | None = None) -> str:
197234
if not value:
198235
return ""
199-
credit = f"\n{_text(value['credit'])}" if value.get("credit") else ""
236+
credit = f"\n{_text(value['credit'])}" if _present(value.get("credit")) else ""
200237
return f"{_text(value['text'])}{credit}"
201238

202239

203240
def _plain_caption(value: TelegramRichCaption | None = None) -> str:
204241
if not value:
205242
return ""
206-
credit = f"\n{_plain(value['credit'])}" if value.get("credit") else ""
243+
credit = f"\n{_plain(value['credit'])}" if _present(value.get("credit")) else ""
207244
return f"{_plain(value['text'])}{credit}"
208245

209246

210247
def _cell(value: TelegramRichCell) -> str:
211-
return _text(value["text"]) if value.get("text") else ""
248+
return _text(value["text"]) if _present(value.get("text")) else ""
212249

213250

214251
def _item(value: TelegramRichItem) -> str:
215252
checked = ""
216253
if value.get("has_checkbox"):
217254
checked = "[x] " if value.get("is_checked") else "[ ] "
218255
content = "\n\n".join(_block(b) for b in value["blocks"]).replace("\n", "\n ")
219-
return f"{value['label']} {checked}{content}".rstrip()
256+
return f"{value['label']} {checked}{content}".rstrip(JS_WHITESPACE)
220257

221258

222259
def _plain_item(value: TelegramRichItem) -> str:
223260
checked = ""
224261
if value.get("has_checkbox"):
225262
checked = "[x] " if value.get("is_checked") else "[ ] "
226263
content = "\n".join(b for b in (_plain_block(blk) for blk in value["blocks"]) if b).replace("\n", "\n ")
227-
return f"{value['label']} {checked}{content}".rstrip()
264+
return f"{value['label']} {checked}{content}".rstrip(JS_WHITESPACE)
228265

229266

230267
def _table(value: TelegramRichBlockTable) -> str:
231268
rows = [f"| {' | '.join(_cell(c) for c in row)} |" for row in value["cells"]]
232269
if len(rows) == 0:
233-
return _text(value["caption"]) if value.get("caption") else ""
270+
return _text(value["caption"]) if _present(value.get("caption")) else ""
234271

235272
columns = max(len(row) for row in value["cells"])
236273
separator = f"| {' | '.join('---' for _ in range(columns))} |"
237274
content = "\n".join([rows[0], separator, *rows[1:]])
238-
return f"{_text(value['caption'])}\n\n{content}" if value.get("caption") else content
275+
return f"{_text(value['caption'])}\n\n{content}" if _present(value.get("caption")) else content
239276

240277

241278
def _quote(value: str) -> str:
@@ -261,10 +298,10 @@ def _block(value: TelegramRichBlock) -> str:
261298
return "\n".join(_item(i) for i in value["items"])
262299
case "blockquote":
263300
content = "\n\n".join(_block(b) for b in value["blocks"])
264-
credit = f"\n\n{_text(value['credit'])}" if value.get("credit") else ""
301+
credit = f"\n\n{_text(value['credit'])}" if _present(value.get("credit")) else ""
265302
return _quote(f"{content}{credit}")
266303
case "pullquote":
267-
credit = f"\n\n{_text(value['credit'])}" if value.get("credit") else ""
304+
credit = f"\n\n{_text(value['credit'])}" if _present(value.get("credit")) else ""
268305
return _quote(f"{_text(value['text'])}{credit}")
269306
case "collage" | "slideshow":
270307
content = "\n\n".join(b for b in (_block(blk) for blk in value["blocks"]) if b)
@@ -295,21 +332,22 @@ def _plain_block(value: TelegramRichBlock) -> str:
295332
return "\n".join(_plain_item(i) for i in value["items"])
296333
case "blockquote":
297334
content = "\n\n".join(b for b in (_plain_block(blk) for blk in value["blocks"]) if b)
298-
credit = f"\n\n{_plain(value['credit'])}" if value.get("credit") else ""
335+
credit = f"\n\n{_plain(value['credit'])}" if _present(value.get("credit")) else ""
299336
return f"{content}{credit}"
300337
case "pullquote":
301-
credit = f"\n\n{_plain(value['credit'])}" if value.get("credit") else ""
338+
credit = f"\n\n{_plain(value['credit'])}" if _present(value.get("credit")) else ""
302339
return f"{_plain(value['text'])}{credit}"
303340
case "collage" | "slideshow":
304341
content = "\n\n".join(b for b in (_plain_block(blk) for blk in value["blocks"]) if b)
305342
description = _plain_caption(value.get("caption"))
306343
return "\n\n".join(part for part in (content, description) if part)
307344
case "table":
308345
rows = [
309-
"\t".join(_plain(entry["text"]) if entry.get("text") else "" for entry in row) for row in value["cells"]
346+
"\t".join(_plain(entry["text"]) if _present(entry.get("text")) else "" for entry in row)
347+
for row in value["cells"]
310348
]
311349
content = "\n".join(rows)
312-
return f"{_plain(value['caption'])}\n\n{content}" if value.get("caption") else content
350+
return f"{_plain(value['caption'])}\n\n{content}" if _present(value.get("caption")) else content
313351
case "details":
314352
body = "\n\n".join(b for b in (_plain_block(blk) for blk in value["blocks"]) if b)
315353
return f"{_plain(value['summary'])}\n\n{body}"
@@ -403,12 +441,12 @@ def _media(blocks: list[TelegramRichBlock], result: list[RichMedia]) -> None:
403441

404442
def rich_message_to_markdown(message: TelegramRichMessage) -> str:
405443
"""Render a Telegram rich message to Markdown."""
406-
return "\n\n".join(b for b in (_block(blk) for blk in message["blocks"]) if b).strip()
444+
return "\n\n".join(b for b in (_block(blk) for blk in message["blocks"]) if b).strip(JS_WHITESPACE)
407445

408446

409447
def rich_message_to_text(message: TelegramRichMessage) -> str:
410448
"""Render a Telegram rich message to plain text."""
411-
return "\n\n".join(b for b in (_plain_block(blk) for blk in message["blocks"]) if b).strip()
449+
return "\n\n".join(b for b in (_plain_block(blk) for blk in message["blocks"]) if b).strip(JS_WHITESPACE)
412450

413451

414452
def rich_message_media(message: TelegramRichMessage) -> list[RichMedia]:

tests/test_telegram_rich.py

Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@
3232
TelegramRichBlockList,
3333
TelegramRichBlockPhoto,
3434
TelegramRichBlockPre,
35+
TelegramRichBlockPullquote,
3536
TelegramRichBlockTable,
3637
TelegramRichBlockText,
3738
TelegramRichCell,
@@ -347,3 +348,91 @@ def test_truncation_passes_through_under_limit_text_unchanged() -> None:
347348
text = "😀" * 5
348349
assert truncate_rich_markdown(text) == text
349350
assert truncate_rich_markdown("plain") == "plain"
351+
352+
353+
# ---------------------------------------------------------------------------
354+
# Fidelity: empty-LIST rich-text field renders (JS arrays are truthy)
355+
# ---------------------------------------------------------------------------
356+
357+
358+
def test_empty_list_credit_renders_like_a_present_credit() -> None:
359+
"""An empty-list ``credit`` ([]) renders, matching JS array truthiness.
360+
361+
Upstream gates the credit with ``value.credit ? ... : ""``; a JS array is
362+
ALWAYS truthy, so ``credit: []`` produces a blank credit (a leading-newline
363+
empty line in the quote), whereas absent / empty-string skip it. A bare
364+
Python-truthiness gate (``if value.get("credit")``) would wrongly skip
365+
``[]`` -- this test fails under that regression.
366+
"""
367+
# credit=[] -> truthy in JS -> credit segment "\n\n" + text([]) ("") renders.
368+
# quote("Quote\n\n") -> "> Quote\n> \n> " -> trailing ws trimmed by .strip().
369+
with_empty_list: TelegramRichBlockPullquote = {"type": "pullquote", "text": "Quote", "credit": []}
370+
assert rich_message_to_markdown({"blocks": [with_empty_list]}) == "> Quote\n> \n>"
371+
372+
# Absent credit -> JS undefined is falsy -> skipped entirely.
373+
absent: TelegramRichBlockPullquote = {"type": "pullquote", "text": "Quote"}
374+
assert rich_message_to_markdown({"blocks": [absent]}) == "> Quote"
375+
376+
# Empty-string credit -> JS "" is falsy -> skipped (the ONLY falsy RichText).
377+
empty_string: TelegramRichBlockPullquote = {"type": "pullquote", "text": "Quote", "credit": ""}
378+
assert rich_message_to_markdown({"blocks": [empty_string]}) == "> Quote"
379+
380+
# A real credit span renders the same blank-line-separated shape as [].
381+
with_author: TelegramRichBlockPullquote = {"type": "pullquote", "text": "Quote", "credit": "Author"}
382+
assert rich_message_to_markdown({"blocks": [with_author]}) == "> Quote\n> \n> Author"
383+
384+
385+
# ---------------------------------------------------------------------------
386+
# Mutation coverage: fence default width, truncation reserve, exact boundary
387+
# ---------------------------------------------------------------------------
388+
389+
390+
def test_pre_block_without_internal_backticks_emits_a_three_backtick_fence() -> None:
391+
"""A code/pre block with NO internal backticks uses the default 3-fence.
392+
393+
``_code_block`` sizes the fence as ``max(3, longest_run + 1)`` and falls
394+
back to ``3`` when there are no backtick runs. This pins the no-run default:
395+
the mutations ``else 3`` -> ``else 2`` and ``max(3, ...)`` -> ``max(2, ...)``
396+
both shrink the fence to 2 backticks and fail this assertion.
397+
"""
398+
fence = "`" * 3
399+
pre: TelegramRichBlockPre = {"type": "pre", "language": "python", "text": "hello world"}
400+
markdown = rich_message_to_markdown({"blocks": [pre]})
401+
402+
assert markdown == f"{fence}python\nhello world\n{fence}"
403+
# Exactly three opening backticks -- never two.
404+
assert len(markdown) - len(markdown.lstrip("`")) == 3
405+
406+
407+
def test_truncation_reserves_room_for_the_ellipsis() -> None:
408+
"""Truncation reserves 3 chars for ``...`` so the result stays at the limit.
409+
410+
With an unclosed bold marker straddling the boundary, the correct reserve
411+
(``end = LIMIT - 3``) returns a result of length EXACTLY ``LIMIT`` on the
412+
first iteration. The mutation ``end = LIMIT`` over-shoots, the shrink loop
413+
diverges, and it returns a shorter (length ``LIMIT - 2``) result -- so an
414+
exact-length assertion fails under the mutation.
415+
"""
416+
# 'a' x (LIMIT-1) + '**' opens an unclosed bold span right at the boundary;
417+
# the trailing run keeps the input over the limit.
418+
over_limit = ("a" * (TELEGRAM_RICH_MESSAGE_LIMIT - 1)) + "**" + ("b" * 200)
419+
result = truncate_rich_markdown(over_limit)
420+
421+
# The ellipsis fits within the limit -- length is exactly the limit here,
422+
# never over it (mutation would yield LIMIT - 2, killing the equality).
423+
assert len(result) == TELEGRAM_RICH_MESSAGE_LIMIT
424+
assert result.endswith("...")
425+
426+
427+
def test_exact_limit_length_passes_through_unchanged() -> None:
428+
"""A string of length EXACTLY ``LIMIT`` is returned verbatim (no ``...``).
429+
430+
The early-return guard is ``len(characters) <= LIMIT``. At the boundary
431+
``len == LIMIT`` the input must pass through; the mutation ``<=`` -> ``<``
432+
would instead truncate it (appending ``...``), failing this assertion.
433+
"""
434+
exact = "a" * TELEGRAM_RICH_MESSAGE_LIMIT
435+
result = truncate_rich_markdown(exact)
436+
437+
assert result == exact
438+
assert not result.endswith("...")

0 commit comments

Comments
 (0)