feat: Telegram messages get real formatting + push DMs at draft origination (#568)

* feat(telegram): HTML-styled bot messages + push DMs at held-draft origination

* fix(telegram): attr-context escaping, balance-aware truncation, send observability; docs

---------

Co-authored-by: Renn F <rennf93@users.noreply.github.com>
This commit is contained in:
Renzo F
2026-07-18 15:54:49 +02:00
committed by GitHub
co-authored by Renn F
parent f0782cb858
commit ff78618b76
17 changed files with 1077 additions and 83 deletions
@@ -6,12 +6,17 @@ from __future__ import annotations
import pytest
from roboco.config import settings as cfg
from roboco.services.telegram_inbound import (
_MESSAGE_CHAR_LIMIT,
ParsedCallback,
_authorized_chat,
_esc,
_esc_attr,
_truncate,
build_action_keyboard,
build_callback,
parse_callback,
parse_command,
render_queue_item_text,
)
_CALLBACK_DATA_MAX_BYTES = 64 # mirrors Telegram's own callback_data cap
@@ -134,3 +139,94 @@ class TestAuthorizedChat:
def test_empty_chat_id_rejected(self) -> None:
assert _authorized_chat("", "12345") is False
class TestEsc:
def test_escapes_angle_brackets_and_ampersand(self) -> None:
assert _esc("<b>bold&joke</b>") == "&lt;b&gt;bold&amp;joke&lt;/b&gt;"
def test_quotes_are_left_alone(self) -> None:
# quote=False — _esc renders HTML text nodes, where quotes need no
# escaping. Attribute values (e.g. href="...") go through _esc_attr
# instead, which does escape them.
assert _esc('it\'s "fine"') == 'it\'s "fine"'
def test_stringifies_non_str_values(self) -> None:
assert _esc(42) == "42"
class TestEscAttr:
def test_escapes_quotes_and_angle_brackets(self) -> None:
assert _esc_attr("""a"b'c<d>e""") == "a&quot;b&#x27;c&lt;d&gt;e"
def test_stringifies_non_str_values(self) -> None:
assert _esc_attr(42) == "42"
class TestTruncateHtml:
def test_short_text_is_untouched(self) -> None:
assert _truncate("hello") == "hello"
def test_backs_off_before_an_unclosed_angle_bracket(self) -> None:
# A naive slice at `limit - 1` would land inside "<code>", leaving a
# bare '<' Telegram's HTML parser can't make sense of — back off to
# before it instead.
text = ("x" * 4093) + "<code>"
result = _truncate(text)
assert result.endswith("")
assert not result.rstrip("").endswith("<")
def test_backs_off_before_an_unclosed_entity(self) -> None:
text = ("x" * 4093) + "&amp;"
result = _truncate(text)
assert result.endswith("")
assert "&am" not in result
@pytest.mark.parametrize("title_len", range(4048, 4069))
def test_render_queue_item_truncation_balances_code_tag(
self, title_len: int
) -> None:
# Regression: a naive char-count slice landed inside the trailing
# `<code>id8</code>` span, shipping an unclosed `<code>` Telegram's
# HTML parser rejects outright.
text = render_queue_item_text("roadmap", "abc12345", "item-0", "A" * title_len)
assert text.count("<code>") == text.count("</code>")
assert len(text) <= _MESSAGE_CHAR_LIMIT
def test_truncate_balances_a_bold_wrapped_tag(self) -> None:
text = "<b>" + ("y" * 4200) + "</b>"
result = _truncate(text)
assert result.count("<b>") == result.count("</b>")
assert len(result) <= _MESSAGE_CHAR_LIMIT
class TestRenderQueueItemText:
def test_escapes_html_in_title(self) -> None:
text = render_queue_item_text("task", "a1b2c3d4", "", "<b>bold&joke</b>")
assert "&lt;b&gt;bold&amp;joke&lt;/b&gt;" in text
assert "<b>bold&joke</b>" not in text
def test_kind_emoji_and_label_per_kind(self) -> None:
assert render_queue_item_text("release", "a1b2c3d4", "", "x").startswith(
"🚀 <b>Release</b>"
)
assert render_queue_item_text("video", "a1b2c3d4", "", "x").startswith(
"🎬 <b>Video</b>"
)
assert render_queue_item_text("xpost", "a1b2c3d4", "", "x").startswith(
"✕ <b>Post</b>"
)
assert render_queue_item_text("roadmap", "a1b2c3d4", "", "x").startswith(
"🗺️ <b>Roadmap</b>"
)
assert render_queue_item_text("task", "a1b2c3d4", "", "x").startswith(
"📋 <b>Task</b>"
)
def test_id8_and_extra_render_as_code_span(self) -> None:
text = render_queue_item_text("roadmap", "a1b2c3d4", "item-2", "x")
assert "<code>a1b2c3d4:item-2</code>" in text
def test_no_extra_omits_suffix(self) -> None:
text = render_queue_item_text("task", "a1b2c3d4", "", "x")
assert "<code>a1b2c3d4</code>" in text